CCQ: Query SDK (#3421)

* CCQ: Query SDK

* CCQ: TS SDK fixes

---------

Co-authored-by: Kevin Peters <kevin@w7.xyz>
This commit is contained in:
bruce-riley 2023-10-13 14:32:01 -05:00 committed by GitHub
parent 9aa4d0329d
commit e0606497e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 9152 additions and 1 deletions

View File

@ -166,7 +166,7 @@ spec:
# - --chainGovernorEnabled=true
- --ccqEnabled=true
- --ccqAllowedRequesters=beFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe,25021A4FCAf61F2EADC8202D3833Df48B2Fa0D54
- --ccqAllowedPeers=12D3KooWSnju8zhywCYVi2JwTqky1sySPnmtYLsHHzc4WerMnDQH
- --ccqAllowedPeers=12D3KooWSnju8zhywCYVi2JwTqky1sySPnmtYLsHHzc4WerMnDQH,12D3KooWM6WqedfR6ehtTd1y6rJu3ZUrEkTjcJJnJZYesjd89zj8
# - --logLevel=debug
securityContext:
capabilities:

23
sdk/js-query/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# build
/lib

View File

@ -0,0 +1,12 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always"
}

View File

@ -0,0 +1,3 @@
## 0.0.1
Initial release

13
sdk/js-query/LICENSE Normal file
View File

@ -0,0 +1,13 @@
Copyright 2023 Wormhole Project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

1
sdk/js-query/README.md Normal file
View File

@ -0,0 +1 @@
Wormhole cross-chain queries SDK

View File

@ -0,0 +1,7 @@
import type { Config } from "@jest/types";
const config: Config.InitialOptions = {
preset: "ts-jest",
testEnvironment: "node",
};
export default config;

8535
sdk/js-query/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
sdk/js-query/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "@wormhole-foundation/wormhole-query-sdk",
"version": "0.0.1",
"description": "Wormhole cross-chain query SDK",
"homepage": "https://wormhole.com",
"main": "./lib/cjs/index.js",
"module": "./lib/esm/index.js",
"files": [
"lib/"
],
"scripts": {
"test": "jest --verbose",
"build": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json"
},
"keywords": [
"wormhole",
"sdk",
"cross-chain",
"query"
],
"author": "Wormhole Foundation",
"license": "Apache-2.0",
"devDependencies": {
"axios": "^1.4.0",
"jest": "^29.5.0",
"prettier": "^2.3.2",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.1.3"
},
"sideEffects": false,
"dependencies": {
"@types/elliptic": "^6.4.14",
"elliptic": "^6.5.4",
"web3": "^4.1.2"
}
}

View File

@ -0,0 +1 @@
export * from "./query";

View File

@ -0,0 +1,58 @@
// BinaryWriter appends data to the end of a buffer, resizing the buffer as needed
// Numbers are encoded as big endian
export class BinaryWriter {
private _buffer: Buffer;
private _offset: number;
constructor(initialSize: number = 1024) {
if (initialSize < 0) throw new Error("Initial size must be non-negative");
this._buffer = Buffer.alloc(initialSize);
this._offset = 0;
}
// Ensure the buffer has the capacity to write `size` bytes, otherwise allocate more memory
_ensure(size: number) {
const remaining = this._buffer.length - this._offset;
if (remaining < size) {
const oldBuffer = this._buffer;
const newSize = this._buffer.length * 2 + size;
this._buffer = Buffer.alloc(newSize);
oldBuffer.copy(this._buffer);
}
}
writeUint8(value: number) {
if (value < 0 || value > 255) throw new Error("Invalid value");
this._ensure(1);
this._buffer.writeUint8(value, this._offset);
this._offset += 1;
return this;
}
writeUint16(value: number) {
if (value < 0 || value > 65535) throw new Error("Invalid value");
this._ensure(2);
this._offset = this._buffer.writeUint16BE(value, this._offset);
return this;
}
writeUint32(value: number) {
if (value < 0 || value > 4294967295) throw new Error("Invalid value");
this._ensure(4);
this._offset = this._buffer.writeUint32BE(value, this._offset);
return this;
}
writeUint8Array(value: Uint8Array) {
this._ensure(value.length);
this._buffer.set(value, this._offset);
this._offset += value.length;
return this;
}
data(): Uint8Array {
const copy = new Uint8Array(this._offset);
copy.set(this._buffer.subarray(0, this._offset));
return copy;
}
}

View File

@ -0,0 +1,3 @@
export type Network = "MAINNET" | "TESTNET" | "DEVNET";
export type HexString = string;

View File

