Skip to content

Commit

Permalink
Merge pull request #7935 from Agoric/dc-cosmjs-interpose-net
Browse files Browse the repository at this point in the history
feat(casting): makeHttpClient for explicit net access with cosmjs
  • Loading branch information
dckc authored and mhofman committed Aug 7, 2023
2 parents 4fb678a + 468da2b commit b6382e4
Show file tree
Hide file tree
Showing 5 changed files with 353 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/casting/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions packages/casting/src/makeHttpClient.js
Original file line number Diff line number Diff line change
@@ -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());
},
});
};
196 changes: 196 additions & 0 deletions packages/casting/test/net-access-fixture.js
Original file line number Diff line number Diff line change
@@ -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<string, any>} 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;
};
97 changes: 97 additions & 0 deletions packages/casting/test/test-interpose-net-access.js
Original file line number Diff line number Diff line change
@@ -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<Awaited<ReturnType<typeof makeTestContext>>>} */
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,
});
});
2 changes: 2 additions & 0 deletions packages/cosmic-proto/vstorage/query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** @file for backwards compatibility */
export * from '../dist/agoric/vstorage/query.js';

0 comments on commit b6382e4

Please sign in to comment.