diff --git a/src/common/abis/deployer.ts b/src/common/abis/deployer.ts index f04ea4d2..eed9e898 100644 --- a/src/common/abis/deployer.ts +++ b/src/common/abis/deployer.ts @@ -22,6 +22,16 @@ const _deployerV6 = [ `function eval4(${_EvalV4} calldata eval) external view returns (${_StackItem}[] calldata stack, bytes32[] calldata writes)`, ] as const; +// IRainlang, the registry contract of the newer rainlang stack that exposes the +// addresses of the four core contracts, in this stack the expression deployer is +// only the parser and integrity checker and no longer knows the interpreter and store +const _rainlang = [ + "function expressionDeployerAddress() external pure returns (address)", + "function interpreterAddress() external pure returns (address)", + "function storeAddress() external pure returns (address)", + "function parserAddress() external pure returns (address)", +] as const; + /** Keeps ExpressionDeployer related ABIs */ export namespace DeployerAbi { /** ExpressionDeployerNPE2 contract primary parsed ABI */ @@ -31,12 +41,18 @@ export namespace DeployerAbi { /** ExpressionDeployerNPE2 contract primary parsed ABI */ export const DeployerV6 = parseAbi(_deployerV6); + + /** IRainlang registry contract primary parsed ABI */ + export const Rainlang = parseAbi(_rainlang); } /** Deployer signature ABI */ export namespace Signatures { /** ExpressionDeployerNPE2 signature ABI */ export const deployer = _deployer; + + /** IRainlang registry signature ABI */ + export const rainlang = _rainlang; } /** Deployer and Interpreter structs ABI */ diff --git a/src/gas/index.test.ts b/src/gas/index.test.ts index 9ad57330..3cb32708 100644 --- a/src/gas/index.test.ts +++ b/src/gas/index.test.ts @@ -165,11 +165,11 @@ describe("Test GasManager", () => { } as any); // class field defaults - expect(manager.gasIncreasePointsPerStep).toBe(3); - expect(manager.gasIncreaseStepTime).toBe(60 * 60 * 1000); // 3_600_000 ms + expect(manager.gasIncreasePointsPerStep).toBe(10); + expect(manager.gasIncreaseStepTime).toBe(15 * 60 * 1000); // 900_000 ms - // maxGasPriceMultiplier defaults to base + 100 when not provided - expect(manager.maxGasPriceMultiplier).toBe(200); + // maxGasPriceMultiplier defaults to base + 1000 when not provided + expect(manager.maxGasPriceMultiplier).toBe(1100); // multiplier starts at the base value expect(manager.gasPriceMultiplier).toBe(100); diff --git a/src/gas/index.ts b/src/gas/index.ts index bc0df952..8bf3b8d1 100644 --- a/src/gas/index.ts +++ b/src/gas/index.ts @@ -63,9 +63,9 @@ export class GasManager { /** Maximum gas price multiplier */ readonly maxGasPriceMultiplier: number; /** The points to increase the gas price multiplier at each step */ - readonly gasIncreasePointsPerStep: number = 3; // default increase by 3 points + readonly gasIncreasePointsPerStep: number = 10; // default increase by 10 points /** The time to stay in increased the gas price multiplier before reseting to base */ - readonly gasIncreaseStepTime: number = 60 * 60 * 1000; // default 60 minutes in milliseconds + readonly gasIncreaseStepTime: number = 15 * 60 * 1000; // default 15 minutes in milliseconds /** The threshold for transaction time before considering it as a trigger for gas price multiplierincrease */ readonly txTimeThreshold: number; // default 15 seconds threshold @@ -94,7 +94,7 @@ export class GasManager { if (config.maxGasPriceMultiplier !== undefined) { this.maxGasPriceMultiplier = config.maxGasPriceMultiplier; } else { - this.maxGasPriceMultiplier = this.baseGasPriceMultiplier + 100; // default +1x ceiling + this.maxGasPriceMultiplier = this.baseGasPriceMultiplier + 1000; // default +10x ceiling } this.gasPriceMultiplier = config.baseGasPriceMultiplier; } diff --git a/src/order/quote.ts b/src/order/quote.ts index 8678a4c5..84e37573 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -145,7 +145,7 @@ export async function quoteSingleOrderV4( args: [TakeOrder.getQuoteConfig(orderDetails.takeOrder.struct)], }), gas, - blockTag: "pending", + // blockTag: "pending", }) .catch((error) => { orderDetails.takeOrder.quote = undefined; diff --git a/src/signer/actions.test.ts b/src/signer/actions.test.ts index 6f915323..6b49157a 100644 --- a/src/signer/actions.test.ts +++ b/src/signer/actions.test.ts @@ -10,6 +10,7 @@ import { sendTx, getTxGas, broadcastTx, + hasExplicitGasParams, tryGetReceipt, waitUntilFree, getSelfBalance, @@ -51,6 +52,7 @@ describe("Test sendTx", () => { to: "0xdestination" as `0x${string}`, data: "0xdata" as `0x${string}`, gas: 100000n, + gasPrice: 20000000000n, }; beforeEach(() => { @@ -220,6 +222,58 @@ describe("Test sendTx", () => { expect(wait).toBeTypeOf("function"); }); + it("should send through viem sendTransaction when gas limit is not set", async () => { + (mockSigner.sendTransaction as Mock).mockResolvedValue("0xviemhash"); + const tx = { to: mockTx.to, value: 1n }; + const { hash: txHash, wait } = await sendTx(mockSigner, tx as any); + + expect(mockSigner.sendTransaction).toHaveBeenCalledTimes(1); + expect(mockSigner.sendTransaction).toHaveBeenCalledWith({ ...tx, nonce: 5 }); + expect(mockSigner.account.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendRawTransaction).not.toHaveBeenCalled(); + expect(txHash).toBe("0xviemhash"); + expect(mockSigner.busy).toBe(true); + expect(wait).toBeTypeOf("function"); + }); + + it("should send through viem sendTransaction when gas price is not set", async () => { + (mockSigner.sendTransaction as Mock).mockResolvedValue("0xviemhash"); + const tx = { to: mockTx.to, data: mockTx.data, gas: 100000n }; + const { hash: txHash } = await sendTx(mockSigner, tx as any); + + expect(mockSigner.sendTransaction).toHaveBeenCalledTimes(1); + expect(mockSigner.sendTransaction).toHaveBeenCalledWith({ ...tx, nonce: 5 }); + expect(mockSigner.account.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendRawTransaction).not.toHaveBeenCalled(); + expect(txHash).toBe("0xviemhash"); + }); + + it("should sign locally when gas limit and eip1559 max fee are set", async () => { + const tx = { to: mockTx.to, data: mockTx.data, gas: 100000n, maxFeePerGas: 10n }; + const { hash: txHash } = await sendTx(mockSigner, tx as any); + + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + expect(mockSigner.account.signTransaction).toHaveBeenCalledWith( + { ...tx, nonce: 5, chainId: 1 }, + { serializer: undefined }, + ); + expect(mockSigner.sendRawTransaction).toHaveBeenCalledTimes(1); + expect(txHash).toBe("0xhash"); + }); + + it("should retry through viem sendTransaction on failure", async () => { + (mockSigner.sendTransaction as Mock) + .mockRejectedValueOnce(new Error("First attempt failed")) + .mockResolvedValueOnce("0xviemhash"); + const tx = { to: mockTx.to, value: 1n }; + const { hash: txHash } = await sendTx(mockSigner, tx as any, 10); + + expect(mockSigner.sendTransaction).toHaveBeenCalledTimes(2); + expect(mockSigner.account.signTransaction).not.toHaveBeenCalled(); + expect(txHash).toBe("0xviemhash"); + expect(mockSigner.busy).toBe(true); + }); + it("should successfully send a transaction on second attempt", async () => { (mockSigner.sendRawTransaction as Mock) .mockRejectedValueOnce(new Error("First attempt failed")) @@ -364,6 +418,17 @@ describe("Test isAlreadyKnownTxError", () => { }); }); +describe("Test hasExplicitGasParams", () => { + it("should be true only with gas limit and a gas price field", () => { + expect(hasExplicitGasParams({ to: "0x", gas: 1n, gasPrice: 1n } as any)).toBe(true); + expect(hasExplicitGasParams({ to: "0x", gas: 1n, maxFeePerGas: 1n } as any)).toBe(true); + expect(hasExplicitGasParams({ to: "0x", gas: 1n } as any)).toBe(false); + expect(hasExplicitGasParams({ to: "0x", gasPrice: 1n } as any)).toBe(false); + expect(hasExplicitGasParams({ to: "0x", maxFeePerGas: 1n } as any)).toBe(false); + expect(hasExplicitGasParams({ to: "0x", value: 1n } as any)).toBe(false); + }); +}); + describe("Test broadcastTx", () => { const serialized = "0x02abcd" as `0x${string}`; const localHash = keccak256(serialized); @@ -694,7 +759,7 @@ describe("Test estimateGasCost", () => { it("should calculate basic gas cost non-L2 chains", async () => { const result = await estimateGasCost(mockSigner, mockTx); - expect(mockSigner.estimateGas).toHaveBeenCalledWith({ ...mockTx, blockTag: "pending" }); + expect(mockSigner.estimateGas).toHaveBeenCalledWith({ ...mockTx /*blockTag: "pending"*/ }); expect(result).toEqual({ gas: 100000n, gasPrice: 20000000000n, // 20 gwei * 110% diff --git a/src/signer/actions.ts b/src/signer/actions.ts index 2c1d12cc..1b55a46a 100644 --- a/src/signer/actions.ts +++ b/src/signer/actions.ts @@ -150,9 +150,27 @@ export namespace RainSolverSignerActions { } } +/** + * Determines if the given tx has its gas limit and gas price (legacy or eip1559) + * explicitly set, such a tx can be signed locally and sent as a raw tx without + * any rpc call for populating its fields + * @param tx - The transaction parameters + */ +export function hasExplicitGasParams( + tx: SendTransactionParameters, +): boolean { + return ( + typeof tx.gas === "bigint" && + (typeof tx.gasPrice === "bigint" || typeof tx.maxFeePerGas === "bigint") + ); +} + /** * A wrapper for viem sendTransactions that handles nonce and manages signer busy - * state while the transaction is being sent ensuring proper busy state management + * state while the transaction is being sent ensuring proper busy state management, + * a tx with explicit gas limit and gas price is signed locally and broadcasted as + * a raw tx (see broadcastTx), otherwise it is sent through viem sendTransaction + * which populates the missing fields over rpc before signing * * @param signer - The RainSolverSigner instance to use for sending the transaction * @param tx - The transaction parameters to send @@ -179,6 +197,10 @@ export async function sendTx( tx.gas = getTxGas(signer.state, tx.gas); } + // only a fully populated tx can be signed locally, the rest go + // through viem sendTransaction that fills in the missing fields + const canBroadcast = hasExplicitGasParams(tx); + async function send() { if (typeof nonce !== "number") { await signer @@ -192,7 +214,11 @@ export async function sendTx( throw e; }); } - return await broadcastTx(signer, { ...(tx as any), nonce }); + if (canBroadcast) { + return await broadcastTx(signer, { ...(tx as any), nonce }); + } else { + return await signer.sendTransaction({ ...(tx as any), nonce }); + } } try { const hash = await send(); @@ -226,7 +252,7 @@ export async function estimateGasCost( tx: EstimateGasParameters, ): Promise { const gasPrice = signer.state.gasPrice; - const gas = await signer.estimateGas({ ...tx, blockTag: "pending" } as any); + const gas = await signer.estimateGas({ ...tx } as any); const result: EstimateGasCostResult = { gas, gasPrice, diff --git a/src/state/contracts.test.ts b/src/state/contracts.test.ts index 46794278..616ee656 100644 --- a/src/state/contracts.test.ts +++ b/src/state/contracts.test.ts @@ -288,7 +288,13 @@ describe("SolverContracts.fromAppOptions", () => { getAddressesForTrade: expect.any(Function), }); - expect(mockClient.readContract).toHaveBeenCalledTimes(3); + // one interpreter read per version, plus the IRainlang fallback read for v6 + expect(mockClient.readContract).toHaveBeenCalledTimes(4); + expect(mockClient.readContract).toHaveBeenNthCalledWith(4, { + address: "0xv6dispair", + functionName: "interpreterAddress", + abi: ABI.Deployer.Primary.Rainlang, + }); }); it("should handle partial contract read errors", async () => { @@ -395,14 +401,16 @@ describe("SolverContracts.fromAppOptions", () => { .mockRejectedValueOnce(new Error("v5 interpreter failed")) // v5 interpreter error .mockResolvedValueOnce("0xv5store" as `0x${string}`) // v5 store success .mockRejectedValueOnce(new Error("v6 interpreter failed")) // v6 interpreter error - .mockResolvedValueOnce("0xv6store" as `0x${string}`); // v6 store success + .mockResolvedValueOnce("0xv6store" as `0x${string}`) // v6 store success + .mockRejectedValueOnce(new Error("v6 rainlang interpreter failed")) // v6 IRainlang fallback error + .mockRejectedValueOnce(new Error("v6 rainlang store failed")); // v6 IRainlang fallback error const result = await SolverContracts.fromAppOptions(mockClient, mockAppOptions); expect(result).toEqual({ v4: undefined, // Should be undefined because store fetch failed v5: undefined, // Should be undefined because interpreter fetch failed - v6: undefined, + v6: undefined, // Should be undefined because the IRainlang fallback failed too getAddressesForTrade: expect.any(Function), }); }); @@ -413,14 +421,15 @@ describe("SolverContracts.fromAppOptions", () => { .mockResolvedValueOnce("0xv5interpreter" as `0x${string}`) // v5 interpreter success .mockRejectedValueOnce(new Error("v5 store failed")) // v5 store error .mockResolvedValueOnce("0xv6interpreter" as `0x${string}`) // v6 interpreter success - .mockRejectedValueOnce(new Error("v6 store failed")); // v6 store error + .mockRejectedValueOnce(new Error("v6 store failed")) // v6 store error + .mockRejectedValueOnce(new Error("v6 rainlang interpreter failed")); // v6 IRainlang fallback error const result = await SolverContracts.fromAppOptions(mockClient, mockAppOptions); expect(result).toEqual({ v4: undefined, // Should be undefined because interpreter fetch failed v5: undefined, // Should be undefined because store fetch failed - v6: undefined, + v6: undefined, // Should be undefined because the IRainlang fallback failed too getAddressesForTrade: expect.any(Function), }); }); @@ -650,6 +659,125 @@ describe("resolveVersionContracts", () => { abi: ABI.Deployer.Primary.DeployerV6, }); }); + + it("should resolve v6 contracts from a Rainlang registry when deployer getters fail", async () => { + const mockAddresses = { + dispair: "0xrainlang" as `0x${string}`, + sushiArb: "0xsushiArbAddress" as `0x${string}`, + }; + + mockClient.readContract + .mockRejectedValueOnce(new Error("I_INTERPRETER reverted")) // I_INTERPRETER + .mockResolvedValueOnce("0xinterpreterAddress" as `0x${string}`) // interpreterAddress + .mockRejectedValueOnce(new Error("I_STORE reverted")) // I_STORE + .mockResolvedValueOnce("0xstoreAddress" as `0x${string}`) // storeAddress + .mockResolvedValueOnce("0xdeployerAddress" as `0x${string}`); // expressionDeployerAddress + + const result = await resolveVersionContracts(mockClient, mockAddresses, "v6"); + + // the deployer used for parsing is the one the registry points at, not the registry + expect(result).toEqual({ + dispair: { + deployer: "0xdeployerAddress", + interpreter: "0xinterpreterAddress", + store: "0xstoreAddress", + }, + sushiArb: "0xsushiArbAddress", + }); + + expect(mockClient.readContract).toHaveBeenCalledTimes(5); + expect(mockClient.readContract).toHaveBeenNthCalledWith(1, { + address: "0xrainlang", + functionName: "I_INTERPRETER", + abi: ABI.Deployer.Primary.DeployerV6, + }); + expect(mockClient.readContract).toHaveBeenNthCalledWith(2, { + address: "0xrainlang", + functionName: "interpreterAddress", + abi: ABI.Deployer.Primary.Rainlang, + }); + expect(mockClient.readContract).toHaveBeenNthCalledWith(3, { + address: "0xrainlang", + functionName: "I_STORE", + abi: ABI.Deployer.Primary.DeployerV6, + }); + expect(mockClient.readContract).toHaveBeenNthCalledWith(4, { + address: "0xrainlang", + functionName: "storeAddress", + abi: ABI.Deployer.Primary.Rainlang, + }); + expect(mockClient.readContract).toHaveBeenNthCalledWith(5, { + address: "0xrainlang", + functionName: "expressionDeployerAddress", + abi: ABI.Deployer.Primary.Rainlang, + }); + }); + + it("should read the deployer from the registry when only the store getter fell back", async () => { + const mockAddresses = { dispair: "0xrainlang" as `0x${string}` }; + + mockClient.readContract + .mockResolvedValueOnce("0xinterpreterAddress" as `0x${string}`) // I_INTERPRETER + .mockRejectedValueOnce(new Error("I_STORE reverted")) // I_STORE + .mockResolvedValueOnce("0xstoreAddress" as `0x${string}`) // storeAddress + .mockResolvedValueOnce("0xdeployerAddress" as `0x${string}`); // expressionDeployerAddress + + const result = await resolveVersionContracts(mockClient, mockAddresses, "v6"); + + expect(result).toEqual({ + dispair: { + deployer: "0xdeployerAddress", + interpreter: "0xinterpreterAddress", + store: "0xstoreAddress", + }, + }); + expect(mockClient.readContract).toHaveBeenCalledTimes(4); + }); + + it("should return undefined for v6 when both deployer and Rainlang registry reads fail", async () => { + const mockAddresses = { dispair: "0xunknown" as `0x${string}` }; + + mockClient.readContract.mockRejectedValue(new Error("reverted")); + + const result = await resolveVersionContracts(mockClient, mockAddresses, "v6"); + + expect(result).toBeUndefined(); + expect(mockClient.readContract).toHaveBeenCalledTimes(2); + expect(mockClient.readContract).toHaveBeenNthCalledWith(2, { + address: "0xunknown", + functionName: "interpreterAddress", + abi: ABI.Deployer.Primary.Rainlang, + }); + }); + + it("should return undefined for v6 when a Rainlang registry read fails midway", async () => { + const mockAddresses = { dispair: "0xrainlang" as `0x${string}` }; + + mockClient.readContract + .mockRejectedValueOnce(new Error("I_INTERPRETER reverted")) // I_INTERPRETER + .mockResolvedValueOnce("0xinterpreterAddress" as `0x${string}`) // interpreterAddress + .mockRejectedValueOnce(new Error("I_STORE reverted")) // I_STORE + .mockRejectedValueOnce(new Error("storeAddress reverted")); // storeAddress + + const result = await resolveVersionContracts(mockClient, mockAddresses, "v6"); + + expect(result).toBeUndefined(); + expect(mockClient.readContract).toHaveBeenCalledTimes(4); + }); + + it("should not try the Rainlang registry for non v6 versions", async () => { + const mockAddresses = { dispair: "0xdispair" as `0x${string}` }; + + mockClient.readContract.mockRejectedValue(new Error("reverted")); + + expect(await resolveVersionContracts(mockClient, mockAddresses)).toBeUndefined(); + expect(await resolveVersionContracts(mockClient, mockAddresses, "v4")).toBeUndefined(); + expect(await resolveVersionContracts(mockClient, mockAddresses, "v5")).toBeUndefined(); + expect(mockClient.readContract).toHaveBeenCalledTimes(3); + for (const call of mockClient.readContract.mock.calls) { + expect(call[0].functionName).toBe("iInterpreter"); + } + }); }); describe("versionAddressGetter", () => { diff --git a/src/state/contracts.ts b/src/state/contracts.ts index db152d94..706a27c5 100644 --- a/src/state/contracts.ts +++ b/src/state/contracts.ts @@ -79,31 +79,56 @@ export async function resolveVersionContracts( return undefined; } + // a v6 dispair can also be a Rainlang registry (IRainlang) of the newer rainlang + // stack instead of an expression deployer, so its getters are tried when the + // expression deployer getters fail, in that stack the expression deployer is only + // the parser, so the one used for parsing is the one the registry points at + const dispairAddress = addresses.dispair; + let isRainlang = false; + const readRainlang = ( + functionName: "interpreterAddress" | "storeAddress" | "expressionDeployerAddress", + ) => { + if (version !== "v6") return undefined; + isRainlang = true; + return client + .readContract({ + address: dispairAddress, + functionName, + abi: ABI.Deployer.Primary.Rainlang, + }) + .catch(() => undefined); + }; + const interpreter = await client .readContract({ - address: addresses.dispair, + address: dispairAddress, functionName: version === "v6" ? "I_INTERPRETER" : "iInterpreter", abi: version === "v6" ? ABI.Deployer.Primary.DeployerV6 : ABI.Deployer.Primary.Deployer, }) - .catch(() => undefined); + .catch(() => readRainlang("interpreterAddress")); if (!interpreter) { return undefined; } const store = await client .readContract({ - address: addresses.dispair, + address: dispairAddress, functionName: version === "v6" ? "I_STORE" : "iStore", abi: version === "v6" ? ABI.Deployer.Primary.DeployerV6 : ABI.Deployer.Primary.Deployer, }) - .catch(() => undefined); + .catch(() => readRainlang("storeAddress")); if (!store) { return undefined; } + const deployer = isRainlang ? await readRainlang("expressionDeployerAddress") : dispairAddress; + if (!deployer) { + return undefined; + } + const result: any = { dispair: { - deployer: addresses.dispair, + deployer, interpreter, store, }, diff --git a/src/subgraph/index.test.ts b/src/subgraph/index.test.ts index 800cf626..16eb9a30 100644 --- a/src/subgraph/index.test.ts +++ b/src/subgraph/index.test.ts @@ -125,6 +125,46 @@ describe("Test SubgraphManager", () => { const orderbooks = await manager.getOrderbooks(); expect(orderbooks).toContain("0x1"); expect(orderbooks).toContain("0x2"); + expect((axios.post as Mock).mock.calls[0][1].query).toContain("orderbooks {"); + expect((axios.post as Mock).mock.calls[0][1].query).not.toContain("raindices"); + }); + + it("test getOrderbooks: should query raindices for v6 subgraphs", async () => { + const v6Url = "https://v6.example.com"; + const _manager = new SubgraphManager({ + subgraphs: [subgraphUrl, `v6=${v6Url}`], + filters: undefined, + requestTimeout: 1000, + }); + (axios.post as Mock).mockImplementation(async (url: string) => { + if (url === v6Url) return { data: { data: { orderbooks: [{ id: "0x2" }] } } }; + return { data: { data: { orderbooks: [{ id: "0x1" }] } } }; + }); + const orderbooks = await _manager.getOrderbooks(); + expect(orderbooks).toEqual(new Set(["0x1", "0x2"])); + + const calls = (axios.post as Mock).mock.calls; + const legacyCall = calls.find((c) => c[0] === subgraphUrl)!; + const v6Call = calls.find((c) => c[0] === v6Url)!; + expect(legacyCall[1].query).toContain("orderbooks {"); + expect(v6Call[1].query).toContain("orderbooks: raindices {"); + }); + + it("test fetchSubgraphOrders: should query raindex entity for v6", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: { data: { orders: [] } }, + }); + await manager.fetchSubgraphOrders(subgraphUrl, SubgraphVersions.V6); + expect((axios.post as Mock).mock.calls[0][1].query).toContain("orderbook: raindex {"); + }); + + it("test fetchSubgraphOrders: should query orderbook entity for legacy", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: { data: { orders: [] } }, + }); + await manager.fetchSubgraphOrders(subgraphUrl, SubgraphVersions.LEGACY); + expect((axios.post as Mock).mock.calls[0][1].query).toContain("orderbook {"); + expect((axios.post as Mock).mock.calls[0][1].query).not.toContain("raindex"); }); it("test statusCheck: should report OK when no indexing errors", async () => { @@ -196,6 +236,30 @@ describe("Test SubgraphManager", () => { result[subgraphUrl].forEach((v) => { expect(v.__version).toBe(SubgraphVersions.LEGACY); }); + expect((axios.post as Mock).mock.calls[0][1].query).toContain("orderbook {"); + expect((axios.post as Mock).mock.calls[0][1].query).not.toContain("raindex"); + }); + + it("test getUpstreamEvents: should query raindex entity and tag v6 for v6 subgraphs", async () => { + const v6Url = "https://v6.example.com"; + const _manager = new SubgraphManager({ + subgraphs: [`v6=${v6Url}`], + filters: undefined, + requestTimeout: 1000, + }); + (axios.post as Mock) + .mockResolvedValueOnce({ + data: { data: { transactions: [{}] } }, + }) + .mockResolvedValueOnce({ + data: { data: { transactions: [] } }, + }); + const { status, result } = await _manager.getUpstreamEvents(); + expect(status[v6Url].status).toMatch("Fully fetched"); + expect(result[v6Url].length).toBe(1); + expect(result[v6Url][0].__version).toBe(SubgraphVersions.V6); + expect((axios.post as Mock).mock.calls[0][1].query).toContain("orderbook: raindex {"); + expect((axios.post as Mock).mock.calls[0][1].query).not.toContain("orderbook {"); }); it("test getUpstreamEvents: should handle errors and partial sync", async () => { diff --git a/src/subgraph/index.ts b/src/subgraph/index.ts index 8b5abbf6..922d611b 100644 --- a/src/subgraph/index.ts +++ b/src/subgraph/index.ts @@ -7,7 +7,7 @@ import { PreAssembledSpan } from "../logger"; import { SpanStatusCode } from "@opentelemetry/api"; import { ErrorSeverity, errorSnapshot } from "../error"; import { SgOrder, SgTransaction, SubgraphSyncState, SubgraphVersions } from "./types"; -import { getTxsQuery, orderbooksQuery, DEFAULT_PAGE_SIZE, getQueryPaginated } from "./query"; +import { getTxsQuery, getOrderbooksQuery, DEFAULT_PAGE_SIZE, getQueryPaginated } from "./query"; // re-export export * from "./types"; @@ -70,7 +70,11 @@ export class SubgraphManager { */ async getOrderbooks(): Promise> { const promises = this.subgraphs.map((url) => - axios.post(url, { query: orderbooksQuery }, { headers, timeout: this.requestTimeout }), + axios.post( + url, + { query: getOrderbooksQuery(this.getSubgraphVersion(url)) }, + { headers, timeout: this.requestTimeout }, + ), ); const queryResults = await Promise.allSettled(promises); const addresses = queryResults.flatMap( @@ -152,7 +156,7 @@ export class SubgraphManager { const res = await axios.post( url, { - query: getQueryPaginated(skip, this.filters), + query: getQueryPaginated(skip, this.filters, version), }, { headers, timeout: this.requestTimeout }, ); @@ -229,19 +233,27 @@ export class SubgraphManager { status[url] = {}; const allResults: SgTransaction[] = []; const startTimestamp = this.syncState[url].lastFetchTimestamp; + const version = this.getSubgraphVersion(url); let partiallyFetched = false; for (;;) { try { const res = await axios.post( url, - { query: getTxsQuery(startTimestamp, this.syncState[url].skip) }, + { + query: getTxsQuery( + startTimestamp, + this.syncState[url].skip, + undefined, + version, + ), + }, { headers, timeout: this.requestTimeout }, ); if (typeof res?.data?.data?.transactions !== "undefined") { partiallyFetched = true; const txs: SgTransaction[] = res.data.data.transactions; this.syncState[url].skip += txs.length; - if (this.getSubgraphVersion(url) === SubgraphVersions.V6) { + if (version === SubgraphVersions.V6) { txs.forEach((v) => (v.__version = SubgraphVersions.V6)); } else { txs.forEach((v) => (v.__version = SubgraphVersions.LEGACY)); diff --git a/src/subgraph/query.test.ts b/src/subgraph/query.test.ts index 7e26fa5c..2b235c31 100644 --- a/src/subgraph/query.test.ts +++ b/src/subgraph/query.test.ts @@ -1,5 +1,19 @@ +import { SubgraphVersions } from "./types"; import { describe, it, expect } from "vitest"; -import { getQueryPaginated, getTxsQuery, DEFAULT_PAGE_SIZE } from "./query"; +import { + getTxsQuery, + DEFAULT_PAGE_SIZE, + getQueryPaginated, + getOrderbooksQuery, + getOrderbookEntityName, +} from "./query"; + +describe("Test getOrderbookEntityName", () => { + it("should return orderbook for legacy and raindex for v6", () => { + expect(getOrderbookEntityName(SubgraphVersions.LEGACY)).toBe("orderbook"); + expect(getOrderbookEntityName(SubgraphVersions.V6)).toBe("raindex"); + }); +}); describe("Test getQueryPaginated", () => { it("should generate query with no filters", () => { @@ -7,6 +21,22 @@ describe("Test getQueryPaginated", () => { expect(query).toContain(`first: ${DEFAULT_PAGE_SIZE}`); expect(query).toContain("skip: 0"); expect(query).toContain("active: true"); + expect(query).toContain("orderbook {"); + expect(query).not.toContain("raindex"); + }); + + it("should use raindex entity aliased as orderbook for v6", () => { + const filters = { + includeOrderbooks: new Set(["0xbook"]), + excludeOrderbooks: new Set(["0xnotbook"]), + }; + const query = getQueryPaginated(0, filters, SubgraphVersions.V6); + expect(query).toContain("orderbook: raindex {"); + expect(query).not.toContain("orderbook {"); + expect(query).toContain('raindex_in: ["0xbook"]'); + expect(query).toContain('raindex_not_in: ["0xnotbook"]'); + expect(query).not.toContain("orderbook_in"); + expect(query).not.toContain("orderbook_not_in"); }); it("should generate query with include/exclude filters", () => { @@ -29,11 +59,36 @@ describe("Test getQueryPaginated", () => { }); }); +describe("Test getOrderbooksQuery", () => { + it("should query orderbooks for legacy", () => { + const query = getOrderbooksQuery(); + expect(query).toContain("orderbooks {"); + expect(query).not.toContain("raindices"); + }); + + it("should query raindices aliased as orderbooks for v6", () => { + const query = getOrderbooksQuery(SubgraphVersions.V6); + expect(query).toContain("orderbooks: raindices {"); + }); +}); + describe("Test getTxsQuery", () => { it("should generate a transaction query with correct skip and timestamp", () => { const query = getTxsQuery(123456, 20); expect(query).toContain('timestamp_gt: "123456"'); expect(query).toContain("skip: 20"); expect(query).toContain(`first: ${DEFAULT_PAGE_SIZE}`); + expect(query).toContain("orderbook {"); + expect(query).not.toContain("raindex"); + }); + + it("should use raindex entity aliased as orderbook for v6", () => { + const query = getTxsQuery(123456, 20, 654321, SubgraphVersions.V6); + expect(query).toContain('timestamp_lte: "654321"'); + expect(query).toContain("orderbook: raindex {"); + expect(query).not.toContain("orderbook {"); + // one for each of AddOrder, RemoveOrder, Deposit, Withdrawal and + // the two vault balance changes of TradeEvent + expect(query.match(/orderbook: raindex \{/g)?.length).toBe(6); }); }); diff --git a/src/subgraph/query.ts b/src/subgraph/query.ts index bf648c88..d6fa6078 100644 --- a/src/subgraph/query.ts +++ b/src/subgraph/query.ts @@ -1,23 +1,52 @@ import { SgFilter } from "./filter"; +import { SubgraphVersions } from "./types"; export const DEFAULT_PAGE_SIZE = 1000 as const; +/** + * Returns the subgraph entity name that holds the orderbook address for the + * given subgraph version, the v6 subgraph schema renamed the orderbook entity + * to raindex (and the orderbooks collection to raindices) + * @param version - The subgraph version + */ +export function getOrderbookEntityName(version: SubgraphVersions): "orderbook" | "raindex" { + return version === SubgraphVersions.V6 ? "raindex" : "orderbook"; +} + +/** + * Returns the query field selection for the orderbook address for the given + * subgraph version, the v6 field is aliased back to "orderbook" so the response + * shape is the same across all subgraph versions + * @param version - The subgraph version + */ +function getOrderbookField(version: SubgraphVersions): string { + const entity = getOrderbookEntityName(version); + return entity === "orderbook" ? entity : `orderbook: ${entity}`; +} + /** * Method to get the subgraph query body for order details with optional filters * @param skip - Number of results to skip * @param filters - Applies the filters for query + * @param version - The subgraph version, defaults to legacy * @returns the query string */ -export function getQueryPaginated(skip: number, filters?: SgFilter): string { +export function getQueryPaginated( + skip: number, + filters?: SgFilter, + version: SubgraphVersions = SubgraphVersions.LEGACY, +): string { const getFilterVar = (header: string, f?: Set) => f ? `${header}: [${[...f].map((v) => `"${v.toLowerCase()}"`).join(", ")}], ` : ""; + const orderbookEntity = getOrderbookEntityName(version); + const orderbookField = getOrderbookField(version); const incOwnerFilter = getFilterVar("owner_in", filters?.includeOwners); const exOwnerFilter = getFilterVar("owner_not_in", filters?.excludeOwners); const incOrderFilter = getFilterVar("orderHash_in", filters?.includeOrders); const exOrderFilter = getFilterVar("orderHash_not_in", filters?.excludeOrders); - const incOrderbookFilter = getFilterVar("orderbook_in", filters?.includeOrderbooks); - const exOrderbookFilter = getFilterVar("orderbook_not_in", filters?.excludeOrderbooks); + const incOrderbookFilter = getFilterVar(`${orderbookEntity}_in`, filters?.includeOrderbooks); + const exOrderbookFilter = getFilterVar(`${orderbookEntity}_not_in`, filters?.excludeOrderbooks); return `{ orders( @@ -42,7 +71,7 @@ export function getQueryPaginated(skip: number, filters?: SgFilter): string { meta active nonce - orderbook { + ${orderbookField} { id } inputs { @@ -67,11 +96,20 @@ export function getQueryPaginated(skip: number, filters?: SgFilter): string { }`; } -export const orderbooksQuery = `{ - orderbooks { +/** + * Returns the query for the list of orderbook addresses that a subgraph indexes, + * the v6 raindices collection is aliased back to "orderbooks" so the response + * shape is the same across all subgraph versions + * @param version - The subgraph version, defaults to legacy + */ +export function getOrderbooksQuery(version: SubgraphVersions = SubgraphVersions.LEGACY): string { + const collection = version === SubgraphVersions.V6 ? "orderbooks: raindices" : "orderbooks"; + return `{ + ${collection} { id } }`; +} export const statusCheckQuery = `{ _meta { @@ -87,8 +125,15 @@ export const statusCheckQuery = `{ * @param startTimestamp - The timestamp to start query from * @param skip - Skips the first number of results * @param endTimestamp - (optional) The timestamp to end query at + * @param version - The subgraph version, defaults to legacy */ -export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: number) => { +export const getTxsQuery = ( + startTimestamp: number, + skip: number, + endTimestamp?: number, + version: SubgraphVersions = SubgraphVersions.LEGACY, +) => { + const orderbookField = getOrderbookField(version); const endTimestampClause = typeof endTimestamp === "number" ? `timestamp_lte: "${endTimestamp}"` : ""; return `{transactions( @@ -112,7 +157,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: meta active nonce - orderbook { + ${orderbookField} { id } inputs { @@ -147,7 +192,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: meta active nonce - orderbook { + ${orderbookField} { id } inputs { @@ -183,7 +228,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: symbol } } - orderbook { + ${orderbookField} { id } } @@ -200,7 +245,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: symbol } } - orderbook { + ${orderbookField} { id } } @@ -219,7 +264,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: symbol } } - orderbook { + ${orderbookField} { id } } @@ -236,7 +281,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: symbol } } - orderbook { + ${orderbookField} { id } }