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 Testspull/3763/head
parent
78d3e62dc7
commit
69de68a66e
@ -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 |
@ -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; |
||||||
|
}; |
Loading…
Reference in new issue