diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8910a9..41f35732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## `v0.0.39` + +### Features + +- Added the optional `height` params to the `FetchClient` to execute queries at a custom block height +- Added batching of queries to `FetchClient` (see `examples/batch-query`) + ## `v0.0.38` [breaking change] ### Features diff --git a/README.md b/README.md index 39b1d7c6..8a9b4af8 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,12 @@ See [`examples/solid-vite`](./examples/solid-vite) for a working example. ## Examples -Docs do not exist yet - see the [`examples`](./examples) folder for various working examples. +Docs do not exist yet - see the [`examples`](./examples) folder for various working examples: + +1. [How do I connect to third party wallets via browser extension or WalletConnect? How do I create, sign, and broadcast transactions?](./examples/solid-vite) +2. [How do I programmatically sign and broadcast transactions without relying on a third party wallet?](./examples/mnemonic-wallet) +3. [How do I verify signatures signed using the `signArbitrary` function?](./examples/verify-signatures) +4. [How do I batch queries to the blockchain?](./examples/batch-query) ## Modules diff --git a/examples/batch-query/package.json b/examples/batch-query/package.json new file mode 100644 index 00000000..c7f8f47f --- /dev/null +++ b/examples/batch-query/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cosmes/batch-query", + "private": true, + "main": "src/index.ts", + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "cosmes": "link:../.." + }, + "devDependencies": { + "@types/node": "^20.2.0", + "tsx": "^3.12.7", + "typescript": "^5.0.4" + } +} diff --git a/examples/batch-query/pnpm-lock.yaml b/examples/batch-query/pnpm-lock.yaml new file mode 100644 index 00000000..36a25e66 --- /dev/null +++ b/examples/batch-query/pnpm-lock.yaml @@ -0,0 +1,312 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + cosmes: + specifier: link:../.. + version: link:../.. + +devDependencies: + '@types/node': + specifier: ^20.2.0 + version: 20.8.6 + tsx: + specifier: ^3.12.7 + version: 3.13.0 + typescript: + specifier: ^5.0.4 + version: 5.2.2 + +packages: + + /@esbuild/android-arm64@0.18.20: + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.18.20: + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.18.20: + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.18.20: + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.18.20: + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.18.20: + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.18.20: + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.18.20: + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.18.20: + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.18.20: + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.18.20: + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.18.20: + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.18.20: + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.18.20: + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.18.20: + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.18.20: + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.18.20: + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.18.20: + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.18.20: + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.18.20: + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.18.20: + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.18.20: + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@types/node@20.8.6: + resolution: {integrity: sha512-eWO4K2Ji70QzKUqRy6oyJWUeB7+g2cRagT3T/nxYibYcT4y2BDL8lqolRXjTHmkZCdJfIPaY73KbJAZmcryxTQ==} + dependencies: + undici-types: 5.25.3 + dev: true + + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + dev: true + + /esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /get-tsconfig@4.7.2: + resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + + /source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /tsx@3.13.0: + resolution: {integrity: sha512-rjmRpTu3as/5fjNq/kOkOtihgLxuIz6pbKdj9xwP4J5jOLkBxw/rjN5ANw+KyrrOXV5uB7HC8+SrrSJxT65y+A==} + hasBin: true + dependencies: + esbuild: 0.18.20 + get-tsconfig: 4.7.2 + source-map-support: 0.5.21 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /undici-types@5.25.3: + resolution: {integrity: sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==} + dev: true diff --git a/examples/batch-query/src/index.ts b/examples/batch-query/src/index.ts new file mode 100644 index 00000000..06bafc09 --- /dev/null +++ b/examples/batch-query/src/index.ts @@ -0,0 +1,55 @@ +import { RpcClient, toBaseAccount } from "cosmes/client"; +import { + CosmosAuthV1beta1QueryAccountService as QueryAccountService, + CosmosBankV1beta1QueryAllBalancesService as QueryAllBalancesService, +} from "cosmes/protobufs"; + +RpcClient.newBatchQuery("https://phoenix-rpc.terra.dev") + .add( + QueryAllBalancesService, + { + address: + "terra1fd68ah02gr2y8ze7tm9te7m70zlmc7vjyyhs6xlhsdmqqcjud4dql4wpxr", + height: 7_500_000, // this will produce errors if the full node does not have the block + }, + (err, res) => + err + ? console.log("[1] QUERY ERROR:", err) + : console.log("[1] QUERY SUCCESS:", res.balances) + ) + .add( + QueryAccountService, + { + address: + "terra1fd68ah02gr2y8ze7tm9te7m70zlmc7vjyyhs6xlhsdmqqcjud4dql4wpxr", + }, + (err, res) => + err + ? console.log("[2] QUERY ERROR:", err) + : console.log("[2] QUERY SUCCESS:", toBaseAccount(res.account!)) + ) + .add( + QueryAllBalancesService, + { address: "terra1x9jh7pl623jaj8dlt4q53mpmv5mv5nlpzdpxfs" }, + (err, res) => + err + ? console.log("[3] QUERY ERROR:", err) + : console.log("[3] QUERY SUCCESS:", res.balances) + ) + .add( + QueryAccountService, + { address: "terra1x9jh7pl623jaj8dlt4q53mpmv5mv5nlpzdpxfs" }, + (err, res) => + err + ? console.log("[4] QUERY ERROR:", err) + : console.log("[4] QUERY SUCCESS:", toBaseAccount(res.account!)) + ) + .add( + QueryAccountService, + { address: "THIS IS AN INVALID ADDRESS" }, + (err, res) => + err + ? console.log("[5] QUERY ERROR:", err) // this should log an error + : console.log("[5] QUERY SUCCESS:", toBaseAccount(res.account!)) + ) + .send(); diff --git a/examples/batch-query/tsconfig.json b/examples/batch-query/tsconfig.json new file mode 100644 index 00000000..af71ed40 --- /dev/null +++ b/examples/batch-query/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "baseUrl": ".", + "moduleResolution": "bundler", + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/package.json b/package.json index 93d7ce38..ab60c5fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmes", - "version": "0.0.38", + "version": "0.0.39", "private": false, "packageManager": "pnpm@8.3.0", "sideEffects": false, diff --git a/src/client/clients/FetchClient.ts b/src/client/clients/FetchClient.ts index 574e3072..24bd1ed9 100644 --- a/src/client/clients/FetchClient.ts +++ b/src/client/clients/FetchClient.ts @@ -1,3 +1,5 @@ +import { JsonValue } from "@bufbuild/protobuf"; + /** * A simple and minimal wrapper around the native `fetch` API. */ @@ -20,10 +22,7 @@ export class FetchClient { * Performs a POST request to the given `endpoint`, and returns the * JSON response. */ - public static async post( - endpoint: string, - body: Record - ): Promise { + public static async post(endpoint: string, body: JsonValue): Promise { const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/client/clients/RpcClient.ts b/src/client/clients/RpcClient.ts index 5c365f20..54040623 100644 --- a/src/client/clients/RpcClient.ts +++ b/src/client/clients/RpcClient.ts @@ -1,4 +1,4 @@ -import { Message, PartialMessage } from "@bufbuild/protobuf"; +import { JsonValue, Message, PartialMessage } from "@bufbuild/protobuf"; import { base16, base64 } from "cosmes/codec"; import { FetchClient } from "./FetchClient"; @@ -49,11 +49,20 @@ type BroadcastTxResult = { log: string; }; +type RequestMessage> = PartialMessage & { + /** + * The block height at which the query should be executed. Providing a height + * that is outside the range of the full node will result in an error. Leave + * this field empty to default to the latest block. + */ + height?: number | undefined; +}; + export class RpcClient { private static async doRequest( endpoint: string, method: string, - params: Record + params: JsonValue ) { const { result, error } = await FetchClient.post>(endpoint, { id: Date.now(), @@ -68,13 +77,13 @@ export class RpcClient { } /** - * Posts an `abci_query` request to the RPC `endpoint`. If successful, - * returns the response, otherwise throws an error. + * Posts an ABCI query to the RPC `endpoint`. If successful, returns the response, + * otherwise throws an error. */ public static async query, U extends Message>( endpoint: string, { typeName, method, Request, Response }: QueryService, - requestMsg: PartialMessage + requestMsg: RequestMessage ): Promise { const { response } = await this.doRequest( endpoint, @@ -82,6 +91,7 @@ export class RpcClient { { path: `/${typeName}/${method}`, data: base16.encode(new Request(requestMsg).toBinary()), + ...(requestMsg.height ? { height: requestMsg.height.toString() } : {}), } ); const { log, value } = response; @@ -111,4 +121,82 @@ export class RpcClient { } return hash; } + + /** + * Creates a new ABCI batch query. + */ + public static newBatchQuery(endpoint: string): BatchQuery { + return new BatchQuery(endpoint); + } +} + +class BatchQuery { + private readonly endpoint: string; + private readonly queries: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryService: QueryService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + requestMsg: RequestMessage; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (err: Error | null, response: any) => unknown; + }[] = []; + + constructor(endpoint: string) { + this.endpoint = endpoint; + } + + /** + * Adds an `abci_query` to this query batch. + * + * @param callback An error-first callback function for the response of the query. + * If `err` is not `null`, `response` will be `null` and should not be used. + */ + public add, U extends Message>( + queryService: QueryService, + requestMsg: RequestMessage, + callback: (err: Error | null, response: U) => unknown + ) { + this.queries.push({ queryService, requestMsg, callback }); + return this; + } + + /** + * Executes the batched query. + */ + public async send() { + if (this.queries.length === 0) { + return; + } + const payload = this.queries.map(({ queryService, requestMsg }, idx) => ({ + id: idx, + jsonrpc: "2.0", + method: "abci_query", + params: { + path: `/${queryService.typeName}/${queryService.method}`, + data: base16.encode(new queryService.Request(requestMsg).toBinary()), + ...(requestMsg.height ? { height: requestMsg.height.toString() } : {}), + }, + })); + const res = await FetchClient.post< + // Array is returned if and only if the payload has more than one query + Response[] | Response + >(this.endpoint, payload); + const results = Array.isArray(res) ? res : [res]; + for (const { id, result, error } of results) { + const { queryService, callback: handler } = this.queries[id]; + if (error != null) { + handler(new Error(error.data), null); + continue; + } + const { log, value } = result.response; + if (!value) { + handler(new Error(log), null); + continue; + } + const responseMsg = queryService.Response.fromBinary( + base64.decode(value) + ); + handler(null, responseMsg); + } + } }