From dbad30b11b5af70f585b31194caf6e46c78a73bf Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 2 Dec 2024 22:44:40 -0600 Subject: [PATCH 1/4] feat(vats): first cut of Address Hooks in JS --- packages/cosmic-proto/package.json | 9 +- packages/cosmic-proto/src/address-hooks.js | 249 ++++++++++++++++++ .../cosmic-proto/test/address-hooks.test.js | 242 +++++++++++++++++ packages/cosmic-proto/test/vatsafe.test.js | 1 - 4 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 packages/cosmic-proto/src/address-hooks.js create mode 100644 packages/cosmic-proto/test/address-hooks.test.js diff --git a/packages/cosmic-proto/package.json b/packages/cosmic-proto/package.json index 6e0081a39d1..64d68d60a22 100644 --- a/packages/cosmic-proto/package.json +++ b/packages/cosmic-proto/package.json @@ -20,6 +20,10 @@ "default": "./dist/index.js" }, "./package.json": "./package.json", + "./address-hooks.js": { + "types": "./dist/address-hooks.d.ts", + "default": "./dist/address-hooks.js" + }, "./agoric/*.js": { "types": "./dist/codegen/agoric/*.d.ts", "default": "./dist/codegen/agoric/*.js" @@ -149,6 +153,7 @@ "@cosmology/telescope": "https://gitpkg.vercel.app/agoric-labs/telescope/packages/telescope?8d2c2f6ba637a5578eead09a7368dc41c262a9d0", "@endo/bundle-source": "^3.5.0", "@endo/import-bundle": "^1.3.2", + "@endo/ses-ava": "^1.2.8", "ava": "^5.3.1", "rimraf": "^5.0.0", "tsd": "^0.31.1", @@ -156,7 +161,9 @@ }, "dependencies": { "@endo/base64": "^1.0.9", - "@endo/init": "^1.1.7" + "@endo/init": "^1.1.7", + "bech32": "^2.0.0", + "query-string": "^9.1.1" }, "resolutions": { "**/axios": "^1.6.7", diff --git a/packages/cosmic-proto/src/address-hooks.js b/packages/cosmic-proto/src/address-hooks.js new file mode 100644 index 00000000000..b51fc4e4751 --- /dev/null +++ b/packages/cosmic-proto/src/address-hooks.js @@ -0,0 +1,249 @@ +/** + * @module address-hooks + * @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/cosmic-proto/address-hooks.js'; + * + * const baseAddress = 'agoric1qqp0e5ys'; + * const query = { key: 'value', foo: ['bar', 'baz'] }; + * + * const addressHook = encodeAddressHook(baseAddress, query); + * // 'agoric10rchqqplvehk70tzv9ezven0du7kyct6ye4k27faweskcat9qqqstnf2eq' + * + * addressHook.startsWith('agoric10rch'); + * // true + * + * const decoded = decodeAddressHook(addressHook); + * // { + * // baseAddress: 'agoric1qqp0e5ys', + * // query: [Object: null prototype] { foo: [ 'bar', 'baz' ], key: 'value' } + * // } + */ + +/* eslint-disable no-bitwise */ +import { bech32 } from 'bech32'; +import queryString from 'query-string'; + +// ADDRESS_HOOK_VERSION is the version of the address hook format used in this +// module. +const ADDRESS_HOOK_VERSION = 0; + +if ((ADDRESS_HOOK_VERSION & 0x0f) !== ADDRESS_HOOK_VERSION) { + throw Error(`ADDRESS_HOOK_VERSION ${ADDRESS_HOOK_VERSION} exceeds 0x0f`); +} + +// AddressHookMagic is a magic byte prefix that identifies a hooked address. +// Chosen to make bech32 address hooks that look like "agoric10rch..." +const ADDRESS_HOOK_MAGIC = new Uint8Array([ + 0x78, + 0xf1, + 0x70 | ADDRESS_HOOK_VERSION, +]); + +/** + * The default maximum number of characters in a bech32-encoded hooked address. + */ +export const DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT = 1024; + +/** + * @typedef {Record} HookQuery A + * record of query keys mapped to query values. `null` values denote valueless + * keys. Array values denote multiple occurrences of a key: + * + * { key: null } // '?key' + * { key: 'value' } // '?key=value' + * { key: ['value1', 'value2', 'value3'] } // '?key=value1&key=value2&key=value3' + * { key: ['value1', null, 'value3'] } // '?key=value1&key&key=value3' + */ + +export const BASE_ADDRESS_LENGTH_BYTES = 2; + +/** + * @param {string} specimen + * @param {number} [charLimit] + * @returns {{ prefix: string; bytes: Uint8Array }} + */ +export const decodeBech32 = ( + specimen, + charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, +) => { + const { prefix, words } = bech32.decode(specimen, charLimit); + const rawBytes = bech32.fromWords(words); + + const bytes = new Uint8Array(rawBytes); + return { prefix, bytes }; +}; + +/** + * @param {string} humanReadablePart + * @param {ArrayLike} bytes + * @param {number} [charLimit] + * @returns {string} + */ +export const encodeBech32 = ( + humanReadablePart, + bytes, + charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, +) => { + const words = bech32.toWords(bytes); + return bech32.encode(humanReadablePart, words, charLimit); +}; + +/** + * Join raw base address bytes and hook data into a bech32-encoded hooked + * address. The bech32-payload is: + * + * | offset | 0 | 3 | 3+len(baseAddress) | len(payload)-2 | + * | ------ | ----- | ----------- | ------------------ | ---------------- | + * | data | magic | baseAddress | hookData | len(baseAddress) | + * + * `magic` is a 3-byte prefix that identifies a hooked address and its version + * nibble, whose value is 4 bits (between 0 and 0xf (15)). Currently, the only + * supported version is 0. + * + * 0x78, 0xf1, (0x70 | ADDRESS_HOOK_VERSION), + * + * This magic prefix encodes as `0rch`, regardless of the version or HRP (e.g. + * `agoric10rch`). + * + * @param {string} baseAddress + * @param {ArrayLike} hookData + * @param {number} [charLimit] + * @returns {string} + */ +export const joinHookedAddress = ( + baseAddress, + hookData, + charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, +) => { + const { prefix, bytes } = decodeBech32(baseAddress, charLimit); + + const baseAddressLength = bytes.length; + const b = baseAddressLength; + const hd = hookData.length; + + const maxBaseAddressLength = 2 ** (BASE_ADDRESS_LENGTH_BYTES * 8); + if (b >= maxBaseAddressLength) { + throw RangeError( + `Base address length 0x${b.toString(16)} exceeds maximum 0x${maxBaseAddressLength.toString(16)}`, + ); + } + + if (!Number.isSafeInteger(hd) || hd < 0) { + throw RangeError(`Hook data length ${hd} is not a non-negative integer`); + } + + const magicLength = ADDRESS_HOOK_MAGIC.length; + const hookBuf = new Uint8Array( + magicLength + b + hd + BASE_ADDRESS_LENGTH_BYTES, + ); + hookBuf.set(ADDRESS_HOOK_MAGIC, 0); + hookBuf.set(bytes, magicLength); + hookBuf.set(hookData, magicLength + b); + + // Append the address length bytes, since we've already ensured these do not + // exceed maxBaseAddressLength above. These are big-endian because the length + // is at the end of the payload, so if we want to support more bytes for the + // length, we just need encroach further into the payload. We can do that + // without changing the meaning of the bytes at the end of existing payloads. + let len = b; + for (let i = 0; i < BASE_ADDRESS_LENGTH_BYTES; i += 1) { + hookBuf[hookBuf.length - 1 - i] = len & 0xff; + len >>>= 8; + } + + return encodeBech32(prefix, hookBuf, charLimit); +}; + +/** + * @param {string} baseAddress + * @param {HookQuery} query + * @param {number} [charLimit] + */ +export const encodeAddressHook = (baseAddress, query, charLimit) => { + const queryStr = queryString.stringify(query); + + const te = new TextEncoder(); + const hookData = te.encode(`?${queryStr}`); + return joinHookedAddress(baseAddress, hookData, charLimit); +}; + +/** + * @param {string} addressHook + * @param {number} [charLimit] + * @returns {{ baseAddress: string; query: HookQuery }} + */ +export const decodeAddressHook = (addressHook, charLimit) => { + const { baseAddress, hookData } = splitHookedAddress(addressHook, charLimit); + 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: Uint8Array }} + */ +export const splitHookedAddressUnsafe = ( + specimen, + charLimit = DEFAULT_HOOKED_ADDRESS_CHAR_LIMIT, +) => { + const { prefix, bytes } = decodeBech32(specimen, charLimit); + + const magicLength = ADDRESS_HOOK_MAGIC.length; + for (let i = 0; i < magicLength; i += 1) { + if (bytes[i] !== ADDRESS_HOOK_MAGIC[i]) { + return { baseAddress: specimen, hookData: new Uint8Array() }; + } + } + + let len = 0; + for (let i = BASE_ADDRESS_LENGTH_BYTES - 1; i >= 0; i -= 1) { + const byte = bytes.at(-i - 1); + if (byte === undefined) { + return `Cannot get base address length from byte ${-i - 1} of ${bytes.length}`; + } + len <<= 8; + len |= byte; + } + + const b = len; + if (b > bytes.length - BASE_ADDRESS_LENGTH_BYTES - magicLength) { + return `Base address length 0x${b.toString(16)} is longer than specimen length ${bytes.length - BASE_ADDRESS_LENGTH_BYTES - magicLength}`; + } + + const baseAddressBuf = bytes.subarray(magicLength, magicLength + b); + const baseAddress = encodeBech32(prefix, baseAddressBuf, charLimit); + + const hookData = bytes.subarray(magicLength + b, -BASE_ADDRESS_LENGTH_BYTES); + + return { baseAddress, hookData }; +}; + +/** + * @param {string} specimen + * @param {number} [charLimit] + * @returns {{ + * baseAddress: string; + * hookData: Uint8Array; + * }} + */ +export const splitHookedAddress = (specimen, charLimit) => { + const result = splitHookedAddressUnsafe(specimen, charLimit); + if (typeof result === 'object') { + return result; + } + throw Error(result); +}; diff --git a/packages/cosmic-proto/test/address-hooks.test.js b/packages/cosmic-proto/test/address-hooks.test.js new file mode 100644 index 00000000000..31ce2334f10 --- /dev/null +++ b/packages/cosmic-proto/test/address-hooks.test.js @@ -0,0 +1,242 @@ +import rawTest from '@endo/ses-ava/prepare-endo.js'; + +import bundleSourceAmbient from '@endo/bundle-source'; +import { importBundle } from '@endo/import-bundle'; + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +/** + * @type {import('ava').TestFn<{ + * addressHooks: import('../src/address-hooks.js'); + * }>} + */ +const test = rawTest; + +const makeTestContext = async () => { + // Do all this work so that we test bundling and evaluation of the module in a + // fresh compartment. + const bundleSource = bundleSourceAmbient; + const loadBundle = async specifier => { + const modulePath = require.resolve(specifier); + const bundle = await bundleSource(modulePath); + return bundle; + }; + + const evaluateBundle = async (bundle, endowments = {}) => { + return importBundle(bundle, endowments); + }; + + const importSpecifier = async (specifier, endowments = {}) => { + const bundle = await loadBundle(specifier); + return 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, string], + * { addressHooks: import('../src/address-hooks.js') } + * >} + */ +const roundtripMacro = test.macro({ + title(providedTitle = '', prefix, addrBytes, hookData) { + const space = providedTitle.endsWith(' ') ? '' : ' '; + 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 = joinHookedAddress(baseAddress, hookData); + t.deepEqual(encoded, expected); + const decoded = splitHookedAddress(encoded); + t.deepEqual(decoded, { + baseAddress, + hookData: new Uint8Array(hookData || []), + }); + }, +}); + +test( + 'roundtripEmpty', + roundtripMacro, + 'agoric', + [], + [], + 'agoric10rchqqqq8gt2j4', +); + +test( + 'roundtripEmptyHookData', + roundtripMacro, + 'agoric', + [0x01, 0x02, 0x03], + [], + 'agoric10rchqqgzqvqqxcc0kwx', +); + +test( + 'roundtripEmptyBaseAddress', + roundtripMacro, + 'agoric', + [], + [0x01, 0x02, 0x03], + 'agoric10rchqqgzqvqqqyhvnj3', +); + +test( + 'roundtrip', + roundtripMacro, + 'agoric', + [0x01, 0x02, 0x03], + [0x04, 0x05, 0x06], + 'agoric10rchqqgzqvzq2psqqv59f9cy', +); + +/** + * @type {import('ava').Macro< + * [ + * string, + * ArrayLike, + * ArrayLike, + * number | undefined, + * { message } | undefined, + * ] + * >} + */ +const lengthCheckMacro = test.macro({ + title(providedTitle = '', prefix, baseAddress, hookData, charLimit, throws) { + let sep = providedTitle.endsWith(' ') ? '' : ' '; + const limitDesc = charLimit ? `${sep}charLimit=${charLimit}` : ''; + if (limitDesc) sep = ' '; + const throwsDesc = throws ? `${sep}throws` : ''; + if (throwsDesc) sep = ' '; + 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 = () => joinHookedAddress(baseAddress, hookData, charLimit); + if (throws) { + t.throws(make, throws); + return; + } + const encoded = make(); + t.log('encoded', encoded, addrBytes); + const decoded = splitHookedAddress(encoded, charLimit); + t.deepEqual(decoded, { + baseAddress, + hookData, + }); + }, +}); + +{ + const charLimit = 90; + const prefix = 'agoric'; + const addrBytes = new Uint8Array(20); + for (let j = 0; j < addrBytes.length; j += 1) { + addrBytes[j] = j; + } + + for (let i = 0; i <= 30; i += 1) { + const hookData = new Uint8Array(i); + for (let j = 0; j < i; j += 1) { + hookData[j] = i - j; + } + test( + `${addrBytes.length}-byte baseAddress, ${i}-byte hookData`, + lengthCheckMacro, + prefix, + addrBytes, + hookData, + charLimit, + i > 23 ? { message: /Exceeds length limit/ } : undefined, + ); + } +} + +/** + * @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 } = 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'] }, + 'agoric10rchqqplvy7kyfnr84jzvceavsezveqqqyu2w5lp', +); + +test( + 'cosmos hook', + addressHookMacro, + 'cosmos1qqxuevtt', + { + everything: null, + dst: ['a', 'b', 'c'], + }, + 'cosmos10rchqqplv3ehg0tpyej8xapavgnxgum5843jvetkv4e8jargd9hxwqqp4vx73n', +); + +test( + 'slideshow hook', + addressHookMacro, + 'agoric1qqp0e5ys', + { + stake: 'TIA', + strat: 'compound', + holder: 'agoric1adjbkubiukd', + }, + 'agoric10rchqqpldphkcer9wg7kzem0wf5kxvtpv34xy6m4vf5h26myyeehgcttv574gj2pyeehgunpws7kxmmdwphh2mnyqqqsc2lz8v', +); + +test( + 'Fast USDC hook', + addressHookMacro, + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', + { + EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + 'agoric10rchp4vc53apxn32q42c3zryml8xq3xshyzuhjk6405wtxy7tl3d7e0f8az423padaek6me38qekget2vdhx66mtvy6kg7nrw5uhsaekd4uhwufswqex6dtsv44hxv3cd4jkuqpqvduyhf', +); diff --git a/packages/cosmic-proto/test/vatsafe.test.js b/packages/cosmic-proto/test/vatsafe.test.js index a184176e27d..5d5de4ee531 100644 --- a/packages/cosmic-proto/test/vatsafe.test.js +++ b/packages/cosmic-proto/test/vatsafe.test.js @@ -1,5 +1,4 @@ // @ts-check -/* eslint-disable import/no-extraneous-dependencies -- requiring the package itself to check exports map */ import test from '@endo/ses-ava/prepare-endo.js'; import { MsgDelegate } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; From 5014cf9ad09b25db12db284d969312edb8f17adf Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 5 Dec 2024 18:17:09 -0600 Subject: [PATCH 2/4] build(deps): regenerate `yarn.lock`s --- .../proposals/s:stake-bld/yarn.lock | 41 +++++++++++++++++++ .../proposals/z:acceptance/yarn.lock | 41 +++++++++++++++++++ yarn.lock | 24 +++++++++++ 3 files changed, 106 insertions(+) diff --git a/a3p-integration/proposals/s:stake-bld/yarn.lock b/a3p-integration/proposals/s:stake-bld/yarn.lock index 0d13fa55ab9..a406cffd13a 100644 --- a/a3p-integration/proposals/s:stake-bld/yarn.lock +++ b/a3p-integration/proposals/s:stake-bld/yarn.lock @@ -99,6 +99,8 @@ __metadata: dependencies: "@endo/base64": "npm:^1.0.9" "@endo/init": "npm:^1.1.7" + bech32: "npm:^2.0.0" + query-string: "npm:^9.1.1" languageName: node linkType: soft @@ -1835,6 +1837,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: 10c0/45e7cc62758c9b26c05161b4483f40ea534437cf68ef785abadc5b62a2611319b878fef4f86ddc14854f183b645917a19addebc9573ab890e19194bc8f521942 + languageName: node + linkType: hard + "better-sqlite3@npm:^9.1.1": version: 9.4.3 resolution: "better-sqlite3@npm:9.4.3" @@ -2362,6 +2371,13 @@ __metadata: languageName: node linkType: hard +"decode-uri-component@npm:^0.4.1": + version: 0.4.1 + resolution: "decode-uri-component@npm:0.4.1" + checksum: 10c0/a180bbdb5398ec8270d236a3ac07cb988bbf6097428481780b85840f088951dc0318a8d8f9d56796e1a322b55b29859cea29982f22f9b03af0bc60974c54e591 + languageName: node + linkType: hard + "decompress-response@npm:^6.0.0": version: 6.0.0 resolution: "decompress-response@npm:6.0.0" @@ -2777,6 +2793,13 @@ __metadata: languageName: node linkType: hard +"filter-obj@npm:^5.1.0": + version: 5.1.0 + resolution: "filter-obj@npm:5.1.0" + checksum: 10c0/716e8ad2bc352e206556b3e5695b3cdff8aab80c53ea4b00c96315bbf467b987df3640575100aef8b84e812cf5ea4251db4cd672bbe33b1e78afea88400c67dd + languageName: node + linkType: hard + "find-up@npm:^6.0.0": version: 6.3.0 resolution: "find-up@npm:6.3.0" @@ -4425,6 +4448,17 @@ __metadata: languageName: node linkType: hard +"query-string@npm:^9.1.1": + version: 9.1.1 + resolution: "query-string@npm:9.1.1" + dependencies: + decode-uri-component: "npm:^0.4.1" + filter-obj: "npm:^5.1.0" + split-on-first: "npm:^3.0.0" + checksum: 10c0/16481f17754f660aec3cae7abb838a70e383dfcf152414d184e0d0f81fae426acf112b4d51bf754f9c256eaf83ba4241241ba907c8d58b6ed9704425e1712e8c + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -4818,6 +4852,13 @@ __metadata: languageName: node linkType: hard +"split-on-first@npm:^3.0.0": + version: 3.0.0 + resolution: "split-on-first@npm:3.0.0" + checksum: 10c0/a1262eae12b68de235e1a08e011bf5b42c42621985ddf807e6221fb1e2b3304824913ae7019f18436b96b8fab8aef5f1ad80dedd2385317fdc51b521c3882cd0 + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" diff --git a/a3p-integration/proposals/z:acceptance/yarn.lock b/a3p-integration/proposals/z:acceptance/yarn.lock index c4f420e2cde..a3921c6567d 100644 --- a/a3p-integration/proposals/z:acceptance/yarn.lock +++ b/a3p-integration/proposals/z:acceptance/yarn.lock @@ -99,6 +99,8 @@ __metadata: dependencies: "@endo/base64": "npm:^1.0.9" "@endo/init": "npm:^1.1.7" + bech32: "npm:^2.0.0" + query-string: "npm:^9.1.1" languageName: node linkType: soft @@ -1989,6 +1991,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: 10c0/45e7cc62758c9b26c05161b4483f40ea534437cf68ef785abadc5b62a2611319b878fef4f86ddc14854f183b645917a19addebc9573ab890e19194bc8f521942 + languageName: node + linkType: hard + "better-sqlite3@npm:^9.1.1, better-sqlite3@npm:^9.6.0": version: 9.6.0 resolution: "better-sqlite3@npm:9.6.0" @@ -2543,6 +2552,13 @@ __metadata: languageName: node linkType: hard +"decode-uri-component@npm:^0.4.1": + version: 0.4.1 + resolution: "decode-uri-component@npm:0.4.1" + checksum: 10c0/a180bbdb5398ec8270d236a3ac07cb988bbf6097428481780b85840f088951dc0318a8d8f9d56796e1a322b55b29859cea29982f22f9b03af0bc60974c54e591 + languageName: node + linkType: hard + "decompress-response@npm:^6.0.0": version: 6.0.0 resolution: "decompress-response@npm:6.0.0" @@ -3204,6 +3220,13 @@ __metadata: languageName: node linkType: hard +"filter-obj@npm:^5.1.0": + version: 5.1.0 + resolution: "filter-obj@npm:5.1.0" + checksum: 10c0/716e8ad2bc352e206556b3e5695b3cdff8aab80c53ea4b00c96315bbf467b987df3640575100aef8b84e812cf5ea4251db4cd672bbe33b1e78afea88400c67dd + languageName: node + linkType: hard + "find-up-simple@npm:^1.0.0": version: 1.0.0 resolution: "find-up-simple@npm:1.0.0" @@ -5357,6 +5380,17 @@ __metadata: languageName: node linkType: hard +"query-string@npm:^9.1.1": + version: 9.1.1 + resolution: "query-string@npm:9.1.1" + dependencies: + decode-uri-component: "npm:^0.4.1" + filter-obj: "npm:^5.1.0" + split-on-first: "npm:^3.0.0" + checksum: 10c0/16481f17754f660aec3cae7abb838a70e383dfcf152414d184e0d0f81fae426acf112b4d51bf754f9c256eaf83ba4241241ba907c8d58b6ed9704425e1712e8c + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -5905,6 +5939,13 @@ __metadata: languageName: node linkType: hard +"split-on-first@npm:^3.0.0": + version: 3.0.0 + resolution: "split-on-first@npm:3.0.0" + checksum: 10c0/a1262eae12b68de235e1a08e011bf5b42c42621985ddf807e6221fb1e2b3304824913ae7019f18436b96b8fab8aef5f1ad80dedd2385317fdc51b521c3882cd0 + languageName: node + linkType: hard + "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" diff --git a/yarn.lock b/yarn.lock index d7e8659d34c..366e1bc92ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5595,6 +5595,11 @@ decode-named-character-reference@^1.0.0: dependencies: character-entities "^2.0.0" +decode-uri-component@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.4.1.tgz#2ac4859663c704be22bf7db760a1494a49ab2cc5" + integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ== + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -6766,6 +6771,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +filter-obj@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-5.1.0.tgz#5bd89676000a713d7db2e197f660274428e524ed" + integrity sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng== + finalhandler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" @@ -10708,6 +10718,15 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" +query-string@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.1.tgz#dbfebb4196aeb2919915f2b2b81b91b965cf03a0" + integrity sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg== + dependencies: + decode-uri-component "^0.4.1" + filter-obj "^5.1.0" + split-on-first "^3.0.0" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -11537,6 +11556,11 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz#887da8aa73218e51a1d917502d79863161a93f9c" integrity sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg== +split-on-first@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-3.0.0.tgz#f04959c9ea8101b9b0bbf35a61b9ebea784a23e7" + integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== + split2@^3.0.0: version "3.2.2" resolved "https://registry.yarnpkg.com/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" From 159098bbfaddf4448da4778aa29fbc072aed80a9 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sat, 7 Dec 2024 18:44:45 -0600 Subject: [PATCH 3/4] feat(vtransfer): port some `address-hooks.js` functions to Go --- .../baseaddr.go => types/address_hooks.go} | 105 +++++++++++++----- .../address_hooks_test.go} | 96 ++++++++-------- .../cosmos/x/vtransfer/ibc_middleware_test.go | 5 +- golang/cosmos/x/vtransfer/keeper/keeper.go | 3 +- 4 files changed, 133 insertions(+), 76 deletions(-) rename golang/cosmos/{x/vtransfer/types/baseaddr.go => types/address_hooks.go} (56%) rename golang/cosmos/{x/vtransfer/types/baseaddr_test.go => types/address_hooks_test.go} (62%) diff --git a/golang/cosmos/x/vtransfer/types/baseaddr.go b/golang/cosmos/types/address_hooks.go similarity index 56% rename from golang/cosmos/x/vtransfer/types/baseaddr.go rename to golang/cosmos/types/address_hooks.go index 7ad65541746..b26fe1e85f4 100644 --- a/golang/cosmos/x/vtransfer/types/baseaddr.go +++ b/golang/cosmos/types/address_hooks.go @@ -1,11 +1,11 @@ package types import ( + "bytes" "fmt" - "net/url" - "strings" "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/types/bech32" transfertypes "github.com/cosmos/ibc-go/v6/modules/apps/transfer/types" clienttypes "github.com/cosmos/ibc-go/v6/modules/core/02-client/types" @@ -18,48 +18,97 @@ type AddressRole string const ( RoleSender AddressRole = "Sender" RoleReceiver AddressRole = "Receiver" + + AddressHookVersion = 0 + BaseAddressLengthBytes = 2 ) -func trimSlashPrefix(s string) string { - return strings.TrimPrefix(s, "/") +// AddressHookMagic is a magic byte prefix that identifies a hooked address. +// Chosen to make bech32 address hooks that look like "agoric10rch..." +var AddressHookMagic = []byte{0x78, 0xf1, 0x70 | AddressHookVersion} + +func init() { + if AddressHookVersion&0x0f != AddressHookVersion { + panic(fmt.Sprintf("AddressHookVersion must be less than 0x10, got 0x%x", AddressHookVersion)) + } } -// ExtractBaseAddress extracts the base address from a parameterized address. -// It removes all subpath and query components from addr. +// ExtractBaseAddress extracts the base address from an Address Hook. It +// returns addr verbatim if it is not an Address Hook. func ExtractBaseAddress(addr string) (string, error) { - parsed, err := url.Parse(addr) + baseAddr, _, err := SplitHookedAddress(addr) if err != nil { return "", err } + return baseAddr, nil +} + +// SplitHookedAddress splits a hooked address into its base address and hook data. +// For the JS implementation, look at @agoric/cosmic-proto/src/address-hooks.js. +func SplitHookedAddress(addr string) (string, []byte, error) { + prefix, payload, err := bech32.DecodeAndConvert(addr) + if err != nil { + return "", []byte{}, err + } - // Specify the fields and values we expect. Unspecified fields will only - // match if they are zero values in order to be robust against extensions to - // the url.URL struct. - // - // Remove leading slashes from the path fields so that only parsed relative - // paths match the expected test. - expected := url.URL{ - Path: trimSlashPrefix(parsed.Path), - RawPath: trimSlashPrefix(parsed.RawPath), - RawQuery: parsed.RawQuery, - Fragment: parsed.Fragment, - RawFragment: parsed.RawFragment, + bz := bytes.TrimPrefix(payload, AddressHookMagic) + if len(bz) == len(payload) { + // Return an unhooked address. + return addr, []byte{}, nil + } - // Skip over parsing control flags. - ForceQuery: parsed.ForceQuery, - OmitHost: parsed.OmitHost, + if len(bz) < BaseAddressLengthBytes { + return "", []byte{}, fmt.Errorf("hooked address must have at least %d bytes", BaseAddressLengthBytes) } - if *parsed != expected { - return "", fmt.Errorf("address must be relative path with optional query and fragment, got %s", addr) + b := 0 + for i := BaseAddressLengthBytes - 1; i >= 0; i -= 1 { + byteVal := bz[len(bz)-1-i] + b <<= 8 + b |= int(byteVal) } - baseAddr, _, _ := strings.Cut(expected.Path, "/") - if baseAddr == "" { - return "", fmt.Errorf("base address cannot be empty") + payloadEnd := len(bz) - BaseAddressLengthBytes + if b > payloadEnd { + return "", []byte{}, fmt.Errorf("base address length 0x%x is longer than payload end 0x%x", b, payloadEnd) } - return baseAddr, nil + baseAddressBuf := bz[0:b] + baseAddress, err := bech32.ConvertAndEncode(prefix, baseAddressBuf) + if err != nil { + return "", []byte{}, err + } + + return baseAddress, bz[b:payloadEnd], nil +} + +// JoinHookedAddress joins a base bech32 address with hook data to create a +// hooked bech32 address. +// For the JS implementation, look at @agoric/cosmic-proto/src/address-hooks.js +func JoinHookedAddress(baseAddr string, hookData []byte) (string, error) { + prefix, bz, err := bech32.DecodeAndConvert(baseAddr) + if err != nil { + return "", err + } + + b := len(bz) + maxB := 1<<(8*BaseAddressLengthBytes-1) + 1 + if b > maxB { + return "", fmt.Errorf("base address length 0x%x is longer than the maximum 0x%x", b, maxB) + } + + payload := make([]byte, 0, len(AddressHookMagic)+b+len(hookData)+BaseAddressLengthBytes) + payload = append(payload, AddressHookMagic...) + payload = append(payload, bz...) + payload = append(payload, hookData...) + baLen := make([]byte, BaseAddressLengthBytes) + for i := BaseAddressLengthBytes - 1; i >= 0; i -= 1 { + baLen[i] = byte(b) + b >>= 8 + } + payload = append(payload, baLen...) + + return bech32.ConvertAndEncode(prefix, payload) } // extractBaseTransferData returns the base address from the transferData.Sender diff --git a/golang/cosmos/x/vtransfer/types/baseaddr_test.go b/golang/cosmos/types/address_hooks_test.go similarity index 62% rename from golang/cosmos/x/vtransfer/types/baseaddr_test.go rename to golang/cosmos/types/address_hooks_test.go index eb83f07ddb6..8ae9faad1e7 100644 --- a/golang/cosmos/x/vtransfer/types/baseaddr_test.go +++ b/golang/cosmos/types/address_hooks_test.go @@ -12,7 +12,7 @@ import ( clienttypes "github.com/cosmos/ibc-go/v6/modules/core/02-client/types" channeltypes "github.com/cosmos/ibc-go/v6/modules/core/04-channel/types" - "github.com/Agoric/agoric-sdk/golang/cosmos/x/vtransfer/types" + "github.com/Agoric/agoric-sdk/golang/cosmos/types" ) func TestExtractBaseAddress(t *testing.T) { @@ -20,28 +20,12 @@ func TestExtractBaseAddress(t *testing.T) { name string addr string }{ - {"agoric address", "agoric1abcdefghiteaneas"}, - {"cosmos address", "cosmos1abcdeffiharceuht"}, - {"hex address", "0xabcdef198189818c93839ibia"}, - } - - prefixes := []struct { - prefix string - baseIsWrong bool - isErr bool - }{ - {"", false, false}, - {"/", false, true}, - {"orch:/", false, true}, - {"unexpected", true, false}, - {"norch:/", false, true}, - {"orch:", false, true}, - {"norch:", false, true}, - {"\x01", false, true}, + {"agoric address", "agoric1qqp0e5ys"}, + {"cosmos address", "cosmos1qqxuevtt"}, } suffixes := []struct { - suffix string + hookStr string baseIsWrong bool isErr bool }{ @@ -50,31 +34,33 @@ func TestExtractBaseAddress(t *testing.T) { {"/sub/account", false, false}, {"?query=something&k=v&k2=v2", false, false}, {"?query=something&k=v&k2=v2#fragment", false, false}, - {"unexpected", true, false}, - {"\x01", false, true}, + {"unexpected", false, false}, + {"\x01", false, false}, } for _, b := range bases { b := b - for _, p := range prefixes { - p := p - for _, s := range suffixes { - s := s - t.Run(b.name+" "+p.prefix+" "+s.suffix, func(t *testing.T) { - addr := p.prefix + b.addr + s.suffix - addr, err := types.ExtractBaseAddress(addr) - if p.isErr || s.isErr { - require.Error(t, err) + for _, s := range suffixes { + s := s + t.Run(b.name+" "+s.hookStr, func(t *testing.T) { + addrHook, err := types.JoinHookedAddress(b.addr, []byte(s.hookStr)) + require.NoError(t, err) + addr, err := types.ExtractBaseAddress(addrHook) + if s.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + if s.baseIsWrong { + require.NotEqual(t, b.addr, addr) } else { + require.Equal(t, b.addr, addr) + addr, hookData, err := types.SplitHookedAddress(addrHook) require.NoError(t, err) - if p.baseIsWrong || s.baseIsWrong { - require.NotEqual(t, b.addr, addr) - } else { - require.Equal(t, b.addr, addr) - } + require.Equal(t, b.addr, addr) + require.Equal(t, s.hookStr, string(hookData)) } - }) - } + } + }) } } } @@ -86,32 +72,50 @@ func TestExtractBaseAddressFromPacket(t *testing.T) { channeltypes.RegisterInterfaces(ir) clienttypes.RegisterInterfaces(ir) + cosmosAddr := "cosmos1qqxuevtt" + cosmosHookStr := "?foo=bar&baz=bot#fragment" + cosmosHook, err := types.JoinHookedAddress(cosmosAddr, []byte(cosmosHookStr)) + require.NoError(t, err) + addr, hookData, err := types.SplitHookedAddress(cosmosHook) + require.NoError(t, err) + require.Equal(t, cosmosAddr, addr) + require.Equal(t, cosmosHookStr, string(hookData)) + + agoricAddr := "agoric1qqp0e5ys" + agoricHookStr := "?bingo=again" + agoricHook, err := types.JoinHookedAddress(agoricAddr, []byte(agoricHookStr)) + require.NoError(t, err) + addr, hookData, err = types.SplitHookedAddress(agoricHook) + require.NoError(t, err) + require.Equal(t, agoricAddr, addr) + require.Equal(t, agoricHookStr, string(hookData)) + cases := []struct { name string addrs map[types.AddressRole]struct{ addr, baseAddr string } }{ {"sender has params", map[types.AddressRole]struct{ addr, baseAddr string }{ - types.RoleSender: {"cosmos1abcdeffiharceuht?foo=bar&baz=bot#fragment", "cosmos1abcdeffiharceuht"}, - types.RoleReceiver: {"agoric1abcdefghiteaneas", "agoric1abcdefghiteaneas"}, + types.RoleSender: {cosmosHook, "cosmos1qqxuevtt"}, + types.RoleReceiver: {"agoric1qqp0e5ys", "agoric1qqp0e5ys"}, }, }, {"receiver has params", map[types.AddressRole]struct{ addr, baseAddr string }{ - types.RoleSender: {"cosmos1abcdeffiharceuht", "cosmos1abcdeffiharceuht"}, - types.RoleReceiver: {"agoric1abcdefghiteaneas?bingo=again", "agoric1abcdefghiteaneas"}, + types.RoleSender: {"cosmos1qqxuevtt", "cosmos1qqxuevtt"}, + types.RoleReceiver: {agoricHook, "agoric1qqp0e5ys"}, }, }, {"both are base", map[types.AddressRole]struct{ addr, baseAddr string }{ - types.RoleSender: {"cosmos1abcdeffiharceuht", "cosmos1abcdeffiharceuht"}, - types.RoleReceiver: {"agoric1abcdefghiteaneas", "agoric1abcdefghiteaneas"}, + types.RoleSender: {"cosmos1qqxuevtt", "cosmos1qqxuevtt"}, + types.RoleReceiver: {"agoric1qqp0e5ys", "agoric1qqp0e5ys"}, }, }, {"both have params", map[types.AddressRole]struct{ addr, baseAddr string }{ - types.RoleSender: {"agoric1abcdefghiteaneas?bingo=again", "agoric1abcdefghiteaneas"}, - types.RoleReceiver: {"cosmos1abcdeffiharceuht?foo=bar&baz=bot#fragment", "cosmos1abcdeffiharceuht"}, + types.RoleSender: {agoricHook, "agoric1qqp0e5ys"}, + types.RoleReceiver: {cosmosHook, "cosmos1qqxuevtt"}, }, }, } diff --git a/golang/cosmos/x/vtransfer/ibc_middleware_test.go b/golang/cosmos/x/vtransfer/ibc_middleware_test.go index 23279bf8a63..ee9c80ebb56 100644 --- a/golang/cosmos/x/vtransfer/ibc_middleware_test.go +++ b/golang/cosmos/x/vtransfer/ibc_middleware_test.go @@ -16,6 +16,7 @@ import ( "github.com/tendermint/tendermint/libs/log" dbm "github.com/tendermint/tm-db" + "github.com/Agoric/agoric-sdk/golang/cosmos/types" swingsettesting "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset/testing" swingsettypes "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset/types" vibckeeper "github.com/Agoric/agoric-sdk/golang/cosmos/x/vibc/keeper" @@ -332,11 +333,13 @@ func (s *IntegrationTestSuite) TestTransferFromAgdToAgd() { s.Run("TransferFromAgdToAgd", func() { // create a transfer packet's data contents baseReceiver := s.chainB.SenderAccounts[1].SenderAccount.GetAddress().String() + receiverHook, err := types.JoinHookedAddress(baseReceiver, []byte("?what=arbitrary-data&why=to-test-bridge-targets")) + s.Require().NoError(err) transferData := ibctransfertypes.NewFungibleTokenPacketData( "uosmo", "1000000", s.chainA.SenderAccount.GetAddress().String(), - baseReceiver+"?what=arbitrary-data&why=to-test-bridge-targets", + receiverHook, `"This is a JSON memo"`, ) diff --git a/golang/cosmos/x/vtransfer/keeper/keeper.go b/golang/cosmos/x/vtransfer/keeper/keeper.go index 0d99840c061..ed5b0f57cba 100644 --- a/golang/cosmos/x/vtransfer/keeper/keeper.go +++ b/golang/cosmos/x/vtransfer/keeper/keeper.go @@ -13,10 +13,11 @@ import ( capabilitykeeper "github.com/cosmos/cosmos-sdk/x/capability/keeper" capabilitytypes "github.com/cosmos/cosmos-sdk/x/capability/types" + "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/vm" "github.com/Agoric/agoric-sdk/golang/cosmos/x/vibc" vibctypes "github.com/Agoric/agoric-sdk/golang/cosmos/x/vibc/types" - "github.com/Agoric/agoric-sdk/golang/cosmos/x/vtransfer/types" + channeltypes "github.com/cosmos/ibc-go/v6/modules/core/04-channel/types" porttypes "github.com/cosmos/ibc-go/v6/modules/core/05-port/types" host "github.com/cosmos/ibc-go/v6/modules/core/24-host" From c8ad417b75e7d26178369800383013609c8b1483 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Tue, 10 Dec 2024 15:54:57 -0600 Subject: [PATCH 4/4] docs(cosmic-proto): generate initial typedoc --- packages/cosmic-proto/src/address-hooks.js | 9 +++++---- packages/cosmic-proto/typedoc.json | 9 +++++++++ typedoc.json | 1 - 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 packages/cosmic-proto/typedoc.json diff --git a/packages/cosmic-proto/src/address-hooks.js b/packages/cosmic-proto/src/address-hooks.js index b51fc4e4751..073f0783c71 100644 --- a/packages/cosmic-proto/src/address-hooks.js +++ b/packages/cosmic-proto/src/address-hooks.js @@ -1,8 +1,9 @@ /** - * @module address-hooks - * @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. + * 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. + * + * @module address-hooks.js * @example * * import { diff --git a/packages/cosmic-proto/typedoc.json b/packages/cosmic-proto/typedoc.json new file mode 100644 index 00000000000..0193d155bf9 --- /dev/null +++ b/packages/cosmic-proto/typedoc.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "../../typedoc.base.json" + ], + "entryPoints": [ + "src/*.js", + "src/helpers.ts" + ] +} diff --git a/typedoc.json b/typedoc.json index 90667d1f50f..3b8979ae26a 100644 --- a/typedoc.json +++ b/typedoc.json @@ -3,7 +3,6 @@ "packages/*" ], "exclude": [ - "packages/cosmic-proto/" ], "name": "API documentation of Agoric SDK ", "entryPointStrategy": "packages",