diff --git a/packages/casting/package.json b/packages/casting/package.json index 553399e7823..ec982f7f5fd 100644 --- a/packages/casting/package.json +++ b/packages/casting/package.json @@ -38,6 +38,7 @@ "node-fetch": "^2.6.0" }, "devDependencies": { + "@agoric/cosmic-proto": "^0.3.0", "@endo/ses-ava": "^0.2.40", "@types/node-fetch": "^2.6.2", "ava": "^5.3.0", diff --git a/packages/casting/src/makeHttpClient.js b/packages/casting/src/makeHttpClient.js new file mode 100644 index 00000000000..0a72ccac72f --- /dev/null +++ b/packages/casting/src/makeHttpClient.js @@ -0,0 +1,57 @@ +// @ts-check + +const { freeze } = Object; + +const filterBadStatus = res => { + if (res.status >= 400) { + throw new Error(`Bad status on response: ${res.status}`); + } + return res; +}; + +/** + * Make an RpcClient using explicit access to the network. + * + * The RpcClient implementations included in cosmjs + * such as {@link https://cosmos.github.io/cosmjs/latest/tendermint-rpc/classes/HttpClient.html HttpClient} + * use ambient authority (fetch or axios) for network access. + * + * To facilitate cooperation without vulnerability, + * as well as unit testing, etc. this RpcClient maker takes + * network access as a parameter, following + * {@link https://github.com/Agoric/agoric-sdk/wiki/OCap-Discipline|OCap Discipline}. + * + * @param {string} url + * @param {typeof window.fetch} fetch + * @returns {import('@cosmjs/tendermint-rpc').RpcClient} + */ +export const makeHttpClient = (url, fetch) => { + const headers = {}; // XXX needed? + + // based on cosmjs 0.30.1: + // https://github.com/cosmos/cosmjs/blob/33271bc51cdc865cadb647a1b7ab55d873637f39/packages/tendermint-rpc/src/rpcclients/http.ts#L37 + // https://github.com/cosmos/cosmjs/blob/33271bc51cdc865cadb647a1b7ab55d873637f39/packages/tendermint-rpc/src/rpcclients/httpclient.ts#L25 + return freeze({ + disconnect: () => { + // nothing to be done + }, + + /** + * @param {import('@cosmjs/json-rpc').JsonRpcRequest} request + */ + execute: async request => { + const settings = { + method: 'POST', + body: request ? JSON.stringify(request) : undefined, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + ...headers, + }, + }; + return fetch(url, settings) + .then(filterBadStatus) + .then(res => res.json()); + }, + }); +}; diff --git a/packages/casting/test/net-access-fixture.js b/packages/casting/test/net-access-fixture.js new file mode 100644 index 00000000000..d89f438271b --- /dev/null +++ b/packages/casting/test/net-access-fixture.js @@ -0,0 +1,196 @@ +const { stringify: jq } = JSON; + +/** + * @file to regenerate + * 1. set RECORDING=true in test-interpose-net-access.js + * 2. run: yarn test test/test-test-interpose-net-access.js --update-snapshots + * 3. for each map in test-test-interpose-net-access.js.md, copy it and + * 4. replace all occurences of => with : and paste as args to Object.fromEntries() + * 5. change RECORDING back to false + */ +export const web1 = new Map([ + [ + jq([ + 'https://emerynet.rpc.agoric.net/', + { + method: 'POST', + body: jq({ + id: 1208387614, + method: 'no-such-method', + params: [], + jsonrpc: '2.0', + }), + headers: { 'Content-Type': 'application/json' }, + }, + ]), + { + error: { + code: -32601, + message: 'Method not found', + }, + id: 1208387614, + jsonrpc: '2.0', + }, + ], + [ + jq([ + 'https://emerynet.rpc.agoric.net/', + { + method: 'POST', + body: jq({ + jsonrpc: '2.0', + id: 797030719, + method: 'abci_query', + params: { + path: '/cosmos.bank.v1beta1.Query/Balance', + data: '0a2d61676f726963313430646d6b727a326534326572676a6a37677976656a687a6d6a7a7572767165713832616e67120475697374', + prove: false, + }, + }), + headers: { 'Content-Type': 'application/json' }, + }, + ]), + { + id: 797030719, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '123985', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + value: 'ChAKBHVpc3QSCDI1MDUwMDAw', + }, + }, + }, + ], +]); + +export const web2 = new Map([ + [ + jq([ + 'https://emerynet.rpc.agoric.net/', + { + method: 'POST', + body: jq({ + jsonrpc: '2.0', + id: 1757612624, + method: 'abci_query', + params: { + path: '/agoric.vstorage.Query/Children', + data: '', + prove: false, + }, + }), + headers: { 'Content-Type': 'application/json' }, + }, + ]), + { + id: 1757612624, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '123985', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + value: + 'CgxhY3Rpdml0eWhhc2gKCmJlYW5zT3dpbmcKBmVncmVzcwoTaGlnaFByaW9yaXR5U2VuZGVycwoJcHVibGlzaGVkCgpzd2luZ1N0b3Jl', + }, + }, + }, + ], +]); + +/** + * @param {string} str + * ack: https://stackoverflow.com/a/7616484 + */ +const hashCode = str => { + let hash = 0; + let i; + let chr; + if (str.length === 0) return hash; + for (i = 0; i < str.length; i += 1) { + chr = str.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + chr; + // eslint-disable-next-line no-bitwise + hash |= 0; // Convert to 32bit integer + } + return hash; +}; + +/** + * Normalize JSON RPC request ID + * + * tendermint-rpc generates ids using ambient access to Math.random() + * So we normalize them to a hash of the rest of the JSON. + * + * Earlier, we tried a sequence number, but it was non-deterministic + * with multiple interleaved requests. + * + * @param {string} argsKey + */ +const normalizeID = argsKey => { + // arbitrary string unlikely to occur in a request. from `pwgen 16 -1` + const placeholder = 'Ajaz1chei7ohnguv'; + + const noid = argsKey.replace(/\\"id\\":\d+/, `\\"id\\":${placeholder}`); + const id = Math.abs(hashCode(noid)); + return noid.replace(placeholder, `${id}`); +}; + +/** + * Wrap `fetch` to capture JSON RPC IO traffic. + * + * @param {typeof window.fetch} fetch + * returns wraped fetch along with a .web map for use with {@link replayIO} + */ +export const captureIO = fetch => { + const web = new Map(); + /** @type {typeof window.fetch} */ + // @ts-expect-error mock + const f = async (...args) => { + const key = normalizeID(JSON.stringify(args)); + const resp = await fetch(...args); + return { + json: async () => { + const data = await resp.json(); + web.set(key, data); + return data; + }, + }; + }; + return { fetch: f, web }; +}; + +/** + * Replay captured JSON RPC IO. + * + * @param {Map} web map from + * JSON-stringified fetch args to fetched JSON data. + */ +export const replayIO = web => { + /** @type {typeof window.fetch} */ + // @ts-expect-error mock + const f = async (...args) => { + const key = normalizeID(JSON.stringify(args)); + const data = web.get(key); + if (!data) { + throw Error(`no data for ${key}`); + } + return { + json: async () => data, + }; + }; + return f; +}; diff --git a/packages/casting/test/test-interpose-net-access.js b/packages/casting/test/test-interpose-net-access.js new file mode 100644 index 00000000000..bcf54166c78 --- /dev/null +++ b/packages/casting/test/test-interpose-net-access.js @@ -0,0 +1,97 @@ +// @ts-check +/* global globalThis */ +import anyTest from 'ava'; +import { + createProtobufRpcClient, + QueryClient, + setupBankExtension, +} from '@cosmjs/stargate'; +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { QueryClientImpl } from '@agoric/cosmic-proto/vstorage/query.js'; + +import { makeHttpClient } from '../src/makeHttpClient.js'; +import { captureIO, replayIO, web1, web2 } from './net-access-fixture.js'; + +/** @type {import('ava').TestFn>>} */ +const test = /** @type {any} */ (anyTest); + +const RECORDING = false; + +const makeTestContext = async () => { + return { fetch: globalThis.fetch }; +}; + +test.before(async t => { + t.context = await makeTestContext(); +}); + +const scenario1 = { + endpoint: 'https://emerynet.rpc.agoric.net/', + request: { + id: 1, + method: 'no-such-method', + params: [], + }, + gov2: { + addr: 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang', + balance: { amount: '25050000', denom: 'uist' }, + }, +}; + +test('interpose net access', async t => { + const fetchMock = replayIO(web1); + const rpcClient = makeHttpClient(scenario1.endpoint, fetchMock); + + t.log('raw JSON RPC'); + const res = await rpcClient.execute({ + ...scenario1.request, + jsonrpc: '2.0', + }); + t.like(res, { error: { message: 'Method not found' } }); + + t.log('Cosmos SDK RPC: balance query'); + const tmClient = await Tendermint34Client.create(rpcClient); + const qClient = new QueryClient(tmClient); + const ext = setupBankExtension(qClient); + const actual = await ext.bank.balance( + scenario1.gov2.addr, + scenario1.gov2.balance.denom, + ); + + t.deepEqual(actual, scenario1.gov2.balance); +}); + +const scenario2 = { + endpoint: 'https://emerynet.rpc.agoric.net/', + children: [ + 'activityhash', + 'beansOwing', + 'egress', + 'highPrioritySenders', + 'published', + 'swingStore', + ], +}; + +test(`vstorage query: Children (RECORDING: ${RECORDING})`, async t => { + const { context: io } = t; + + const { fetch: fetchMock, web } = io.recording + ? captureIO(io.fetch) + : { fetch: replayIO(web2), web: new Map() }; + const rpcClient = makeHttpClient(scenario2.endpoint, fetchMock); + + const tmClient = await Tendermint34Client.create(rpcClient); + const qClient = new QueryClient(tmClient); + const rpc = createProtobufRpcClient(qClient); + const queryService = new QueryClientImpl(rpc); + + const children = await queryService.Children({ path: '' }); + if (io.recording) { + t.snapshot(web); + } + t.deepEqual(children, { + children: scenario2.children, + pagination: undefined, + }); +}); diff --git a/packages/cosmic-proto/vstorage/query.js b/packages/cosmic-proto/vstorage/query.js new file mode 100644 index 00000000000..30ccbd27d81 --- /dev/null +++ b/packages/cosmic-proto/vstorage/query.js @@ -0,0 +1,2 @@ +/** @file for backwards compatibility */ +export * from '../dist/agoric/vstorage/query.js';