Skip to content

Commit

Permalink
sdk/js-query: add mock
Browse files Browse the repository at this point in the history
  • Loading branch information
evan-gray committed Nov 8, 2023
1 parent 85e785a commit d5cd42f
Show file tree
Hide file tree
Showing 7 changed files with 389 additions and 8 deletions.
4 changes: 4 additions & 0 deletions sdk/js-query/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.0.5

Mock support

## 0.0.4

Add ethCallWithFinality support
Expand Down
2 changes: 1 addition & 1 deletion sdk/js-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wormhole-foundation/wormhole-query-sdk",
"version": "0.0.4",
"version": "0.0.5",
"description": "Wormhole cross-chain query SDK",
"homepage": "https://wormhole.com",
"main": "./lib/cjs/index.js",
Expand Down
1 change: 1 addition & 0 deletions sdk/js-query/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./mock";
export * from "./query";
245 changes: 245 additions & 0 deletions sdk/js-query/src/mock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import axios from "axios";
import { Buffer } from "buffer";
import {
ChainQueryType,
EthCallQueryRequest,
EthCallWithFinalityQueryRequest,
QueryRequest,
hexToUint8Array,
sign,
} from "../query";
import { BinaryWriter } from "../query/BinaryWriter";
import { BytesLike } from "@ethersproject/bytes";
import { keccak256 } from "@ethersproject/keccak256";

export type QueryProxyQueryResponse = {
signatures: string[];
bytes: string;
};

const QUERY_RESPONSE_PREFIX = "query_response_0000000000000000000|";

/**
* Usage:
*
* ```js
* const mock = new QueryProxyMock({
* 2: "http://localhost:8545",
* });
* ```
*
* If you are running an Anvil fork like
*
* ```bash
* anvil -f https://ethereum-goerli.publicnode.com
* ```
*
* You can use the following command to switch the guardian address to the devnet / mock guardian
*
* Where the `-a` parameter is the core bridge address on that chain
*
* https://docs.wormhole.com/wormhole/reference/constants#core-contracts
*
* ```bash
* npx \@wormhole-foundation/wormhole-cli evm hijack -a 0x706abc4E45D419950511e474C7B9Ed348A4a716c -g 0xbeFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe
* ```
*/
export class QueryProxyMock {
constructor(
public rpcMap: { [chainId: number]: string },
public mockPrivateKeys = [
"cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0",
]
) {}
sign(serializedResponse: BytesLike) {
const digest = hexToUint8Array(
keccak256(
Buffer.concat([
Buffer.from(QUERY_RESPONSE_PREFIX),
hexToUint8Array(keccak256(serializedResponse)),
])
)
);
return this.mockPrivateKeys.map(
(key, idx) => `${sign(key, digest)}${idx.toString(16).padStart(2, "0")}`
);
}
/**
* Usage:
*
* ```js
* const { bytes, signatures } = await mock.mock(new QueryRequest(nonce, [
* new PerChainQueryRequest(
* wormholeChainId,
* new EthCallQueryRequest(blockNumber, [
* { to: contract, data: abiEncodedData },
* ])
* ),
* ]));
* ```
*
* @param queryRequest an instance of the `QueryRequest` class
* @returns a promise result matching the query proxy's query response
*/
async mock(queryRequest: QueryRequest): Promise<QueryProxyQueryResponse> {
const serializedRequest = queryRequest.serialize();
const writer = new BinaryWriter()
.writeUint8(1) // version
.writeUint16(0) // source = off-chain
.writeUint8Array(new Uint8Array(new Array(65))) // empty signature for mock
.writeUint32(serializedRequest.length)
.writeUint8Array(serializedRequest)
.writeUint8(queryRequest.requests.length);
for (const perChainRequest of queryRequest.requests) {
const rpc = this.rpcMap[perChainRequest.chainId];
if (!rpc) {
throw new Error(
`Unregistered chain id for mock: ${perChainRequest.chainId}`
);
}
const type = perChainRequest.query.type();
writer.writeUint16(perChainRequest.chainId).writeUint8(type);
if (type === ChainQueryType.EthCall) {
const query = perChainRequest.query as EthCallQueryRequest;
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
query.blockTag,
],
})),
{
jsonrpc: "2.0",
id: query.callData.length,
//TODO: support block hash
method: "eth_getBlockByNumber",
params: [query.blockTag, false],
},
]);
const callResults = response?.data?.slice(0, query.callData.length);
const blockResult = response?.data?.[query.callData.length]?.result;
if (
!blockResult ||
!blockResult.number ||
!blockResult.timestamp ||
!blockResult.hash
) {
throw new Error(
`Invalid block result for chain ${perChainRequest.chainId} block ${query.blockTag}`
);
}
const results = callResults.map(
(callResult: any) =>
new Uint8Array(Buffer.from(callResult.result.substring(2), "hex"))
);
const perChainWriter = new BinaryWriter()
.writeUint64(BigInt(parseInt(blockResult.number.substring(2), 16))) // block number
.writeUint8Array(
new Uint8Array(Buffer.from(blockResult.hash.substring(2), "hex"))
) // hash
.writeUint64(
BigInt(parseInt(blockResult.timestamp.substring(2), 16)) *
BigInt("1000000")
) // time in seconds -> microseconds
.writeUint8(results.length);
for (const result of results) {
perChainWriter.writeUint32(result.length).writeUint8Array(result);
}
const serialized = perChainWriter.data();
writer.writeUint32(serialized.length).writeUint8Array(serialized);
} else if (type === ChainQueryType.EthCallWithFinality) {
const query = perChainRequest.query as EthCallWithFinalityQueryRequest;
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
query.blockId,
],
})),
{
jsonrpc: "2.0",
id: query.callData.length,
//TODO: support block hash
method: "eth_getBlockByNumber",
params: [query.blockId, false],
},
{
jsonrpc: "2.0",
id: query.callData.length,
method: "eth_getBlockByNumber",
params: [query.finality, false],
},
]);
const callResults = response?.data?.slice(0, query.callData.length);
const blockResult = response?.data?.[query.callData.length]?.result;
const finalityBlockResult =
response?.data?.[query.callData.length + 1]?.result;
if (
!blockResult ||
!blockResult.number ||
!blockResult.timestamp ||
!blockResult.hash
) {
throw new Error(
`Invalid block result for chain ${perChainRequest.chainId} block ${query.blockId}`
);
}
if (
!finalityBlockResult ||
!finalityBlockResult.number ||
!finalityBlockResult.timestamp ||
!finalityBlockResult.hash
) {
throw new Error(
`Invalid tagged block result for chain ${perChainRequest.chainId} tag ${query.finality}`
);
}
const blockNumber = BigInt(
parseInt(blockResult.number.substring(2), 16)
);
const latestBlockNumberMatchingFinality = BigInt(
parseInt(finalityBlockResult.number.substring(2), 16)
);
if (blockNumber > latestBlockNumberMatchingFinality) {
throw new Error(
`Requested block for eth_call_with_finality has not yet reached the requested finality. Block: ${blockNumber}, ${query.finality}: ${latestBlockNumberMatchingFinality}`
);
}
const results = callResults.map(
(callResult: any) =>
new Uint8Array(Buffer.from(callResult.result.substring(2), "hex"))
);
const perChainWriter = new BinaryWriter()
.writeUint64(blockNumber) // block number
.writeUint8Array(
new Uint8Array(Buffer.from(blockResult.hash.substring(2), "hex"))
) // hash
.writeUint64(
BigInt(parseInt(blockResult.timestamp.substring(2), 16)) *
BigInt("1000000")
) // time in seconds -> microseconds
.writeUint8(results.length);
for (const result of results) {
perChainWriter.writeUint32(result.length).writeUint8Array(result);
}
const serialized = perChainWriter.data();
writer.writeUint32(serialized.length).writeUint8Array(serialized);
} else {
throw new Error(`Unsupported query type for mock: ${type}`);
}
}
const serializedResponse = writer.data();
return {
signatures: this.sign(serializedResponse),
bytes: Buffer.from(serializedResponse).toString("hex"),
};
}
}
130 changes: 130 additions & 0 deletions sdk/js-query/src/mock/mock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
afterAll,
beforeAll,
describe,
expect,
jest,
test,
} from "@jest/globals";
import axios from "axios";
import { eth } from "web3";
import {
EthCallQueryRequest,
EthCallWithFinalityQueryRequest,
PerChainQueryRequest,
QueryProxyMock,
QueryProxyQueryResponse,
QueryRequest,
} from "..";

