forked from wormhole-foundation/wormhole
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
389 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from "./mock"; | ||
export * from "./query"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.