From c3904ac6bbfb6d7c9a4fd909289ffd8b156e3683 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 5 Dec 2024 23:34:13 -0600 Subject: [PATCH] feat(vats): add `query-string` API for Address Hooks --- packages/vats/src/address-hooks.js | 79 +++++++++++++--- packages/vats/test/address-hooks.test.js | 115 +++++++++++++++++++---- 2 files changed, 163 insertions(+), 31 deletions(-) diff --git a/packages/vats/src/address-hooks.js b/packages/vats/src/address-hooks.js index 5fde5bde3b8..1489ddeba19 100644 --- a/packages/vats/src/address-hooks.js +++ b/packages/vats/src/address-hooks.js @@ -1,16 +1,37 @@ /** * @module address-hooks - * @file This module provides functions for encoding and decoding bech32 - * addresses, and hooked addresses which attach a bech32 base address with - * arbitrary hookData bytes. + * @file This module provides functions for encoding and decoding address hooks + * which are comprised of a bech32 base address with an HTTP query string, all + * wrapped in a bech32 envelope. + * @example + * + * import { + * encodeAddressHook, + * decodeAddressHook, + * } from '@agoric/vat/src/address-hooks.js'; + * + * const baseAddress = 'agoric1qqp0e5ys'; + * const query = { key: 'value', foo: ['bar', 'baz'] }; + * + * const addressHook = encodeAddressHook(baseAddress, query); + * + * // 'agoric-hook1qqlkvmm0843xzu3xvehk70tzv9azv6m90y7hvctvw4jsqqg6g6dxe' + * const decoded = decodeAddressHook(addressHook); + * + * // { baseAddress: 'agoric1qqp0e5ys', query: { key: 'value', foo: ['bar', 'baz'] } } */ /* eslint-disable no-bitwise */ import { bech32 } from 'bech32'; +import queryString from 'query-string'; // The default maximum number of characters in a bech32-encoded hooked address. export const DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT = 1000; +/** + * @typedef {Record} HookQuery + */ + /** * @typedef {`${string}${ADDRESS_HOOK_HUMAN_READABLE_SUFFIX}`} HookPrefix */ @@ -52,20 +73,20 @@ export const encodeBech32 = ( }; /** - * Encode a base address and hook data into a bech32-encoded hooked address. The + * Join a base address and hook data into a bech32-encoded hooked address. The * bech32-payload is: * - * | 0..ba | ba..-2 | 2 | - * | baseAddress | hookData | ba | + * | 0..ba | ba..-2 | 2 | + * | baseAddressBytes | hookData | ba | * * @param {string} baseAddress - * @param {ArrayLike} [hookData] + * @param {ArrayLike} hookData * @param {number} [charLimit] * @returns {string} */ -export const encodeHookedAddress = ( +export const joinHookedAddress = ( baseAddress, - hookData = [], + hookData, charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, ) => { const { prefix: innerPrefix, bytes } = decodeBech32(baseAddress, charLimit); @@ -98,18 +119,46 @@ export const encodeHookedAddress = ( ); }; +/** + * @param {string} baseAddress + * @param {HookQuery} query + */ +export const encodeAddressHook = (baseAddress, query) => { + const queryStr = queryString.stringify(query); + + const te = new TextEncoder(); + const hookData = te.encode(`?${queryStr}`); + return joinHookedAddress(baseAddress, hookData); +}; + +/** + * @param {string} addressHook + * @returns {{ baseAddress: string; query: HookQuery }} + */ +export const decodeAddressHook = addressHook => { + const { baseAddress, hookData } = splitHookedAddress(addressHook); + const hookStr = new TextDecoder().decode(hookData); + if (hookStr && !hookStr.startsWith('?')) { + throw Error(`Hook data does not start with '?': ${hookStr}`); + } + + /** @type {HookQuery} */ + const query = queryString.parse(hookStr); + return { baseAddress, query }; +}; + /** * @param {string} specimen * @param {number} [charLimit] - * @returns {string | { baseAddress: string; hookData?: ArrayLike }} + * @returns {string | { baseAddress: string; hookData: Uint8Array }} */ -export const decodeHookedAddressUnsafe = ( +export const splitHookedAddressUnsafe = ( specimen, charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, ) => { const { prefix: outerPrefix, bytes } = decodeBech32(specimen, charLimit); if (!outerPrefix.endsWith(ADDRESS_HOOK_HUMAN_READABLE_SUFFIX)) { - return { baseAddress: specimen, hookData: undefined }; + return { baseAddress: specimen, hookData: new Uint8Array() }; } const innerPrefix = outerPrefix.slice( @@ -145,11 +194,11 @@ export const decodeHookedAddressUnsafe = ( * @param {number} [charLimit] * @returns {{ * baseAddress: string; - * hookData?: ArrayLike; + * hookData: Uint8Array; * }} */ -export const decodeHookedAddress = (specimen, charLimit) => { - const result = decodeHookedAddressUnsafe(specimen, charLimit); +export const splitHookedAddress = (specimen, charLimit) => { + const result = splitHookedAddressUnsafe(specimen, charLimit); if (typeof result === 'object') { return result; } diff --git a/packages/vats/test/address-hooks.test.js b/packages/vats/test/address-hooks.test.js index aefc66216f2..5b6dc5ac4cb 100644 --- a/packages/vats/test/address-hooks.test.js +++ b/packages/vats/test/address-hooks.test.js @@ -1,19 +1,45 @@ -import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { test as rawTest } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; -import { - encodeHookedAddress, - decodeHookedAddress, - encodeBech32, -} from '../src/address-hooks.js'; +import bundleSourceAmbient from '@endo/bundle-source'; +import { importBundle } from '@endo/import-bundle'; + +/** + * @type {import('ava').TestFn<{ + * addressHooks: import('../src/address-hooks.js'); + * }>} + */ +const test = rawTest; + +const makeTestContext = async () => { + const bundleSource = bundleSourceAmbient; + const loadBundle = async specifier => { + const modulePath = new URL(specifier, import.meta.url).pathname; + const bundle = await bundleSource(modulePath); + return bundle; + }; + + const evaluateBundle = async (bundle, endowments = {}) => { + return await importBundle(bundle, endowments); + }; + + const importSpecifier = async (specifier, endowments = {}) => { + const bundle = await loadBundle(specifier); + return await evaluateBundle(bundle, endowments); + }; + + const addressHooks = await importSpecifier('../src/address-hooks.js'); + + return { addressHooks }; +}; + +test.before(async t => { + t.context = await makeTestContext(); +}); /** * @type {import('ava').Macro< - * [ - * string, - * ArrayLike | undefined, - * ArrayLike | undefined, - * string, - * ] + * [string, ArrayLike | undefined, ArrayLike, string], + * { addressHooks: import('../src/address-hooks.js') } * >} */ const roundtripMacro = test.macro({ @@ -22,10 +48,12 @@ const roundtripMacro = test.macro({ return `${providedTitle}${space}prefix: ${prefix}, addrBytes: ${addrBytes}, hookData: ${hookData}`; }, exec(t, prefix, addrBytes, hookData, expected) { + const { encodeBech32, joinHookedAddress, splitHookedAddress } = + t.context.addressHooks; const baseAddress = encodeBech32(prefix, addrBytes || []); - const encoded = encodeHookedAddress(baseAddress, hookData); + const encoded = joinHookedAddress(baseAddress, hookData); t.deepEqual(encoded, expected); - const decoded = decodeHookedAddress(encoded); + const decoded = splitHookedAddress(encoded); t.deepEqual(decoded, { baseAddress, hookData: new Uint8Array(hookData || []), @@ -90,15 +118,17 @@ const lengthCheckMacro = test.macro({ return `${providedTitle}${limitDesc}${throwsDesc}`; }, exec(t, prefix, addrBytes, hookData, charLimit, throws) { + const { encodeBech32, joinHookedAddress, splitHookedAddress } = + t.context.addressHooks; const baseAddress = encodeBech32(prefix, addrBytes, charLimit); - const make = () => encodeHookedAddress(baseAddress, hookData, charLimit); + const make = () => joinHookedAddress(baseAddress, hookData, charLimit); if (throws) { t.throws(make, throws); return; } const encoded = make(); t.log('encoded', encoded, addrBytes); - const decoded = decodeHookedAddress(encoded, charLimit); + const decoded = splitHookedAddress(encoded, charLimit); t.deepEqual(decoded, { baseAddress, hookData, @@ -130,3 +160,56 @@ const lengthCheckMacro = test.macro({ ); } } + +/** + * @type {import('ava').Macro< + * [ + * baseAddress: string, + * query: import('../src/address-hooks.js').HookQuery, + * expected: string, + * ] + * >} + */ +const addressHookMacro = test.macro({ + title(providedTitle = '', baseAddress, query) { + return `${providedTitle} ${baseAddress} ${JSON.stringify(query)}`; + }, + exec(t, baseAddress, query, expected) { + const { encodeAddressHook, splitHookedAddress, decodeAddressHook } = + t.context.addressHooks; + const encoded = encodeAddressHook(baseAddress, query); + t.log('encoded', encoded); + t.is(encoded, expected); + + const { baseAddress: ba1, hookData: hookData } = + splitHookedAddress(encoded); + t.is(ba1, baseAddress); + + const td = new TextDecoder(); + t.log('splitHookedAddress', ba1 + td.decode(hookData)); + + const { baseAddress: decodedBaseAddress, query: decodedQuery } = + decodeAddressHook(encoded); + t.is(decodedBaseAddress, baseAddress); + t.deepEqual(decodedQuery, query); + }, +}); + +test( + 'agoric hook', + addressHookMacro, + 'agoric1qqp0e5ys', + { d: null, a: 'b', c: ['d', 'd2'] }, + 'agoric-hook1qqlkz0tzye3n6epxvv7kgv3xvsqqz6cpcpc', +); + +test( + 'cosmos hook', + addressHookMacro, + 'cosmos1qqxuevtt', + { + everything: null, + dst: ['a', 'b', 'c'], + }, + 'cosmos-hook1qqlkgum584sjvernws7kyfnywd6r6cexv4mx2unew35xjmn8qqqsf5mhsw', +);