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 |
||||
|
@ -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