diff --git a/sdk/js-query/CHANGELOG.md b/sdk/js-query/CHANGELOG.md index 610f83fcf2..983bd8da97 100644 --- a/sdk/js-query/CHANGELOG.md +++ b/sdk/js-query/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.7 + +Add EthCallByTimestamp mock support + ## 0.0.6 Deserialization support diff --git a/sdk/js-query/package.json b/sdk/js-query/package.json index a21fd5f814..22ed382ebd 100644 --- a/sdk/js-query/package.json +++ b/sdk/js-query/package.json @@ -1,6 +1,6 @@ { "name": "@wormhole-foundation/wormhole-query-sdk", - "version": "0.0.6", + "version": "0.0.7", "description": "Wormhole cross-chain query SDK", "homepage": "https://wormhole.com", "main": "./lib/cjs/index.js", diff --git a/sdk/js-query/src/mock/index.ts b/sdk/js-query/src/mock/index.ts index 5276cd282e..e1fd296abf 100644 --- a/sdk/js-query/src/mock/index.ts +++ b/sdk/js-query/src/mock/index.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { Buffer } from "buffer"; import { ChainQueryType, + EthCallByTimestampQueryRequest, + EthCallByTimestampQueryResponse, EthCallQueryRequest, EthCallQueryResponse, EthCallWithFinalityQueryRequest, @@ -202,6 +204,150 @@ export class QueryProxyMock { ) ) ); + } else if (type === ChainQueryType.EthCallByTimeStamp) { + const query = perChainRequest.query as EthCallByTimestampQueryRequest; + // Verify that the two block hints are consistent, either both set, or both unset. + if ( + (query.targetBlockHint === "") !== + (query.followingBlockHint === "") + ) { + throw new Error( + `Invalid block id hints in eth_call_by_timestamp query request, both should be either set or unset` + ); + } + let targetBlock = query.targetBlockHint; + let followingBlock = query.followingBlockHint; + if (targetBlock === "") { + let nextQueryBlock = "latest"; + let tries = 0; + let targetTimestamp = BigInt(0); + let followingTimestamp = BigInt(0); + while ( + query.targetTimestamp < targetTimestamp || + query.targetTimestamp >= followingTimestamp + ) { + if (tries > 128) { + throw new Error(`Timestamp was not within the last 128 blocks.`); + } + // TODO: batching + const blockResult = ( + await axios.post(rpc, { + jsonrpc: "2.0", + id: 1, + method: "eth_getBlockByNumber", + params: [nextQueryBlock, false], + }) + ).data?.result; + if (!blockResult) { + throw new Error( + `Invalid block result while searching for timestamp of ${nextQueryBlock}` + ); + } + followingBlock = targetBlock; + followingTimestamp = targetTimestamp; + targetBlock = blockResult.number; + targetTimestamp = + BigInt(parseInt(blockResult.timestamp.substring(2), 16)) * + BigInt("1000000"); // time in seconds -> microseconds + nextQueryBlock = `0x${( + BigInt(blockResult.number) - BigInt(1) + ).toString(16)}`; + tries++; + } + } + const response = await axios.post(rpc, [ + ...query.callData.map((args, idx) => ({ + jsonrpc: "2.0", + id: idx, + method: "eth_call", + params: [ + args, + //TODO: support block hash + targetBlock, + ], + })), + { + jsonrpc: "2.0", + id: query.callData.length, + method: "eth_getBlockByNumber", + params: [targetBlock, false], + }, + { + jsonrpc: "2.0", + id: query.callData.length, + method: "eth_getBlockByNumber", + params: [followingBlock, false], + }, + ]); + const callResults = response?.data?.slice(0, query.callData.length); + const targetBlockResult = + response?.data?.[query.callData.length]?.result; + const followingBlockResult = + response?.data?.[query.callData.length + 1]?.result; + if ( + !targetBlockResult || + !targetBlockResult.number || + !targetBlockResult.timestamp || + !targetBlockResult.hash + ) { + throw new Error( + `Invalid target block result for chain ${perChainRequest.chainId} block ${query.targetBlockHint}` + ); + } + if ( + !followingBlockResult || + !followingBlockResult.number || + !followingBlockResult.timestamp || + !followingBlockResult.hash + ) { + throw new Error( + `Invalid following block result for chain ${perChainRequest.chainId} tag ${query.followingBlockHint}` + ); + } + /* + target_block.timestamp <= target_time < following_block.timestamp + and + following_block_num - 1 == target_block_num + */ + const targetBlockNumber = BigInt( + parseInt(targetBlockResult.number.substring(2), 16) + ); + const followingBlockNumber = BigInt( + parseInt(followingBlockResult.number.substring(2), 16) + ); + if (targetBlockNumber + BigInt(1) !== followingBlockNumber) { + throw new Error( + `eth_call_by_timestamp query blocks are not adjacent` + ); + } + const targetTimestamp = + BigInt(parseInt(targetBlockResult.timestamp.substring(2), 16)) * + BigInt("1000000"); // time in seconds -> microseconds + const followingTimestamp = + BigInt(parseInt(followingBlockResult.timestamp.substring(2), 16)) * + BigInt("1000000"); // time in seconds -> microseconds + if ( + query.targetTimestamp < targetTimestamp || + query.targetTimestamp >= followingTimestamp + ) { + throw new Error( + `eth_call_by_timestamp desired timestamp falls outside of block range` + ); + } + queryResponse.responses.push( + new PerChainQueryResponse( + perChainRequest.chainId, + new EthCallByTimestampQueryResponse( + BigInt(parseInt(targetBlockResult.number.substring(2), 16)), // block number + targetBlockResult.hash, // hash + targetTimestamp, + BigInt(parseInt(followingBlockResult.number.substring(2), 16)), // block number + followingBlockResult.hash, // hash + followingTimestamp, + callResults.map((callResult: any) => callResult.result) + ) + ) + ); } else { throw new Error(`Unsupported query type for mock: ${type}`); } diff --git a/sdk/js-query/src/mock/mock.test.ts b/sdk/js-query/src/mock/mock.test.ts index 03201422d1..24671b75fe 100644 --- a/sdk/js-query/src/mock/mock.test.ts +++ b/sdk/js-query/src/mock/mock.test.ts @@ -9,6 +9,7 @@ import { import axios from "axios"; import { eth } from "web3"; import { + EthCallByTimestampQueryRequest, EthCallQueryRequest, EthCallWithFinalityQueryRequest, PerChainQueryRequest, @@ -127,4 +128,108 @@ describe.skip("mocks match testnet", () => { // const matchesReal = mock.sign(serializedResponse); // expect(matchesReal).toEqual(realResponse.signatures); }); + test("EthCallByTimestampQueryRequest mock matches testnet", async () => { + const targetTimestamp = + BigInt(Date.now() - 1000 * 30) * // thirty seconds ago + BigInt(1000); // milliseconds to microseconds + const arbitrumDemoContract = "0x6E36177f26A3C9cD2CE8DDF1b12904fe36deA47F"; + const data = eth.abi.encodeFunctionSignature("getMyCounter()"); + const query = new QueryRequest(42, [ + new PerChainQueryRequest( + 23, + new EthCallByTimestampQueryRequest(targetTimestamp, "", "", [ + { to: arbitrumDemoContract, data }, + ]) + ), + ]); + const { bytes } = await mock.mock(query); + // from CCQ Demo UI + const signatureNotRequiredApiKey = "2d6c22c6-afae-4e54-b36d-5ba118da646a"; + const realResponse = ( + await axios.post( + QUERY_URL, + { + bytes: Buffer.from(query.serialize()).toString("hex"), + }, + { headers: { "X-API-Key": signatureNotRequiredApiKey } } + ) + ).data; + // the mock has an empty request signature, whereas the real service is signed + // we'll empty out the sig to compare the bytes + const realResponseWithEmptySignature = `${realResponse.bytes.substring( + 0, + 6 + )}${Buffer.from(new Array(65)).toString( + "hex" + )}${realResponse.bytes.substring(6 + 65 * 2)}`; + expect(bytes).toEqual(realResponseWithEmptySignature); + }); + test("EthCallByTimestampQueryRequest fails with non-adjacent blocks", async () => { + expect.assertions(1); + const targetTimestamp = + BigInt(Date.now() - 1000 * 60 * 1) * // one minute ago + BigInt(1000); // milliseconds to microseconds + const blockNumber = ( + await axios.post(ARBITRUM_NODE_URL, { + jsonrpc: "2.0", + id: 1, + method: "eth_getBlockByNumber", + params: ["finalized", false], + }) + ).data?.result?.number; + const arbitrumDemoContract = "0x6E36177f26A3C9cD2CE8DDF1b12904fe36deA47F"; + const data = eth.abi.encodeFunctionSignature("getMyCounter()"); + const query = new QueryRequest(42, [ + new PerChainQueryRequest( + 23, + new EthCallByTimestampQueryRequest( + targetTimestamp, + blockNumber, + blockNumber, + [{ to: arbitrumDemoContract, data }] + ) + ), + ]); + try { + await mock.mock(query); + } catch (e: any) { + expect(e.message).toMatch( + "eth_call_by_timestamp query blocks are not adjacent" + ); + } + }); + test("EthCallByTimestampQueryRequest fails with wrong timestamp", async () => { + expect.assertions(1); + const targetTimestamp = + BigInt(Date.now() - 1000 * 60 * 30) * // thirty minutes ago + BigInt(1000); // milliseconds to microseconds + const blockNumber = ( + await axios.post(ARBITRUM_NODE_URL, { + jsonrpc: "2.0", + id: 1, + method: "eth_getBlockByNumber", + params: ["finalized", false], + }) + ).data?.result?.number; + const arbitrumDemoContract = "0x6E36177f26A3C9cD2CE8DDF1b12904fe36deA47F"; + const data = eth.abi.encodeFunctionSignature("getMyCounter()"); + const query = new QueryRequest(42, [ + new PerChainQueryRequest( + 23, + new EthCallByTimestampQueryRequest( + targetTimestamp, + blockNumber, + `0x${(parseInt(blockNumber, 16) + 1).toString(16)}`, + [{ to: arbitrumDemoContract, data }] + ) + ), + ]); + try { + await mock.mock(query); + } catch (e: any) { + expect(e.message).toMatch( + "eth_call_by_timestamp desired timestamp falls outside of block range" + ); + } + }); });