From bc1b5fb3ea5131f2bfbf2674e69e7a488f74cb8f Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 3 Oct 2023 19:02:48 -0500 Subject: [PATCH 1/2] feat(marshal): encode capData in 1 level of JSON --- packages/marshal/src/capDataJSON.js | 38 +++++++++ packages/marshal/test/test-marshal1.js | 97 +++++++++++++++++++++++ packages/marshal/test/translationTable.js | 35 ++++++++ 3 files changed, 170 insertions(+) create mode 100644 packages/marshal/src/capDataJSON.js create mode 100644 packages/marshal/test/test-marshal1.js create mode 100644 packages/marshal/test/translationTable.js diff --git a/packages/marshal/src/capDataJSON.js b/packages/marshal/src/capDataJSON.js new file mode 100644 index 0000000000..b2f79e7b3c --- /dev/null +++ b/packages/marshal/src/capDataJSON.js @@ -0,0 +1,38 @@ +// @ts-check + +const { Fail } = assert; + +/** @param {unknown} x */ +const assertJSON = x => { + assert.typeof(x, 'string'); + void JSON.parse(x); +}; + +/** + * @param {import('./types').CapData} capData - with "simple" slots; + * that is: slots whose JSON form has no occurrence of `:[`. + * @returns {string} + */ +export const capDataToJSON = ({ body, slots }) => { + assert(Array.isArray(slots)); + const slotj = JSON.stringify(slots); + slotj.indexOf(':[') < 0 || Fail`expected simple slots`; + const body1 = body.replace(/^#/, ''); + assertJSON(body1); + const json = `{"$body":${body1},"slots":${slotj}}`; + assertJSON(json); + return json; +}; + +export const JSONToCapData = json => { + assert.typeof(json, 'string'); + json.startsWith('{"$body":') || Fail`expected $body`; + json.endsWith('}') || Fail`expected }`; + const pos = json.lastIndexOf(':['); + pos > 0 || Fail`expected slots`; + const body = `#${json.slice('{"$body":'.length, pos - ',"slots"'.length)}`; + const slotj = json.slice(pos + 1, -1); + const slots = JSON.parse(slotj); + Array.isArray(slots) || Fail`expected slots to be Array`; + return { body, slots }; +}; diff --git a/packages/marshal/test/test-marshal1.js b/packages/marshal/test/test-marshal1.js new file mode 100644 index 0000000000..288c02888e --- /dev/null +++ b/packages/marshal/test/test-marshal1.js @@ -0,0 +1,97 @@ +/** + * @file avoid double-JSON encoding capData + */ + +// @ts-check + +// eslint-disable-next-line import/order +import { test } from './prepare-test-env-ava.js'; + +import { arbPassable } from '@endo/pass-style/tools.js'; +import { fc } from '@fast-check/ava'; +import { isKey, keyEQ } from '@endo/patterns'; + +import { Far, passStyleOf } from '@endo/pass-style'; +import { makeTranslationTable } from './translationTable.js'; +import { makeMarshal } from '../src/marshal.js'; +import { JSONToCapData, capDataToJSON } from '../src/capDataJSON.js'; + +const smallCaps = /** @type {const} */ ({ + serializeBodyFormat: 'smallcaps', + marshalSaveError: err => err, +}); + +const makeTestMarshal = () => { + const synthesizeRemotable = (_slot, iface) => + Far(iface.replace(/^Alleged: /, ''), {}); + const makeSlot = (v, serial) => { + const sty = passStyleOf(v); + if (sty === 'remotable') return `r${serial}`; + return `a(n) ${sty}`; + }; + const tt = makeTranslationTable(makeSlot, synthesizeRemotable); + + const m = makeMarshal(tt.convertValToSlot, tt.convertSlotToVal, smallCaps); + return m; +}; + +const suite = [ + { obj: null, json: '{"$body":null,"slots":[]}' }, + { obj: [1, 2, undefined], json: '{"$body":[1,2,"#undefined"],"slots":[]}' }, + { obj: { slots: [] }, json: '{"$body":{"slots":[]},"slots":[]}' }, +]; +harden(suite); + +test('encode example passables in 1 level of JSON', t => { + const m = makeTestMarshal(); + + for (const { obj, json } of suite) { + t.log(obj); + const cd = m.toCapData(obj); + const j = capDataToJSON(cd); + t.is(j, json); + + const cd2 = JSONToCapData(j); + t.deepEqual(cd, cd2); + const v = m.fromCapData(cd); + keyEQ(obj, v) ? t.pass() : t.deepEqual(obj, v); + } +}); + +test('encode arbitrary passable in 1 level of JSON', t => { + const m = makeTestMarshal(); + fc.assert( + fc.property(fc.record({ x: arbPassable }), ({ x }) => { + const { body, slots } = m.toCapData(x); + // t.log({ body, slots }); + const j = capDataToJSON({ body, slots }); + const cd = JSONToCapData(j); + // t.log({ cd }); + + const j2 = capDataToJSON(cd); + t.is(j, j2); + + const cd2 = JSONToCapData(j2); + t.deepEqual(cd, cd2); + + if (isKey(x)) { + const v = m.fromCapData(cd); + try { + if (keyEQ(x, v)) { + t.pass(); + } else { + // explain what's different + t.deepEqual(x, v); + } + } catch (err) { + if (err.message.startsWith('Map comparison not yet implemented:')) { + t.pass(); + } else { + t.fail(err); + } + } + } + }), + { numRuns: 5_000 }, + ); +}); diff --git a/packages/marshal/test/translationTable.js b/packages/marshal/test/translationTable.js new file mode 100644 index 0000000000..a521d863c4 --- /dev/null +++ b/packages/marshal/test/translationTable.js @@ -0,0 +1,35 @@ +// #region marshal-table +const makeSlot1 = (val, serial) => { + const prefix = Promise.resolve(val) === val ? 'promise' : 'object'; + return `${prefix}${serial}`; +}; + +export const makeTranslationTable = ( + makeSlot = makeSlot1, + makeVal = x => x, +) => { + const valToSlot = new Map(); + const slotToVal = new Map(); + + const convertValToSlot = val => { + if (valToSlot.has(val)) return valToSlot.get(val); + const slot = makeSlot(val, valToSlot.size); + valToSlot.set(val, slot); + slotToVal.set(slot, val); + return slot; + }; + + const convertSlotToVal = (slot, iface) => { + if (slotToVal.has(slot)) return slotToVal.get(slot); + if (makeVal) { + const val = makeVal(slot, iface); + valToSlot.set(val, slot); + slotToVal.set(slot, val); + return val; + } + throw Error(`no such ${iface}: ${slot}`); + }; + + return harden({ convertValToSlot, convertSlotToVal }); +}; +// #endregion marshal-table From 0667b1dcca1655fa707b4a2d33734c7f27c243be Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 3 Oct 2023 19:12:37 -0500 Subject: [PATCH 2/2] test: include example offer from agoric #7999 --- packages/marshal/test/test-marshal1.js | 46 +++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/marshal/test/test-marshal1.js b/packages/marshal/test/test-marshal1.js index 288c02888e..f4dc2f01bf 100644 --- a/packages/marshal/test/test-marshal1.js +++ b/packages/marshal/test/test-marshal1.js @@ -35,10 +35,54 @@ const makeTestMarshal = () => { return m; }; +const brands = { + IST: Far('IST Brand', {}), + ATOM: Far('ATOM Brand', {}), +}; + const suite = [ { obj: null, json: '{"$body":null,"slots":[]}' }, { obj: [1, 2, undefined], json: '{"$body":[1,2,"#undefined"],"slots":[]}' }, { obj: { slots: [] }, json: '{"$body":{"slots":[]},"slots":[]}' }, + // example from https://github.com/Agoric/agoric-sdk/issues/7999 + { + obj: { + method: 'executeOffer', + offer: { + id: 'bid-1688229012779', + invitationSpec: { + callPipe: [['makeBidInvitation', [brands.ATOM]]], + instancePath: ['auctioneer'], + source: 'agoricContract', + }, + offerArgs: { + maxBuy: { + brand: brands.ATOM, + value: 1_000_000_000_000n, + }, + offerPrice: { + denominator: { + brand: brands.ATOM, + value: 1n, + }, + numerator: { + brand: brands.IST, + value: 7n, + }, + }, + }, + proposal: { + give: { + Bid: { + brand: brands.IST, + value: 3000n, + }, + }, + }, + }, + }, + json: '{"$body":{"method":"executeOffer","offer":{"id":"bid-1688229012779","invitationSpec":{"callPipe":[["makeBidInvitation",["$0.Alleged: ATOM Brand"]]],"instancePath":["auctioneer"],"source":"agoricContract"},"offerArgs":{"maxBuy":{"brand":"$0","value":"+1000000000000"},"offerPrice":{"denominator":{"brand":"$0","value":"+1"},"numerator":{"brand":"$1.Alleged: IST Brand","value":"+7"}}},"proposal":{"give":{"Bid":{"brand":"$1","value":"+3000"}}}}},"slots":["r0","r1"]}', + }, ]; harden(suite); @@ -46,7 +90,7 @@ test('encode example passables in 1 level of JSON', t => { const m = makeTestMarshal(); for (const { obj, json } of suite) { - t.log(obj); + // t.log(obj); const cd = m.toCapData(obj); const j = capDataToJSON(cd); t.is(j, json);