@ -0,0 +1,280 @@
import {
afterAll,
beforeAll,
describe,
expect,
jest,
test,
} from "@jest/globals";
import Web3, { ETH_DATA_FORMAT } from "web3";
import axios from "axios";
import {
EthCallData,
EthCallQueryRequest,
PerChainQueryRequest,
QueryRequest,
sign,
} from "..";
jest.setTimeout(125000);
const CI = false;
const ENV = "DEVNET";
const ETH_NODE_URL = CI ? "ws://eth-devnet:8545" : "ws://localhost:8545";
const CCQ_SERVER_URL = "http://localhost:6069/v1";
const QUERY_URL = CCQ_SERVER_URL + "/query";
const HEALTH_URL = CCQ_SERVER_URL + "/health";
const PRIVATE_KEY =
"cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0";
const WETH_ADDRESS = "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E";
let web3: Web3;
beforeAll(() => {
web3 = new Web3(ETH_NODE_URL);
});
afterAll(() => {
web3.provider?.disconnect();
});
function createTestEthCallData(
to: string,
name: string,
outputType: string
): EthCallData {
return {
to,
data: web3.eth.abi.encodeFunctionCall(
{
constant: true,
inputs: [],
name,
outputs: [{ name, type: outputType }],
payable: false,
stateMutability: "view",
type: "function",
},
[]
),
};
}
describe("eth call", () => {
test("serialize request", () => {
const toAddress = "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270";
const nameCallData = createTestEthCallData(toAddress, "name", "string");
const totalSupplyCallData = createTestEthCallData(
toAddress,
"totalSupply",
"uint256"
);
const ethCall = new EthCallQueryRequest("0x28d9630", [
nameCallData,
totalSupplyCallData,
]);
const chainId = 5;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
expect(Buffer.from(serialized).toString("hex")).toEqual(
"0100000001010005010000004600000009307832386439363330020d500b1d8e8ef31e21c99d1db9a6444d3adf12700000000406fdde030d500b1d8e8ef31e21c99d1db9a6444d3adf12700000000418160ddd"
);
});
test("successful query", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const totalSupplyCallData = createTestEthCallData(
WETH_ADDRESS,
"totalSupply",
"uint256"
);
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [
nameCallData,
totalSupplyCallData,
]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const digest = QueryRequest.digest(ENV, serialized);
const signature = sign(PRIVATE_KEY, digest);
const response = await axios.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "my_secret_key" } }
);
expect(response.status).toBe(200);
});
test("missing api-key should fail", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const totalSupplyCallData = createTestEthCallData(
WETH_ADDRESS,
"totalSupply",
"uint256"
);
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [
nameCallData,
totalSupplyCallData,
]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const digest = QueryRequest.digest(ENV, serialized);
const signature = sign(PRIVATE_KEY, digest);
let err = false;
await axios
.put(QUERY_URL, {
signature,
bytes: Buffer.from(serialized).toString("hex"),
})
.catch(function (error) {
err = true;
expect(error.response.status).toBe(401);
expect(error.response.data).toBe("api key is missing\n");
});
expect(err).toBe(true);
});
test("invalid api-key should fail", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const totalSupplyCallData = createTestEthCallData(
WETH_ADDRESS,
"totalSupply",
"uint256"
);
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [
nameCallData,
totalSupplyCallData,
]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const digest = QueryRequest.digest(ENV, serialized);
const signature = sign(PRIVATE_KEY, digest);
let err = false;
await axios
.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "some_junk" } }
)
.catch(function (error) {
err = true;
expect(error.response.status).toBe(403);
expect(error.response.data).toBe("invalid api key\n");
});
expect(err).toBe(true);
});
test("unauthorized call should fail", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const totalSupplyCallData = createTestEthCallData(
WETH_ADDRESS,
"totalSupply",
"uint256"
);
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [
nameCallData,
totalSupplyCallData, // API key "my_secret_key_2" is not authorized to do total supply.
]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const digest = QueryRequest.digest(ENV, serialized);
const signature = sign(PRIVATE_KEY, digest);
let err = false;
await axios
.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "my_secret_key_2" } }
)
.catch(function (error) {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`call "ethCall:2:000000000000000000000000ddb64fe46a91d46ee29420539fc25fd07c5fea3e:18160ddd" not authorized\n`
);
});
expect(err).toBe(true);
});
test("unsigned query should fail if not allowed", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const totalSupplyCallData = createTestEthCallData(
WETH_ADDRESS,
"totalSupply",
"uint256"
);
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [
nameCallData,
totalSupplyCallData,
]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const signature = "";
let err = false;
await axios
.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "my_secret_key" } }
)
.catch(function (error) {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(`request not signed\n`);
});
expect(err).toBe(true);
});
test("unsigned query should succeed if allowed", async () => {
const nameCallData = createTestEthCallData(WETH_ADDRESS, "name", "string");
const blockNumber = await web3.eth.getBlockNumber(ETH_DATA_FORMAT);
const ethCall = new EthCallQueryRequest(blockNumber, [nameCallData]);
const chainId = 2;
const ethQuery = new PerChainQueryRequest(chainId, ethCall);
const nonce = 1;
const request = new QueryRequest(nonce, [ethQuery]);
const serialized = request.serialize();
const signature = "";
const response = await axios.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "my_secret_key_2" } } // This API key allows unsigned queries.
);
expect(response.status).toBe(200);
});
test("health check", async () => {
const response = await axios.get(HEALTH_URL);
expect(response.status).toBe(200);
});
});

View File

