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
pull/3763/head
Yorke Rhodes 7 months ago committed by GitHub
parent 78d3e62dc7
commit 69de68a66e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .changeset/green-ads-live.md
  2. 6
      .changeset/sour-bats-sort.md
  3. 2
      package.json
  4. 1
      solidity/.gitignore
  5. 20
      solidity/README.md
  6. 5
      solidity/foundry.toml
  7. 4
      solidity/package.json
  8. 50
      solidity/test/isms/AggregationIsm.t.sol
  9. 151
      solidity/test/isms/MultisigIsm.t.sol
  10. 41
      typescript/sdk/src/ism/metadata/aggregation.test.ts
  11. 60
      typescript/sdk/src/ism/metadata/aggregation.ts
  12. 67
      typescript/sdk/src/ism/metadata/multisig.test.ts
  13. 156
      typescript/sdk/src/ism/metadata/multisig.ts
  14. 4
      typescript/sdk/src/ism/metadata/types.test.ts
  15. 1
      typescript/utils/package.json
  16. 2
      typescript/utils/src/index.ts
  17. 1
      typescript/utils/src/logging.ts
  18. 7
      typescript/utils/src/strings.ts

@ -1,5 +1,5 @@
--- ---
"@hyperlane-xyz/cli": minor '@hyperlane-xyz/cli': minor
--- ---
Default to home directory for local registry Default to home directory for local registry

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/utils': minor
'@hyperlane-xyz/sdk': minor
---
Implement aggregation and multisig ISM metadata encoding

@ -25,7 +25,7 @@
"prettier": "yarn workspaces foreach --since --parallel run prettier", "prettier": "yarn workspaces foreach --since --parallel run prettier",
"lint": "yarn workspaces foreach --all --parallel run lint", "lint": "yarn workspaces foreach --all --parallel run lint",
"test": "yarn workspaces foreach --all --parallel run test", "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", "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:prepare": "yarn changeset version && yarn workspaces foreach --all --parallel run version:update && yarn install --no-immutable",
"version:check": "yarn changeset status", "version:check": "yarn changeset status",

@ -14,3 +14,4 @@ forge-cache
docs docs
flattened/ flattened/
buildArtifact.json buildArtifact.json
fixtures/

@ -6,14 +6,30 @@ Hyperlane Core contains the contracts and typechain artifacts for the Hyperlane
```bash ```bash
# Install with NPM # Install with NPM
npm install @hyperlane-xyz/utils npm install @hyperlane-xyz/core
# Or with Yarn # 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) 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 ## License
Apache 2.0 Apache 2.0

@ -10,7 +10,10 @@ solc_version = '0.8.22'
evm_version= 'paris' evm_version= 'paris'
optimizer = true optimizer = true
optimizer_runs = 999_999 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'] ignored_warnings_from = ['fx-portal']
[profile.ci] [profile.ci]

