Skip to content

Commit

Permalink
feat(vats): add query-string API for Address Hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelfig committed Dec 7, 2024
1 parent e29ba97 commit c3904ac
Show file tree
Hide file tree
Showing 2 changed files with 163 additions and 31 deletions.
79 changes: 64 additions & 15 deletions packages/vats/src/address-hooks.js
Original file line number Diff line number Diff line change
@@ -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<string, string | (string | null)[] | null>} HookQuery
*/

/**
* @typedef {`${string}${ADDRESS_HOOK_HUMAN_READABLE_SUFFIX}`} HookPrefix
*/
Expand Down Expand Up @@ -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<number>} [hookData]
* @param {ArrayLike<number>} 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);
Expand Down Expand Up @@ -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<number> }}
* @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(
Expand Down Expand Up @@ -145,11 +194,11 @@ export const decodeHookedAddressUnsafe = (
* @param {number} [charLimit]
* @returns {{
* baseAddress: string;
* hookData?: ArrayLike<number>;
* 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;
}
Expand Down
115 changes: 99 additions & 16 deletions packages/vats/test/address-hooks.test.js
Original file line number Diff line number Diff line change
@@ -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<number> | undefined,
* ArrayLike<number> | undefined,
* string,
* ]
* [string, ArrayLike<number> | undefined, ArrayLike<number>, string],
* { addressHooks: import('../src/address-hooks.js') }
* >}
*/
const roundtripMacro = test.macro({
Expand All @@ -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 || []),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
);

0 comments on commit c3904ac

Please sign in to comment.