@ -0,0 +1,48 @@
import { BinaryWriter } from "./BinaryWriter";
import { HexString } from "./consts";
import { ChainQueryType, ChainSpecificQuery } from "./request";
import { hexToUint8Array, isValidHexString } from "./utils";
export interface EthCallData {
to: string;
data: string;
}
// Can be a block number or a block hash
export type BlockTag = number | HexString;
export class EthCallQueryRequest implements ChainSpecificQuery {
blockTag: string;
constructor(blockTag: BlockTag, public callData: EthCallData[]) {
if (typeof blockTag === "number") {
this.blockTag = `0x${blockTag.toString(16)}`;
} else if (isValidHexString(blockTag)) {
if (!blockTag.startsWith("0x")) {
blockTag = `0x${blockTag}`;
}
this.blockTag = blockTag;
} else {
throw new Error(`Invalid block tag: ${blockTag}`);
}
}
type(): ChainQueryType {
return ChainQueryType.EthCall;
}
serialize(): Uint8Array {
const writer = new BinaryWriter()
.writeUint32(this.blockTag.length)
.writeUint8Array(Buffer.from(this.blockTag))
.writeUint8(this.callData.length);
this.callData.forEach(({ to, data }) => {
const dataArray = hexToUint8Array(data);
writer
.writeUint8Array(hexToUint8Array(to))
.writeUint32(dataArray.length)
.writeUint8Array(dataArray);
});
return writer.data();
}
}

View File

@ -0,0 +1,4 @@
export * from "./request";
export * from "./utils";
export * from "./ethCall";
export * from "./consts";

View File

@ -0,0 +1,70 @@
import { BinaryWriter } from "./BinaryWriter";
import { Network } from "./consts";
import { utils } from "web3";
import { hexToUint8Array } from "./utils";
export const MAINNET_QUERY_REQUEST_PREFIX =
"mainnet_query_request_000000000000|";
export const TESTNET_QUERY_REQUEST_PREFIX =
"testnet_query_request_000000000000|";
export const DEVNET_QUERY_REQUEST_PREFIX =
"devnet_query_request_0000000000000|";
export function getPrefix(network: Network) {
return network === "MAINNET"
? MAINNET_QUERY_REQUEST_PREFIX
: network === "TESTNET"
? TESTNET_QUERY_REQUEST_PREFIX
: DEVNET_QUERY_REQUEST_PREFIX;
}
export class QueryRequest {
constructor(
public nonce: number,
public requests: PerChainQueryRequest[] = [],
public version: number = 1
) {}
serialize(): Uint8Array {
const writer = new BinaryWriter()
.writeUint8(this.version)
.writeUint32(this.nonce)
.writeUint8(this.requests.length);
this.requests.forEach((request) =>
writer.writeUint8Array(request.serialize())
);
return writer.data();
}
static digest(network: Network, bytes: Uint8Array): Uint8Array {
const prefix = getPrefix(network);
const data = Buffer.concat([Buffer.from(prefix), bytes]);
return hexToUint8Array(utils.keccak256(data).slice(2));
}
}
export class PerChainQueryRequest {
constructor(public chainId: number, public query: ChainSpecificQuery) {}
serialize(): Uint8Array {
const writer = new BinaryWriter()
.writeUint16(this.chainId)
.writeUint8(this.query.type());
const queryData = this.query.serialize();
return writer
.writeUint32(queryData.length)
.writeUint8Array(queryData)
.data();
}
}
export interface ChainSpecificQuery {
type(): ChainQueryType;
serialize(): Uint8Array;
}
export enum ChainQueryType {
EthCall = 1,
}

View File

@ -0,0 +1,32 @@
import * as elliptic from "elliptic";
export function isValidHexString(s: string): boolean {
return /^(0x)?[0-9a-fA-F]+$/.test(s);
}
export function hexToUint8Array(s: string): Uint8Array {
if (!isValidHexString(s)) {
throw new Error(`${s} is not hex`);
}
if (s.startsWith("0x")) {
s = s.slice(2);
}
s.padStart(s.length + (s.length % 2), "0");
return new Uint8Array(Buffer.from(s, "hex"));
}
/**
* @param key Private key used to sign `data`
* @param data Data for signing
* @returns ECDSA secp256k1 signature
*/
export function sign(key: string, data: Uint8Array): string {
const ec = new elliptic.ec("secp256k1");
const keyPair = ec.keyFromPrivate(key);
const signature = keyPair.sign(data, { canonical: true });
const packed =
signature.r.toString("hex").padStart(64, "0") +
signature.s.toString("hex").padStart(64, "0") +
Buffer.from([signature.recoveryParam ?? 0]).toString("hex");
return packed;
}

View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./lib/cjs"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es6",
"module": "esnext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./lib/esm",
"strict": true,
"esModuleInterop": true,
"downlevelIteration": true,
"skipLibCheck": true,
"allowJs": true,
"lib": ["dom", "es5", "scripthost", "es2020.bigint"]
},
"include": ["src"],
"exclude": ["node_modules", "**/*.test.*", "**/__tests__/*"]
}