Implement metadata fetching from message (#3702)
### Description - Uses derived hook and ISM config and dispatchTx of message to implement metadata fetching ### Drive-by changes - Change yarn cache key to workaround https://github.com/actions/toolkit/issues/658 - Make `hyperlane message send` use `HyperlaneCore.sendMessage` ### Related issues - Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3450 ### Backward compatibility Yes ### Testing E2E testing BaseMetadataBuilderpull/3854/head
parent
babe816f86
commit
0cf692e731
@ -0,0 +1,7 @@ |
||||
--- |
||||
'@hyperlane-xyz/infra': minor |
||||
'@hyperlane-xyz/utils': minor |
||||
'@hyperlane-xyz/sdk': minor |
||||
--- |
||||
|
||||
Implement metadata builder fetching from message |
@ -0,0 +1,101 @@ |
||||
import { |
||||
Announcement, |
||||
BaseValidator, |
||||
S3Announcement, |
||||
S3CheckpointWithId, |
||||
ValidatorConfig, |
||||
isS3CheckpointWithId, |
||||
} from '@hyperlane-xyz/utils'; |
||||
|
||||
import { S3Config, S3Wrapper } from './s3.js'; |
||||
|
||||
const checkpointWithMessageIdKey = (checkpointIndex: number) => |
||||
`checkpoint_${checkpointIndex}_with_id.json`; |
||||
const LATEST_KEY = 'checkpoint_latest_index.json'; |
||||
const ANNOUNCEMENT_KEY = 'announcement.json'; |
||||
const LOCATION_PREFIX = 's3://'; |
||||
|
||||
/** |
||||
* Extension of BaseValidator that includes AWS S3 utilities. |
||||
*/ |
||||
export class S3Validator extends BaseValidator { |
||||
public s3Bucket: S3Wrapper; |
||||
|
||||
constructor( |
||||
public validatorConfig: ValidatorConfig, |
||||
public s3Config: S3Config, |
||||
) { |
||||
super(validatorConfig); |
||||
this.s3Bucket = new S3Wrapper(s3Config); |
||||
} |
||||
|
||||
static async fromStorageLocation( |
||||
storageLocation: string, |
||||
): Promise<S3Validator> { |
||||
if (storageLocation.startsWith(LOCATION_PREFIX)) { |
||||
const suffix = storageLocation.slice(LOCATION_PREFIX.length); |
||||
const pieces = suffix.split('/'); |
||||
if (pieces.length >= 2) { |
||||
const s3Config = { |
||||
bucket: pieces[0], |
||||
region: pieces[1], |
||||
folder: pieces[2], |
||||
caching: true, |
||||
}; |
||||
const s3Bucket = new S3Wrapper(s3Config); |
||||
const announcement = await s3Bucket.getS3Obj<S3Announcement>( |
||||
ANNOUNCEMENT_KEY, |
||||
); |
||||
if (!announcement) { |
||||
throw new Error('No announcement found'); |
||||
} |
||||
|
||||
const validatorConfig = { |
||||
address: announcement.data.value.validator, |
||||
localDomain: announcement.data.value.mailbox_domain, |
||||
mailbox: announcement.data.value.mailbox_address, |
||||
}; |
||||
|
||||
return new S3Validator(validatorConfig, s3Config); |
||||
} |
||||
} |
||||
throw new Error(`Unable to parse location ${storageLocation}`); |
||||
} |
||||
|
||||
async getAnnouncement(): Promise<Announcement> { |
||||
const resp = await this.s3Bucket.getS3Obj<S3Announcement>(ANNOUNCEMENT_KEY); |
||||
if (!resp) { |
||||
throw new Error('No announcement found'); |
||||
} |
||||
|
||||
return resp.data.value; |
||||
} |
||||
|
||||
async getCheckpoint(index: number): Promise<S3CheckpointWithId | void> { |
||||
const key = checkpointWithMessageIdKey(index); |
||||
const s3Object = await this.s3Bucket.getS3Obj<S3CheckpointWithId>(key); |
||||
if (!s3Object) { |
||||
return; |
||||
} |
||||
|
||||
if (isS3CheckpointWithId(s3Object.data)) { |
||||
return s3Object.data; |
||||
} else { |
||||
throw new Error('Failed to parse checkpoint'); |
||||
} |
||||
} |
||||
|
||||
async getLatestCheckpointIndex(): Promise<number> { |
||||
const latestCheckpointIndex = await this.s3Bucket.getS3Obj<number>( |
||||
LATEST_KEY, |
||||
); |
||||
|
||||
if (!latestCheckpointIndex) return -1; |
||||
|
||||
return latestCheckpointIndex.data; |
||||
} |
||||
|
||||
storageLocation(): string { |
||||
return `${LOCATION_PREFIX}/${this.s3Bucket.config.bucket}/${this.s3Bucket.config.region}`; |
||||
} |
||||
} |
@ -0,0 +1,180 @@ |
||||
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; |
||||
import hre from 'hardhat'; |
||||
import { before } from 'mocha'; |
||||
import sinon from 'sinon'; |
||||
|
||||
import { MerkleTreeHook, TestRecipient } from '@hyperlane-xyz/core'; |
||||
import { |
||||
BaseValidator, |
||||
Checkpoint, |
||||
CheckpointWithId, |
||||
Domain, |
||||
S3CheckpointWithId, |
||||
addressToBytes32, |
||||
eqAddress, |
||||
objMap, |
||||
randomElement, |
||||
} from '@hyperlane-xyz/utils'; |
||||
|
||||
import { testChains } from '../../consts/testChains.js'; |
||||
import { serializeContractsMap } from '../../contracts/contracts.js'; |
||||
import { HyperlaneCore } from '../../core/HyperlaneCore.js'; |
||||
import { TestCoreDeployer } from '../../core/TestCoreDeployer.js'; |
||||
import { TestRecipientDeployer } from '../../core/TestRecipientDeployer.js'; |
||||
import { HyperlaneProxyFactoryDeployer } from '../../deploy/HyperlaneProxyFactoryDeployer.js'; |
||||
import { HyperlaneHookDeployer } from '../../hook/HyperlaneHookDeployer.js'; |
||||
import { HookType, MerkleTreeHookConfig } from '../../hook/types.js'; |
||||
import { MultiProvider } from '../../providers/MultiProvider.js'; |
||||
import { ChainName } from '../../types.js'; |
||||
import { EvmIsmReader } from '../EvmIsmReader.js'; |
||||
import { randomIsmConfig } from '../HyperlaneIsmFactory.hardhat-test.js'; |
||||
import { HyperlaneIsmFactory } from '../HyperlaneIsmFactory.js'; |
||||
|
||||
import { BaseMetadataBuilder, MetadataContext } from './builder.js'; |
||||
|
||||
const MAX_ISM_DEPTH = 5; |
||||
const MAX_NUM_VALIDATORS = 10; |
||||
const NUM_RUNS = 16; |
||||
|
||||
describe('BaseMetadataBuilder', () => { |
||||
let core: HyperlaneCore; |
||||
let ismFactory: HyperlaneIsmFactory; |
||||
let merkleHooks: Record<Domain, MerkleTreeHook>; |
||||
let testRecipients: Record<ChainName, TestRecipient>; |
||||
let relayer: SignerWithAddress; |
||||
let validators: SignerWithAddress[]; |
||||
let metadataBuilder: BaseMetadataBuilder; |
||||
|
||||
before(async () => { |
||||
[relayer, ...validators] = await hre.ethers.getSigners(); |
||||
const multiProvider = MultiProvider.createTestMultiProvider({ |
||||
signer: relayer, |
||||
}); |
||||
const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); |
||||
ismFactory = new HyperlaneIsmFactory( |
||||
await ismFactoryDeployer.deploy(multiProvider.mapKnownChains(() => ({}))), |
||||
multiProvider, |
||||
); |
||||
|
||||
const coreDeployer = new TestCoreDeployer(multiProvider, ismFactory); |
||||
const recipientDeployer = new TestRecipientDeployer(multiProvider); |
||||
testRecipients = objMap( |
||||
await recipientDeployer.deploy( |
||||
Object.fromEntries(testChains.map((c) => [c, {}])), |
||||
), |
||||
(_, { testRecipient }) => testRecipient, |
||||
); |
||||
core = await coreDeployer.deployApp(); |
||||
const hookDeployer = new HyperlaneHookDeployer( |
||||
multiProvider, |
||||
serializeContractsMap(core.contractsMap), |
||||
ismFactory, |
||||
); |
||||
const hookConfig = objMap( |
||||
core.chainMap, |
||||
(): MerkleTreeHookConfig => ({ |
||||
type: HookType.MERKLE_TREE, |
||||
}), |
||||
); |
||||
const hookContracts = await hookDeployer.deploy(hookConfig); |
||||
merkleHooks = Object.fromEntries( |
||||
Object.entries(hookContracts).map(([chain, { merkleTreeHook }]) => [ |
||||
core.multiProvider.getDomainId(chain), |
||||
merkleTreeHook, |
||||
]), |
||||
); |
||||
|
||||
metadataBuilder = new BaseMetadataBuilder(core); |
||||
|
||||
sinon |
||||
.stub(metadataBuilder.multisigMetadataBuilder, 'getS3Checkpoints') |
||||
.callsFake( |
||||
async (multisigAddresses, match): Promise<S3CheckpointWithId[]> => { |
||||
const merkleHook = merkleHooks[match.origin]; |
||||
const checkpoint: Checkpoint = { |
||||
root: await merkleHook.root(), |
||||
merkle_tree_hook_address: addressToBytes32(merkleHook.address), |
||||
index: match.index, |
||||
mailbox_domain: match.origin, |
||||
}; |
||||
const checkpointWithId: CheckpointWithId = { |
||||
checkpoint, |
||||
message_id: match.messageId, |
||||
}; |
||||
const digest = BaseValidator.messageHash(checkpoint, match.messageId); |
||||
const checkpoints: S3CheckpointWithId[] = []; |
||||
for (const validator of multisigAddresses) { |
||||
const signature = await validators |
||||
.find((s) => eqAddress(s.address, validator))! |
||||
.signMessage(digest); |
||||
checkpoints.push({ value: checkpointWithId, signature }); |
||||
} |
||||
return checkpoints; |
||||
}, |
||||
); |
||||
}); |
||||
|
||||
describe('#build', () => { |
||||
let origin: ChainName; |
||||
let destination: ChainName; |
||||
let context: MetadataContext; |
||||
let metadata: string; |
||||
|
||||
beforeEach(async () => { |
||||
origin = randomElement(testChains); |
||||
destination = randomElement(testChains.filter((c) => c !== origin)); |
||||
const testRecipient = testRecipients[destination]; |
||||
|
||||
const addresses = validators |
||||
.map((s) => s.address) |
||||
.slice(0, MAX_NUM_VALIDATORS); |
||||
const config = randomIsmConfig(MAX_ISM_DEPTH, addresses, relayer.address); |
||||
const deployedIsm = await ismFactory.deploy({ |
||||
destination, |
||||
config, |
||||
mailbox: core.getAddresses(destination).mailbox, |
||||
}); |
||||
await testRecipient.setInterchainSecurityModule(deployedIsm.address); |
||||
|
||||
const merkleHookAddress = |
||||
merkleHooks[core.multiProvider.getDomainId(origin)].address; |
||||
const { dispatchTx, message } = await core.sendMessage( |
||||
origin, |
||||
destination, |
||||
testRecipient.address, |
||||
'0xdeadbeef', |
||||
merkleHookAddress, |
||||
); |
||||
|
||||
const derivedIsm = await new EvmIsmReader( |
||||
core.multiProvider, |
||||
destination, |
||||
).deriveIsmConfig(deployedIsm.address); |
||||
|
||||
context = { |
||||
hook: { |
||||
type: HookType.MERKLE_TREE, |
||||
address: merkleHookAddress, |
||||
}, |
||||
ism: derivedIsm, |
||||
message, |
||||
dispatchTx, |
||||
}; |
||||
|
||||
metadata = await metadataBuilder.build(context, MAX_ISM_DEPTH); |
||||
}); |
||||
|
||||
for (let i = 0; i < NUM_RUNS; i++) { |
||||
it(`should build valid metadata for random ism config (${i})`, async () => { |
||||
// must call process for trusted relayer to be able to verify
|
||||
await core |
||||
.getContracts(destination) |
||||
.mailbox.process(metadata, context.message.message); |
||||
}); |
||||
|
||||
it(`should decode metadata for random ism config (${i})`, async () => { |
||||
BaseMetadataBuilder.decode(metadata, context); |
||||
}); |
||||
} |
||||
}); |
||||
}); |
@ -0,0 +1,133 @@ |
||||
/* eslint-disable no-case-declarations */ |
||||
import { TransactionReceipt } from '@ethersproject/providers'; |
||||
|
||||
import { WithAddress, assert, rootLogger } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { deepFind } from '../../../../utils/dist/objects.js'; |
||||
import { HyperlaneCore } from '../../core/HyperlaneCore.js'; |
||||
import { DispatchedMessage } from '../../core/types.js'; |
||||
import { DerivedHookConfig } from '../../hook/EvmHookReader.js'; |
||||
import { HookType, MerkleTreeHookConfig } from '../../hook/types.js'; |
||||
import { MultiProvider } from '../../providers/MultiProvider.js'; |
||||
import { DerivedIsmConfig } from '../EvmIsmReader.js'; |
||||
import { IsmType } from '../types.js'; |
||||
|
||||
import { |
||||
AggregationMetadata, |
||||
AggregationMetadataBuilder, |
||||
} from './aggregation.js'; |
||||
import { MultisigMetadata, MultisigMetadataBuilder } from './multisig.js'; |
||||
import { NullMetadata, NullMetadataBuilder } from './null.js'; |
||||
import { RoutingMetadata, RoutingMetadataBuilder } from './routing.js'; |
||||
|
||||
export type StructuredMetadata = |
||||
| NullMetadata |
||||
| MultisigMetadata |
||||
| AggregationMetadata<any> |
||||
| RoutingMetadata<any>; |
||||
|
||||
export interface MetadataContext< |
||||
IsmContext = DerivedIsmConfig, |
||||
HookContext = DerivedHookConfig, |
||||
> { |
||||
message: DispatchedMessage; |
||||
dispatchTx: TransactionReceipt; |
||||
ism: IsmContext; |
||||
hook: HookContext; |
||||
} |
||||
|
||||
export interface MetadataBuilder { |
||||
build(context: MetadataContext): Promise<string>; |
||||
} |
||||
|
||||
export class BaseMetadataBuilder implements MetadataBuilder { |
||||
public nullMetadataBuilder: NullMetadataBuilder; |
||||
public multisigMetadataBuilder: MultisigMetadataBuilder; |
||||
public aggregationMetadataBuilder: AggregationMetadataBuilder; |
||||
public routingMetadataBuilder: RoutingMetadataBuilder; |
||||
|
||||
public multiProvider: MultiProvider; |
||||
protected logger = rootLogger.child({ module: 'BaseMetadataBuilder' }); |
||||
|
||||
constructor(core: HyperlaneCore) { |
||||
this.multisigMetadataBuilder = new MultisigMetadataBuilder(core); |
||||
this.aggregationMetadataBuilder = new AggregationMetadataBuilder(this); |
||||
this.routingMetadataBuilder = new RoutingMetadataBuilder(this); |
||||
this.nullMetadataBuilder = new NullMetadataBuilder(core.multiProvider); |
||||
this.multiProvider = core.multiProvider; |
||||
} |
||||
|
||||
// assumes that all post dispatch hooks are included in dispatchTx logs
|
||||
async build(context: MetadataContext, maxDepth = 10): Promise<string> { |
||||
this.logger.debug( |
||||
{ context, maxDepth }, |
||||
`Building ${context.ism.type} metadata`, |
||||
); |
||||
assert(maxDepth > 0, 'Max depth reached'); |
||||
|
||||
const { ism, hook } = context; |
||||
switch (ism.type) { |
||||
case IsmType.TRUSTED_RELAYER: |
||||
case IsmType.TEST_ISM: |
||||
case IsmType.OP_STACK: |
||||
case IsmType.PAUSABLE: |
||||
return this.nullMetadataBuilder.build({ ...context, ism }); |
||||
|
||||
case IsmType.MERKLE_ROOT_MULTISIG: |
||||
case IsmType.MESSAGE_ID_MULTISIG: |
||||
const merkleTreeHook = deepFind( |
||||
hook, |
||||
(v): v is WithAddress<MerkleTreeHookConfig> => |
||||
v.type === HookType.MERKLE_TREE && !!v.address, |
||||
); |
||||
assert(merkleTreeHook, 'Merkle tree hook context not found'); |
||||
return this.multisigMetadataBuilder.build({ |
||||
...context, |
||||
ism, |
||||
hook: merkleTreeHook, |
||||
}); |
||||
|
||||
case IsmType.ROUTING: |
||||
return this.routingMetadataBuilder.build( |
||||
{ |
||||
...context, |
||||
ism, |
||||
}, |
||||
maxDepth, |
||||
); |
||||
|
||||
case IsmType.AGGREGATION: |
||||
return this.aggregationMetadataBuilder.build( |
||||
{ ...context, ism }, |
||||
maxDepth, |
||||
); |
||||
|
||||
default: |
||||
throw new Error(`Unsupported ISM type: ${ism.type}`); |
||||
} |
||||
} |
||||
|
||||
static decode( |
||||
metadata: string, |
||||
context: MetadataContext, |
||||
): StructuredMetadata { |
||||
const { ism } = context; |
||||
switch (ism.type) { |
||||
case IsmType.TRUSTED_RELAYER: |
||||
return NullMetadataBuilder.decode(ism); |
||||
|
||||
case IsmType.MERKLE_ROOT_MULTISIG: |
||||
case IsmType.MESSAGE_ID_MULTISIG: |
||||
return MultisigMetadataBuilder.decode(metadata, ism.type); |
||||
|
||||
case IsmType.AGGREGATION: |
||||
return AggregationMetadataBuilder.decode(metadata, { ...context, ism }); |
||||
|
||||
case IsmType.ROUTING: |
||||
return RoutingMetadataBuilder.decode(metadata, { ...context, ism }); |
||||
|
||||
default: |
||||
throw new Error(`Unsupported ISM type: ${ism.type}`); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,35 @@ |
||||
import { WithAddress, assert, eqAddress } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { MultiProvider } from '../../providers/MultiProvider.js'; |
||||
import { IsmType, NullIsmConfig } from '../types.js'; |
||||
|
||||
import { MetadataBuilder, MetadataContext } from './builder.js'; |
||||
|
||||
export const NULL_METADATA = '0x'; |
||||
|
||||
export type NullMetadata = { |
||||
type: NullIsmConfig['type']; |
||||
}; |
||||
|
||||
export class NullMetadataBuilder implements MetadataBuilder { |
||||
constructor(protected multiProvider: MultiProvider) {} |
||||
|
||||
async build( |
||||
context: MetadataContext<WithAddress<NullIsmConfig>>, |
||||
): Promise<string> { |
||||
if (context.ism.type === IsmType.TRUSTED_RELAYER) { |
||||
const destinationSigner = await this.multiProvider.getSignerAddress( |
||||
context.message.parsed.destination, |
||||
); |
||||
assert( |
||||
eqAddress(destinationSigner, context.ism.relayer), |
||||
`Destination signer ${destinationSigner} does not match trusted relayer ${context.ism.relayer}`, |
||||
); |
||||
} |
||||
return NULL_METADATA; |
||||
} |
||||
|
||||
static decode(ism: NullIsmConfig): NullMetadata { |
||||
return { ...ism }; |
||||
} |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { WithAddress, assert } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainName } from '../../types.js'; |
||||
import { DerivedIsmConfig } from '../EvmIsmReader.js'; |
||||
import { IsmType, RoutingIsmConfig } from '../types.js'; |
||||
|
||||
import { |
||||
BaseMetadataBuilder, |
||||
MetadataBuilder, |
||||
MetadataContext, |
||||
StructuredMetadata, |
||||
} from './builder.js'; |
||||
|
||||
export type RoutingMetadata<T> = { |
||||
type: IsmType.ROUTING; |
||||
origin: ChainName; |
||||
metadata: T; |
||||
}; |
||||
|
||||
export class RoutingMetadataBuilder implements MetadataBuilder { |
||||
constructor(protected baseMetadataBuilder: BaseMetadataBuilder) {} |
||||
|
||||
public async build( |
||||
context: MetadataContext<WithAddress<RoutingIsmConfig>>, |
||||
maxDepth = 10, |
||||
): Promise<string> { |
||||
const originChain = this.baseMetadataBuilder.multiProvider.getChainName( |
||||
context.message.parsed.origin, |
||||
); |
||||
const originContext = { |
||||
...context, |
||||
ism: context.ism.domains[originChain] as DerivedIsmConfig, |
||||
}; |
||||
return this.baseMetadataBuilder.build(originContext, maxDepth - 1); |
||||
} |
||||
|
||||
static decode( |
||||
metadata: string, |
||||
context: MetadataContext<WithAddress<RoutingIsmConfig>>, |
||||
): RoutingMetadata<StructuredMetadata | string> { |
||||
// TODO: this is a naive implementation, we should support domain ID keys
|
||||
assert(context.message.parsed.originChain, 'originChain is required'); |
||||
const ism = context.ism.domains[context.message.parsed.originChain]; |
||||
const originMetadata = |
||||
typeof ism === 'string' |
||||
? metadata |
||||
: BaseMetadataBuilder.decode(metadata, { |
||||
...context, |
||||
ism: ism as DerivedIsmConfig, |
||||
}); |
||||
|
||||
return { |
||||
type: IsmType.ROUTING, |
||||
origin: context.message.parsed.originChain, |
||||
metadata: originMetadata, |
||||
}; |
||||
} |
||||
} |
Loading…
Reference in new issue