From 69de68a66e255677f680df53c0f9f86a52d0ef88 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 10 May 2024 13:03:32 -0400 Subject: [PATCH] Multisig and Aggregation ISM Metadata Encoding (#3701) ### Description - Implement multisig metadata encoding/decoding - Implement aggregation metadata encoding/decoding - Generates test metadata fixtures from solidity unit tests - Test encoders using fixtures ### Drive By - Make CI tests run topologically ### Related issues - Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3449 - Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3451 ### Backward compatibility Yes ### Testing Unit Tests --- .changeset/green-ads-live.md | 2 +- .changeset/sour-bats-sort.md | 6 + package.json | 2 +- solidity/.gitignore | 1 + solidity/README.md | 20 ++- solidity/foundry.toml | 5 +- solidity/package.json | 4 +- solidity/test/isms/AggregationIsm.t.sol | 50 ++++-- solidity/test/isms/MultisigIsm.t.sol | 151 ++++++++++++++--- .../sdk/src/ism/metadata/aggregation.test.ts | 41 +++++ .../sdk/src/ism/metadata/aggregation.ts | 60 +++++++ .../sdk/src/ism/metadata/multisig.test.ts | 67 ++++++++ typescript/sdk/src/ism/metadata/multisig.ts | 156 ++++++++++++++++++ typescript/sdk/src/ism/metadata/types.test.ts | 4 + typescript/utils/package.json | 1 + typescript/utils/src/index.ts | 2 + typescript/utils/src/logging.ts | 1 + typescript/utils/src/strings.ts | 7 + 18 files changed, 539 insertions(+), 41 deletions(-) create mode 100644 .changeset/sour-bats-sort.md create mode 100644 typescript/sdk/src/ism/metadata/aggregation.test.ts create mode 100644 typescript/sdk/src/ism/metadata/aggregation.ts create mode 100644 typescript/sdk/src/ism/metadata/multisig.test.ts create mode 100644 typescript/sdk/src/ism/metadata/multisig.ts create mode 100644 typescript/sdk/src/ism/metadata/types.test.ts diff --git a/.changeset/green-ads-live.md b/.changeset/green-ads-live.md index ab79ea16ea..f847f584b6 100644 --- a/.changeset/green-ads-live.md +++ b/.changeset/green-ads-live.md @@ -1,5 +1,5 @@ --- -"@hyperlane-xyz/cli": minor +'@hyperlane-xyz/cli': minor --- Default to home directory for local registry diff --git a/.changeset/sour-bats-sort.md b/.changeset/sour-bats-sort.md new file mode 100644 index 0000000000..37d54bb108 --- /dev/null +++ b/.changeset/sour-bats-sort.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/utils': minor +'@hyperlane-xyz/sdk': minor +--- + +Implement aggregation and multisig ISM metadata encoding diff --git a/package.json b/package.json index 344b1f87d1..3da986a519 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prettier": "yarn workspaces foreach --since --parallel run prettier", "lint": "yarn workspaces foreach --all --parallel run lint", "test": "yarn workspaces foreach --all --parallel run test", - "test:ci": "yarn workspaces foreach --all --parallel run test:ci", + "test:ci": "yarn workspaces foreach --all --topological run test:ci", "coverage": "yarn workspaces foreach --all --parallel run coverage", "version:prepare": "yarn changeset version && yarn workspaces foreach --all --parallel run version:update && yarn install --no-immutable", "version:check": "yarn changeset status", diff --git a/solidity/.gitignore b/solidity/.gitignore index c3e1139887..6f01b8e770 100644 --- a/solidity/.gitignore +++ b/solidity/.gitignore @@ -14,3 +14,4 @@ forge-cache docs flattened/ buildArtifact.json +fixtures/ diff --git a/solidity/README.md b/solidity/README.md index f6af38b8ef..d800b4a1c4 100644 --- a/solidity/README.md +++ b/solidity/README.md @@ -6,14 +6,30 @@ Hyperlane Core contains the contracts and typechain artifacts for the Hyperlane ```bash # Install with NPM -npm install @hyperlane-xyz/utils +npm install @hyperlane-xyz/core # Or with Yarn -yarn add @hyperlane-xyz/utils +yarn add @hyperlane-xyz/core ``` Note, this package uses [ESM Modules](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#pure-esm-package) +## Build + +```bash +yarn build +``` + +## Test + +```bash +yarn test +``` + +### Fixtures + +Some forge tests may generate fixtures in the [fixtures](./fixtures/) directory. This allows [SDK](../typescript/sdk) tests to leverage forge fuzzing. These are git ignored and should not be committed. + ## License Apache 2.0 diff --git a/solidity/foundry.toml b/solidity/foundry.toml index a840dd6e8e..8180d9b58f 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -10,7 +10,10 @@ solc_version = '0.8.22' evm_version= 'paris' optimizer = true optimizer_runs = 999_999 -fs_permissions = [{ access = "read-write", path = "./"}] +fs_permissions = [ + { access = "read", path = "./script/avs/"}, + { access = "write", path = "./fixtures" } +] ignored_warnings_from = ['fx-portal'] [profile.ci] diff --git a/solidity/package.json b/solidity/package.json index 72f712cf26..96077c4339 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -61,14 +61,14 @@ "scripts": { "build": "yarn hardhat-esm compile && tsc && ./exportBuildArtifact.sh", "lint": "solhint contracts/**/*.sol", - "clean": "yarn hardhat-esm clean && rm -rf ./dist ./cache ./types ./coverage ./out ./forge-cache", + "clean": "yarn hardhat-esm clean && rm -rf ./dist ./cache ./types ./coverage ./out ./forge-cache ./fixtures", "coverage": "./coverage.sh", "docs": "forge doc", "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config hardhat.config.cts", "prettier": "prettier --write ./contracts ./test", "test": "yarn hardhat-esm test && yarn test:forge", "test:hardhat": "yarn hardhat-esm test", - "test:forge": "forge test -vvv", + "test:forge": "mkdir -p ./fixtures/aggregation ./fixtures/multisig && forge test -vvv", "test:ci": "yarn test:hardhat && yarn test:forge --no-match-test testFork", "gas": "forge snapshot", "gas-ci": "yarn gas --check --tolerance 2 || (echo 'Manually update gas snapshot' && exit 1)", diff --git a/solidity/test/isms/AggregationIsm.t.sol b/solidity/test/isms/AggregationIsm.t.sol index 889789be0d..8806d9b66c 100644 --- a/solidity/test/isms/AggregationIsm.t.sol +++ b/solidity/test/isms/AggregationIsm.t.sol @@ -3,12 +3,19 @@ pragma solidity ^0.8.13; import "forge-std/Test.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + import {IAggregationIsm} from "../../contracts/interfaces/isms/IAggregationIsm.sol"; import {StaticAggregationIsmFactory} from "../../contracts/isms/aggregation/StaticAggregationIsmFactory.sol"; import {AggregationIsmMetadata} from "../../contracts/isms/libs/AggregationIsmMetadata.sol"; import {TestIsm, ThresholdTestUtils} from "./IsmTestUtils.sol"; contract AggregationIsmTest is Test { + using Strings for uint256; + using Strings for uint8; + + string constant fixtureKey = "fixture"; + StaticAggregationIsmFactory factory; IAggregationIsm ism; @@ -16,6 +23,24 @@ contract AggregationIsmTest is Test { factory = new StaticAggregationIsmFactory(); } + function fixtureAppendMetadata( + uint256 index, + bytes memory metadata + ) internal { + vm.serializeBytes(fixtureKey, index.toString(), metadata); + } + + function fixtureAppendNull(uint256 index) internal { + vm.serializeString(fixtureKey, index.toString(), "null"); + } + + function writeFixture(bytes memory metadata, uint8 m) internal { + string memory path = string( + abi.encodePacked("./fixtures/aggregation/", m.toString(), ".json") + ); + vm.writeJson(vm.serializeBytes(fixtureKey, "encoded", metadata), path); + } + function deployIsms( uint8 m, uint8 n, @@ -32,34 +57,37 @@ contract AggregationIsmTest is Test { return isms; } - function getMetadata( - uint8 m, - bytes32 seed - ) private view returns (bytes memory) { + function getMetadata(uint8 m, bytes32 seed) private returns (bytes memory) { (address[] memory choices, ) = ism.modulesAndThreshold(""); address[] memory chosen = ThresholdTestUtils.choose(m, choices, seed); bytes memory offsets; uint32 start = 8 * uint32(choices.length); bytes memory metametadata; + for (uint256 i = 0; i < choices.length; i++) { bool included = false; for (uint256 j = 0; j < chosen.length; j++) { included = included || choices[i] == chosen[j]; } if (included) { - bytes memory requiredMetadata = TestIsm(choices[i]) - .requiredMetadata(); - uint32 end = start + uint32(requiredMetadata.length); + bytes memory metadata = TestIsm(choices[i]).requiredMetadata(); + uint32 end = start + uint32(metadata.length); uint64 offset = (uint64(start) << 32) | uint64(end); offsets = bytes.concat(offsets, abi.encodePacked(offset)); start = end; - metametadata = abi.encodePacked(metametadata, requiredMetadata); + metametadata = abi.encodePacked(metametadata, metadata); + fixtureAppendMetadata(i, metadata); } else { - uint64 offset = 0; - offsets = bytes.concat(offsets, abi.encodePacked(offset)); + offsets = bytes.concat(offsets, abi.encodePacked(uint64(0))); + fixtureAppendNull(i); } } - return abi.encodePacked(offsets, metametadata); + + bytes memory encoded = abi.encodePacked(offsets, metametadata); + + writeFixture(encoded, m); + + return encoded; } function testVerify(uint8 m, uint8 n, bytes32 seed) public { diff --git a/solidity/test/isms/MultisigIsm.t.sol b/solidity/test/isms/MultisigIsm.t.sol index 5c34be5f3b..6cf8c7036e 100644 --- a/solidity/test/isms/MultisigIsm.t.sol +++ b/solidity/test/isms/MultisigIsm.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.13; import "forge-std/Test.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + import {IMultisigIsm} from "../../contracts/interfaces/isms/IMultisigIsm.sol"; import {TestMailbox} from "../../contracts/test/TestMailbox.sol"; import {StaticMerkleRootMultisigIsmFactory, StaticMessageIdMultisigIsmFactory} from "../../contracts/isms/multisig/StaticMultisigIsm.sol"; @@ -20,6 +22,13 @@ import {ThresholdTestUtils} from "./IsmTestUtils.sol"; abstract contract AbstractMultisigIsmTest is Test { using Message for bytes; using TypeCasts for address; + using Strings for uint256; + using Strings for uint8; + + string constant fixtureKey = "fixture"; + string constant signatureKey = "signature"; + string constant signaturesKey = "signatures"; + string constant prefixKey = "prefix"; uint32 constant ORIGIN = 11; StaticThresholdAddressSetFactory factory; @@ -30,7 +39,47 @@ abstract contract AbstractMultisigIsmTest is Test { function metadataPrefix( bytes memory message - ) internal view virtual returns (bytes memory); + ) internal virtual returns (bytes memory); + + function fixtureInit() internal { + vm.serializeUint(fixtureKey, "type", uint256(ism.moduleType())); + string memory prefix = vm.serializeString(prefixKey, "dummy", "dummy"); + vm.serializeString(fixtureKey, "prefix", prefix); + } + + function fixtureAppendSignature( + uint256 index, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + vm.serializeUint(signatureKey, "v", uint256(v)); + vm.serializeBytes32(signatureKey, "r", r); + string memory signature = vm.serializeBytes32(signatureKey, "s", s); + vm.serializeString(signaturesKey, index.toString(), signature); + } + + function writeFixture(bytes memory metadata, uint8 m, uint8 n) internal { + vm.serializeString( + fixtureKey, + "signatures", + vm.serializeString(signaturesKey, "dummy", "dummy") + ); + + string memory fixturePath = string( + abi.encodePacked( + "./fixtures/multisig/", + m.toString(), + "-", + n.toString(), + ".json" + ) + ); + vm.writeJson( + vm.serializeBytes(fixtureKey, "encoded", metadata), + fixturePath + ); + } function getMetadata( uint8 m, @@ -38,24 +87,40 @@ abstract contract AbstractMultisigIsmTest is Test { bytes32 seed, bytes memory message ) internal returns (bytes memory) { - uint32 domain = mailbox.localDomain(); - uint256[] memory keys = addValidators(m, n, seed); - uint256[] memory signers = ThresholdTestUtils.choose(m, keys, seed); + bytes32 digest; + { + uint32 domain = mailbox.localDomain(); + (bytes32 root, uint32 index) = merkleTreeHook.latestCheckpoint(); + bytes32 messageId = message.id(); + bytes32 merkleTreeAddress = address(merkleTreeHook) + .addressToBytes32(); + digest = CheckpointLib.digest( + domain, + merkleTreeAddress, + root, + index, + messageId + ); + } - (bytes32 root, uint32 index) = merkleTreeHook.latestCheckpoint(); - bytes32 messageId = message.id(); - bytes32 digest = CheckpointLib.digest( - domain, - address(merkleTreeHook).addressToBytes32(), - root, - index, - messageId + uint256[] memory signers = ThresholdTestUtils.choose( + m, + addValidators(m, n, seed), + seed ); + bytes memory metadata = metadataPrefix(message); + fixtureInit(); + for (uint256 i = 0; i < m; i++) { (uint8 v, bytes32 r, bytes32 s) = vm.sign(signers[i], digest); metadata = abi.encodePacked(metadata, r, s, v); + + fixtureAppendSignature(i, v, r, s); } + + writeFixture(metadata, m, n); + return metadata; } @@ -125,6 +190,9 @@ abstract contract AbstractMultisigIsmTest is Test { contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest { using TypeCasts for address; using Message for bytes; + using Strings for uint256; + + string constant proofKey = "proof"; function setUp() public { mailbox = new TestMailbox(ORIGIN); @@ -135,17 +203,45 @@ contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest { mailbox.setRequiredHook(address(noopHook)); } + function fixturePrefix( + uint32 checkpointIndex, + bytes32 merkleTreeAddress, + bytes32 messageId, + bytes32[32] memory proof + ) internal { + vm.serializeUint(prefixKey, "index", uint256(checkpointIndex)); + vm.serializeBytes32(prefixKey, "merkleTree", merkleTreeAddress); + vm.serializeUint(prefixKey, "signedIndex", uint256(checkpointIndex)); + vm.serializeBytes32(prefixKey, "id", messageId); + + for (uint256 i = 0; i < 32; i++) { + vm.serializeBytes32(proofKey, i.toString(), proof[i]); + } + string memory proofString = vm.serializeString( + proofKey, + "dummy", + "dummy" + ); + vm.serializeString(prefixKey, "proof", proofString); + } + // TODO: test merkleIndex != signedIndex function metadataPrefix( bytes memory message - ) internal view override returns (bytes memory) { + ) internal override returns (bytes memory) { uint32 checkpointIndex = uint32(merkleTreeHook.count() - 1); + bytes32[32] memory proof = merkleTreeHook.proof(); + bytes32 messageId = message.id(); + bytes32 merkleTreeAddress = address(merkleTreeHook).addressToBytes32(); + + fixturePrefix(checkpointIndex, merkleTreeAddress, messageId, proof); + return abi.encodePacked( - address(merkleTreeHook).addressToBytes32(), + merkleTreeAddress, checkpointIndex, - message.id(), - merkleTreeHook.proof(), + messageId, + proof, checkpointIndex ); } @@ -164,15 +260,24 @@ contract MessageIdMultisigIsmTest is AbstractMultisigIsmTest { mailbox.setRequiredHook(address(noopHook)); } + function fixturePrefix( + bytes32 root, + uint32 index, + bytes32 merkleTreeAddress + ) internal { + vm.serializeBytes32(prefixKey, "root", root); + vm.serializeUint(prefixKey, "signedIndex", uint256(index)); + vm.serializeBytes32(prefixKey, "merkleTree", merkleTreeAddress); + } + function metadataPrefix( bytes memory - ) internal view override returns (bytes memory) { + ) internal override returns (bytes memory metadata) { (bytes32 root, uint32 index) = merkleTreeHook.latestCheckpoint(); - return - abi.encodePacked( - address(merkleTreeHook).addressToBytes32(), - root, - index - ); + bytes32 merkleTreeAddress = address(merkleTreeHook).addressToBytes32(); + + fixturePrefix(root, index, merkleTreeAddress); + + return abi.encodePacked(merkleTreeAddress, root, index); } } diff --git a/typescript/sdk/src/ism/metadata/aggregation.test.ts b/typescript/sdk/src/ism/metadata/aggregation.test.ts new file mode 100644 index 0000000000..cb6c3818ca --- /dev/null +++ b/typescript/sdk/src/ism/metadata/aggregation.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import { readFileSync, readdirSync } from 'fs'; + +import { + AggregationIsmMetadata, + AggregationIsmMetadataBuilder, +} from './aggregation.js'; +import { Fixture } from './types.test.js'; + +const path = '../../solidity/fixtures/aggregation'; +const files = readdirSync(path); +const fixtures: Fixture[] = files + .map((f) => JSON.parse(readFileSync(`${path}/${f}`, 'utf8'))) + .map((contents) => { + const { encoded, ...values } = contents; + return { + encoded, + decoded: { + submoduleMetadata: Object.values(values), + }, + }; + }); + +describe('AggregationMetadataBuilder', () => { + fixtures.forEach((fixture, i) => { + it(`should encode fixture ${i}`, () => { + expect(AggregationIsmMetadataBuilder.encode(fixture.decoded)).to.equal( + fixture.encoded, + ); + }); + + it(`should decode fixture ${i}`, () => { + expect( + AggregationIsmMetadataBuilder.decode( + fixture.encoded, + fixture.decoded.submoduleMetadata.length, + ), + ).to.deep.equal(fixture.decoded); + }); + }); +}); diff --git a/typescript/sdk/src/ism/metadata/aggregation.ts b/typescript/sdk/src/ism/metadata/aggregation.ts new file mode 100644 index 0000000000..8ddae22f65 --- /dev/null +++ b/typescript/sdk/src/ism/metadata/aggregation.ts @@ -0,0 +1,60 @@ +import { fromHexString, toHexString } from '@hyperlane-xyz/utils'; + +// null indicates that metadata is NOT INCLUDED for this submodule +// empty or 0x string indicates that metadata is INCLUDED but NULL +export interface AggregationIsmMetadata { + submoduleMetadata: Array; +} + +const RANGE_SIZE = 4; + +// adapted from rust/agents/relayer/src/msg/metadata/aggregation.rs +export class AggregationIsmMetadataBuilder { + static rangeIndex(index: number): number { + return index * 2 * RANGE_SIZE; + } + + static encode(metadata: AggregationIsmMetadata): string { + const rangeSize = this.rangeIndex(metadata.submoduleMetadata.length); + + let encoded = Buffer.alloc(rangeSize, 0); + metadata.submoduleMetadata.forEach((meta, index) => { + if (!meta) return; + + const start = encoded.length; + encoded = Buffer.concat([encoded, fromHexString(meta)]); + const end = encoded.length; + + const rangeStart = this.rangeIndex(index); + encoded.writeUint32BE(start, rangeStart); + encoded.writeUint32BE(end, rangeStart + RANGE_SIZE); + }); + + return toHexString(encoded); + } + + static metadataRange( + metadata: string, + index: number, + ): { start: number; end: number; encoded: string } { + const rangeStart = this.rangeIndex(index); + const encoded = fromHexString(metadata); + const start = encoded.readUint32BE(rangeStart); + const end = encoded.readUint32BE(rangeStart + RANGE_SIZE); + return { + start, + end, + encoded: toHexString(encoded.subarray(start, end)), + }; + } + + static decode(metadata: string, count: number): AggregationIsmMetadata { + const submoduleMetadata = []; + for (let i = 0; i < count; i++) { + const range = this.metadataRange(metadata, i); + const submeta = range.start > 0 ? range.encoded : null; + submoduleMetadata.push(submeta); + } + return { submoduleMetadata }; + } +} diff --git a/typescript/sdk/src/ism/metadata/multisig.test.ts b/typescript/sdk/src/ism/metadata/multisig.test.ts new file mode 100644 index 0000000000..1afca2bdb1 --- /dev/null +++ b/typescript/sdk/src/ism/metadata/multisig.test.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import { readFileSync, readdirSync } from 'fs'; + +import { SignatureLike } from '@hyperlane-xyz/utils'; + +import { ModuleType } from '../types.js'; + +import { MultisigMetadata, MultisigMetadataBuilder } from './multisig.js'; +import { Fixture } from './types.test.js'; + +const path = '../../solidity/fixtures/multisig'; +const files = readdirSync(path); +const fixtures: Fixture[] = files + .map((f) => JSON.parse(readFileSync(`${path}/${f}`, 'utf8'))) + .map((contents) => { + const type = contents.type as MultisigMetadata['type']; + + const { dummy: _dummy, ...signatureValues } = contents.signatures; + const signatures = Object.values(signatureValues); + + let decoded: MultisigMetadata; + if (type === ModuleType.MERKLE_ROOT_MULTISIG) { + const { dummy: _dummy, ...branchValues } = contents.prefix.proof; + const branch = Object.values(branchValues); + decoded = { + type, + proof: { + branch, + leaf: contents.prefix.id, + index: contents.prefix.signedIndex, + }, + checkpoint: { + root: '', + index: contents.prefix.index, + merkle_tree_hook_address: contents.prefix.merkleTree, + }, + signatures, + }; + } else { + decoded = { + type, + checkpoint: { + root: contents.prefix.root, + index: contents.prefix.signedIndex, + merkle_tree_hook_address: contents.prefix.merkleTree, + }, + signatures, + }; + } + return { decoded, encoded: contents.encoded }; + }); + +describe('MultisigMetadataBuilder', () => { + fixtures.forEach((fixture, i) => { + it(`should encode fixture ${i}`, () => { + expect(MultisigMetadataBuilder.encode(fixture.decoded)).to.equal( + fixture.encoded, + ); + }); + + it(`should decode fixture ${i}`, () => { + expect( + MultisigMetadataBuilder.decode(fixture.encoded, fixture.decoded.type), + ).to.deep.equal(fixture.decoded); + }); + }); +}); diff --git a/typescript/sdk/src/ism/metadata/multisig.ts b/typescript/sdk/src/ism/metadata/multisig.ts new file mode 100644 index 0000000000..f27e7114fe --- /dev/null +++ b/typescript/sdk/src/ism/metadata/multisig.ts @@ -0,0 +1,156 @@ +import { joinSignature, splitSignature } from 'ethers/lib/utils.js'; + +import { + Checkpoint, + MerkleProof, + SignatureLike, + assert, + chunk, + ensure0x, + fromHexString, + strip0x, + toHexString, +} from '@hyperlane-xyz/utils'; + +import { ModuleType } from '../types.js'; + +interface MessageIdMultisigMetadata { + type: ModuleType.MESSAGE_ID_MULTISIG; + signatures: SignatureLike[]; + checkpoint: Omit; +} + +interface MerkleRootMultisigMetadata + extends Omit { + type: ModuleType.MERKLE_ROOT_MULTISIG; + proof: MerkleProof; +} + +const SIGNATURE_LENGTH = 65; + +export type MultisigMetadata = + | MessageIdMultisigMetadata + | MerkleRootMultisigMetadata; + +export class MultisigMetadataBuilder { + static encodeSimplePrefix(metadata: MessageIdMultisigMetadata): string { + const checkpoint = metadata.checkpoint; + const buf = Buffer.alloc(68); + buf.write(strip0x(checkpoint.merkle_tree_hook_address), 0, 32, 'hex'); + buf.write(strip0x(checkpoint.root), 32, 32, 'hex'); + buf.writeUInt32BE(checkpoint.index, 64); + return toHexString(buf); + } + + static decodeSimplePrefix(metadata: string) { + const buf = fromHexString(metadata); + const merkleTree = toHexString(buf.subarray(0, 32)); + const root = toHexString(buf.subarray(32, 64)); + const index = buf.readUint32BE(64); + const checkpoint = { + root, + index, + merkle_tree_hook_address: merkleTree, + }; + return { + signatureOffset: 68, + type: ModuleType.MESSAGE_ID_MULTISIG, + checkpoint, + }; + } + + static encodeProofPrefix(metadata: MerkleRootMultisigMetadata): string { + const checkpoint = metadata.checkpoint; + const buf = Buffer.alloc(1096); + buf.write(strip0x(checkpoint.merkle_tree_hook_address), 0, 32, 'hex'); + buf.writeUInt32BE(metadata.proof.index, 32); + buf.write(strip0x(metadata.proof.leaf.toString()), 36, 32, 'hex'); + const branchEncoded = metadata.proof.branch + .map((b) => strip0x(b.toString())) + .join(''); + buf.write(branchEncoded, 68, 32 * 32, 'hex'); + buf.writeUint32BE(checkpoint.index, 1092); + return toHexString(buf); + } + + static decodeProofPrefix(metadata: string) { + const buf = fromHexString(metadata); + const merkleTree = toHexString(buf.subarray(0, 32)); + const messageIndex = buf.readUint32BE(32); + const signedMessageId = toHexString(buf.subarray(36, 68)); + const branchEncoded = buf.subarray(68, 1092).toString('hex'); + const branch = chunk(branchEncoded, 32 * 2).map((v) => ensure0x(v)); + const signedIndex = buf.readUint32BE(1092); + const checkpoint = { + root: '', + index: messageIndex, + merkle_tree_hook_address: merkleTree, + }; + const proof: MerkleProof = { + branch, + leaf: signedMessageId, + index: signedIndex, + }; + return { + signatureOffset: 1096, + type: ModuleType.MERKLE_ROOT_MULTISIG, + checkpoint, + proof, + }; + } + + static encode(metadata: MultisigMetadata): string { + let encoded = + metadata.type === ModuleType.MESSAGE_ID_MULTISIG + ? this.encodeSimplePrefix(metadata) + : this.encodeProofPrefix(metadata); + + metadata.signatures.forEach((signature) => { + const encodedSignature = joinSignature(signature); + assert(fromHexString(encodedSignature).byteLength === SIGNATURE_LENGTH); + encoded += strip0x(encodedSignature); + }); + + return encoded; + } + + static signatureAt( + metadata: string, + offset: number, + index: number, + ): SignatureLike | undefined { + const buf = fromHexString(metadata); + const start = offset + index * SIGNATURE_LENGTH; + const end = start + SIGNATURE_LENGTH; + if (end > buf.byteLength) { + return undefined; + } + + return toHexString(buf.subarray(start, end)); + } + + static decode( + metadata: string, + type: ModuleType.MERKLE_ROOT_MULTISIG | ModuleType.MESSAGE_ID_MULTISIG, + ): MultisigMetadata { + const prefix: any = + type === ModuleType.MERKLE_ROOT_MULTISIG + ? this.decodeProofPrefix(metadata) + : this.decodeSimplePrefix(metadata); + + const { signatureOffset: offset, ...values } = prefix; + + const signatures: SignatureLike[] = []; + for (let i = 0; this.signatureAt(metadata, offset, i); i++) { + const { r, s, v } = splitSignature( + this.signatureAt(metadata, offset, i)!, + ); + signatures.push({ r, s, v }); + } + + return { + signatures, + ...values, + }; + } +} diff --git a/typescript/sdk/src/ism/metadata/types.test.ts b/typescript/sdk/src/ism/metadata/types.test.ts new file mode 100644 index 0000000000..b173f3ca35 --- /dev/null +++ b/typescript/sdk/src/ism/metadata/types.test.ts @@ -0,0 +1,4 @@ +export type Fixture = { + decoded: T; + encoded: string; +}; diff --git a/typescript/utils/package.json b/typescript/utils/package.json index 72475e014b..02c8cd29f9 100644 --- a/typescript/utils/package.json +++ b/typescript/utils/package.json @@ -27,6 +27,7 @@ "license": "Apache-2.0", "prepublish": "yarn build", "scripts": { + "dev": "tsc -w", "build": "tsc", "clean": "rm -rf ./dist", "check": "tsc --noEmit", diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index 6a1752a632..08b85a1c87 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -119,6 +119,8 @@ export { streamToString, toTitleCase, trimToLength, + fromHexString, + toHexString, } from './strings.js'; export { isNullish, isNumeric } from './typeof.js'; export { diff --git a/typescript/utils/src/logging.ts b/typescript/utils/src/logging.ts index caffe418ee..6ea8333dc5 100644 --- a/typescript/utils/src/logging.ts +++ b/typescript/utils/src/logging.ts @@ -9,6 +9,7 @@ import { safelyAccessEnvVar } from './env.js'; // A custom enum definition because pino does not export an enum // and because we use 'off' instead of 'silent' to match the agent options export enum LogLevel { + Trace = 'trace', Debug = 'debug', Info = 'info', Warn = 'warn', diff --git a/typescript/utils/src/strings.ts b/typescript/utils/src/strings.ts index 3690ace198..26d40838aa 100644 --- a/typescript/utils/src/strings.ts +++ b/typescript/utils/src/strings.ts @@ -1,3 +1,5 @@ +import { ensure0x, strip0x } from './addresses.js'; + export function toTitleCase(str: string) { return str.replace(/\w\S*/g, (txt) => { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); @@ -38,3 +40,8 @@ export function errorToString(error: any, maxLength = 300) { if (typeof details === 'string') return trimToLength(details, maxLength); return trimToLength(JSON.stringify(details), maxLength); } + +export const fromHexString = (hexstr: string) => + Buffer.from(strip0x(hexstr), 'hex'); + +export const toHexString = (buf: Buffer) => ensure0x(buf.toString('hex'));