jest.setTimeout(60000);

const POLYGON_NODE_URL = "https://polygon-mumbai-bor.publicnode.com";
const ARBITRUM_NODE_URL = "https://arbitrum-goerli.publicnode.com";
const QUERY_URL = "https://testnet.ccq.vaa.dev/v1/query";

let mock: QueryProxyMock;

beforeAll(() => {
mock = new QueryProxyMock({
5: POLYGON_NODE_URL,
23: ARBITRUM_NODE_URL,
});
});

afterAll(() => {});

describe.skip("mocks match testnet", () => {
test("EthCallQueryRequest mock matches testnet", async () => {
const blockNumber = (
await axios.post(POLYGON_NODE_URL, {
jsonrpc: "2.0",
id: 1,
method: "eth_getBlockByNumber",
params: ["latest", false],
})
).data?.result?.number;
expect(blockNumber).toBeTruthy();
const polygonDemoContract = "0x130Db1B83d205562461eD0720B37f1FBC21Bf67F";
const data = eth.abi.encodeFunctionSignature("getMyCounter()");
const query = new QueryRequest(42, [
new PerChainQueryRequest(
5,
new EthCallQueryRequest(blockNumber, [
{ to: polygonDemoContract, data },
])
),
]);
const { bytes, signatures } = await mock.mock(query);
// from CCQ Demo UI
const signatureNotRequiredApiKey = "2d6c22c6-afae-4e54-b36d-5ba118da646a";
const realResponse = (
await axios.post<QueryProxyQueryResponse>(
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);
// similarly, we'll resign the bytes, to compare the signatures (only works with testnet key)
// const serializedResponse = Buffer.from(realResponse.bytes, "hex");
// const matchesReal = mock.sign(serializedResponse);
// expect(matchesReal).toEqual(realResponse.signatures);
});
test("EthCallWithFinalityQueryRequest mock matches testnet", async () => {
const blockNumber = (
await axios.post(ARBITRUM_NODE_URL, {
jsonrpc: "2.0",
id: 1,
method: "eth_getBlockByNumber",
params: ["finalized", false],
})
).data?.result?.number;
expect(blockNumber).toBeTruthy();
const arbitrumDemoContract = "0x6E36177f26A3C9cD2CE8DDF1b12904fe36deA47F";
const data = eth.abi.encodeFunctionSignature("getMyCounter()");
const query = new QueryRequest(42, [
new PerChainQueryRequest(
23,
new EthCallWithFinalityQueryRequest(blockNumber, "finalized", [
{ 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<QueryProxyQueryResponse>(
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);
// similarly, we'll resign the bytes, to compare the signatures (only works with testnet key)
// const serializedResponse = Buffer.from(realResponse.bytes, "hex");
// const matchesReal = mock.sign(serializedResponse);
// expect(matchesReal).toEqual(realResponse.signatures);
});
});
Loading

0 comments on commit d5cd42f

Please sign in to comment.