@ -61,14 +61,14 @@
"scripts": { "scripts": {
"build": "yarn hardhat-esm compile && tsc && ./exportBuildArtifact.sh", "build": "yarn hardhat-esm compile && tsc && ./exportBuildArtifact.sh",
"lint": "solhint contracts/**/*.sol", "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", "coverage": "./coverage.sh",
"docs": "forge doc", "docs": "forge doc",
"hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config hardhat.config.cts", "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config hardhat.config.cts",
"prettier": "prettier --write ./contracts ./test", "prettier": "prettier --write ./contracts ./test",
"test": "yarn hardhat-esm test && yarn test:forge", "test": "yarn hardhat-esm test && yarn test:forge",
"test:hardhat": "yarn hardhat-esm test", "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", "test:ci": "yarn test:hardhat && yarn test:forge --no-match-test testFork",
"gas": "forge snapshot", "gas": "forge snapshot",
"gas-ci": "yarn gas --check --tolerance 2 || (echo 'Manually update gas snapshot' && exit 1)", "gas-ci": "yarn gas --check --tolerance 2 || (echo 'Manually update gas snapshot' && exit 1)",

@ -3,12 +3,19 @@ pragma solidity ^0.8.13;
import "forge-std/Test.sol"; import "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import {IAggregationIsm} from "../../contracts/interfaces/isms/IAggregationIsm.sol"; import {IAggregationIsm} from "../../contracts/interfaces/isms/IAggregationIsm.sol";
import {StaticAggregationIsmFactory} from "../../contracts/isms/aggregation/StaticAggregationIsmFactory.sol"; import {StaticAggregationIsmFactory} from "../../contracts/isms/aggregation/StaticAggregationIsmFactory.sol";
import {AggregationIsmMetadata} from "../../contracts/isms/libs/AggregationIsmMetadata.sol"; import {AggregationIsmMetadata} from "../../contracts/isms/libs/AggregationIsmMetadata.sol";
import {TestIsm, ThresholdTestUtils} from "./IsmTestUtils.sol"; import {TestIsm, ThresholdTestUtils} from "./IsmTestUtils.sol";
contract AggregationIsmTest is Test { contract AggregationIsmTest is Test {
using Strings for uint256;
using Strings for uint8;
string constant fixtureKey = "fixture";
StaticAggregationIsmFactory factory; StaticAggregationIsmFactory factory;
IAggregationIsm ism; IAggregationIsm ism;
@ -16,6 +23,24 @@ contract AggregationIsmTest is Test {
factory = new StaticAggregationIsmFactory(); 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( function deployIsms(
uint8 m, uint8 m,
uint8 n, uint8 n,
@ -32,34 +57,37 @@ contract AggregationIsmTest is Test {
return isms; return isms;
} }
function getMetadata( function getMetadata(uint8 m, bytes32 seed) private returns (bytes memory) {
uint8 m,
bytes32 seed
) private view returns (bytes memory) {
(address[] memory choices, ) = ism.modulesAndThreshold(""); (address[] memory choices, ) = ism.modulesAndThreshold("");
address[] memory chosen = ThresholdTestUtils.choose(m, choices, seed); address[] memory chosen = ThresholdTestUtils.choose(m, choices, seed);
bytes memory offsets; bytes memory offsets;
uint32 start = 8 * uint32(choices.length); uint32 start = 8 * uint32(choices.length);
bytes memory metametadata; bytes memory metametadata;
for (uint256 i = 0; i < choices.length; i++) { for (uint256 i = 0; i < choices.length; i++) {
bool included = false; bool included = false;
for (uint256 j = 0; j < chosen.length; j++) { for (uint256 j = 0; j < chosen.length; j++) {
included = included || choices[i] == chosen[j]; included = included || choices[i] == chosen[j];
} }
if (included) { if (included) {
bytes memory requiredMetadata = TestIsm(choices[i]) bytes memory metadata = TestIsm(choices[i]).requiredMetadata();
.requiredMetadata(); uint32 end = start + uint32(metadata.length);
uint32 end = start + uint32(requiredMetadata.length);
uint64 offset = (uint64(start) << 32) | uint64(end); uint64 offset = (uint64(start) << 32) | uint64(end);
offsets = bytes.concat(offsets, abi.encodePacked(offset)); offsets = bytes.concat(offsets, abi.encodePacked(offset));
start = end; start = end;
metametadata = abi.encodePacked(metametadata, requiredMetadata); metametadata = abi.encodePacked(metametadata, metadata);
fixtureAppendMetadata(i, metadata);
} else { } else {
uint64 offset = 0; offsets = bytes.concat(offsets, abi.encodePacked(uint64(0)));
offsets = bytes.concat(offsets, abi.encodePacked(offset)); 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 { function testVerify(uint8 m, uint8 n, bytes32 seed) public {

@ -3,6 +3,8 @@ pragma solidity ^0.8.13;
import "forge-std/Test.sol"; import "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import {IMultisigIsm} from "../../contracts/interfaces/isms/IMultisigIsm.sol"; import {IMultisigIsm} from "../../contracts/interfaces/isms/IMultisigIsm.sol";
import {TestMailbox} from "../../contracts/test/TestMailbox.sol"; import {TestMailbox} from "../../contracts/test/TestMailbox.sol";
import {StaticMerkleRootMultisigIsmFactory, StaticMessageIdMultisigIsmFactory} from "../../contracts/isms/multisig/StaticMultisigIsm.sol"; import {StaticMerkleRootMultisigIsmFactory, StaticMessageIdMultisigIsmFactory} from "../../contracts/isms/multisig/StaticMultisigIsm.sol";
@ -20,6 +22,13 @@ import {ThresholdTestUtils} from "./IsmTestUtils.sol";
abstract contract AbstractMultisigIsmTest is Test { abstract contract AbstractMultisigIsmTest is Test {
using Message for bytes; using Message for bytes;
using TypeCasts for address; 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; uint32 constant ORIGIN = 11;
StaticThresholdAddressSetFactory factory; StaticThresholdAddressSetFactory factory;
@ -30,7 +39,47 @@ abstract contract AbstractMultisigIsmTest is Test {
function metadataPrefix( function metadataPrefix(
bytes memory message 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( function getMetadata(
uint8 m, uint8 m,
@ -38,24 +87,40 @@ abstract contract AbstractMultisigIsmTest is Test {
bytes32 seed, bytes32 seed,
bytes memory message bytes memory message
) internal returns (bytes memory) { ) internal returns (bytes memory) {
uint32 domain = mailbox.localDomain(); bytes32 digest;
uint256[] memory keys = addValidators(m, n, seed); {
uint256[] memory signers = ThresholdTestUtils.choose(m, keys, seed); 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(); uint256[] memory signers = ThresholdTestUtils.choose(
bytes32 messageId = message.id(); m,
bytes32 digest = CheckpointLib.digest( addValidators(m, n, seed),
domain, seed
address(merkleTreeHook).addressToBytes32(),
root,
index,
messageId
); );
bytes memory metadata = metadataPrefix(message); bytes memory metadata = metadataPrefix(message);
fixtureInit();
for (uint256 i = 0; i < m; i++) { for (uint256 i = 0; i < m; i++) {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signers[i], digest); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signers[i], digest);
metadata = abi.encodePacked(metadata, r, s, v); metadata = abi.encodePacked(metadata, r, s, v);
fixtureAppendSignature(i, v, r, s);
} }
writeFixture(metadata, m, n);
return metadata; return metadata;
} }
@ -125,6 +190,9 @@ abstract contract AbstractMultisigIsmTest is Test {
contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest { contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest {
using TypeCasts for address; using TypeCasts for address;
using Message for bytes; using Message for bytes;
using Strings for uint256;
string constant proofKey = "proof";
function setUp() public { function setUp() public {
mailbox = new TestMailbox(ORIGIN); mailbox = new TestMailbox(ORIGIN);
@ -135,17 +203,45 @@ contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest {
mailbox.setRequiredHook(address(noopHook)); 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 // TODO: test merkleIndex != signedIndex
function metadataPrefix( function metadataPrefix(
bytes memory message bytes memory message
) internal view override returns (bytes memory) { ) internal override returns (bytes memory) {
uint32 checkpointIndex = uint32(merkleTreeHook.count() - 1); 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 return
abi.encodePacked( abi.encodePacked(
address(merkleTreeHook).addressToBytes32(), merkleTreeAddress,
checkpointIndex, checkpointIndex,
message.id(), messageId,
merkleTreeHook.proof(), proof,
checkpointIndex checkpointIndex
); );
} }
@ -164,15 +260,24 @@ contract MessageIdMultisigIsmTest is AbstractMultisigIsmTest {
mailbox.setRequiredHook(address(noopHook)); 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( function metadataPrefix(
bytes memory bytes memory
) internal view override returns (bytes memory) { ) internal override returns (bytes memory metadata) {
(bytes32 root, uint32 index) = merkleTreeHook.latestCheckpoint(); (bytes32 root, uint32 index) = merkleTreeHook.latestCheckpoint();
return bytes32 merkleTreeAddress = address(merkleTreeHook).addressToBytes32();
abi.encodePacked(
address(merkleTreeHook).addressToBytes32(), fixturePrefix(root, index, merkleTreeAddress);
root,
index return abi.encodePacked(merkleTreeAddress, root, index);
);
} }
} }

@ -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<AggregationIsmMetadata>[] = 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);
});
});
});

@ -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<string | null>;
}
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 };
}
}

@ -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<MultisigMetadata>[] = 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<SignatureLike>(signatureValues);
let decoded: MultisigMetadata;
if (type === ModuleType.MERKLE_ROOT_MULTISIG) {
const { dummy: _dummy, ...branchValues } = contents.prefix.proof;
const branch = Object.values<string>(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);
});
});
});

@ -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<Checkpoint, 'mailbox_domain'>;
}
interface MerkleRootMultisigMetadata
extends Omit<MessageIdMultisigMetadata, 'type'> {
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,
};
}
}

@ -0,0 +1,4 @@
export type Fixture<T> = {
decoded: T;
encoded: string;
};

@ -27,6 +27,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"prepublish": "yarn build", "prepublish": "yarn build",
"scripts": { "scripts": {
"dev": "tsc -w",
"build": "tsc", "build": "tsc",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"check": "tsc --noEmit", "check": "tsc --noEmit",

@ -119,6 +119,8 @@ export {
streamToString, streamToString,
toTitleCase, toTitleCase,
trimToLength, trimToLength,
fromHexString,
toHexString,
} from './strings.js'; } from './strings.js';
export { isNullish, isNumeric } from './typeof.js'; export { isNullish, isNumeric } from './typeof.js';
export { export {

@ -9,6 +9,7 @@ import { safelyAccessEnvVar } from './env.js';
// A custom enum definition because pino does not export an enum // A custom enum definition because pino does not export an enum
// and because we use 'off' instead of 'silent' to match the agent options // and because we use 'off' instead of 'silent' to match the agent options
export enum LogLevel { export enum LogLevel {
Trace = 'trace',
Debug = 'debug', Debug = 'debug',
Info = 'info', Info = 'info',
Warn = 'warn', Warn = 'warn',

@ -1,3 +1,5 @@
import { ensure0x, strip0x } from './addresses.js';
export function toTitleCase(str: string) { export function toTitleCase(str: string) {
return str.replace(/\w\S*/g, (txt) => { return str.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); 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); if (typeof details === 'string') return trimToLength(details, maxLength);
return trimToLength(JSON.stringify(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'));

Loading…
Cancel
Save