From 3e37fe6882f88954758e32c494a44a891fb23d70 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Sun, 5 May 2024 19:02:02 -0400 Subject: [PATCH] Add networked builders --- typescript/sdk/src/aws/s3.ts | 76 +++++++ typescript/sdk/src/aws/validator.ts | 103 +++++++++ typescript/sdk/src/hook/read.ts | 30 +-- .../sdk/src/ism/metadata/aggregation.ts | 66 +++++- typescript/sdk/src/ism/metadata/builder.ts | 140 ++++++++++++ typescript/sdk/src/ism/metadata/multisig.ts | 199 +++++++++++++++++- typescript/sdk/src/ism/read.ts | 17 +- typescript/utils/src/async.ts | 3 + typescript/utils/src/index.ts | 4 +- typescript/utils/src/objects.ts | 20 ++ typescript/utils/src/types.ts | 40 ++-- 11 files changed, 653 insertions(+), 45 deletions(-) create mode 100644 typescript/sdk/src/aws/s3.ts create mode 100644 typescript/sdk/src/aws/validator.ts create mode 100644 typescript/sdk/src/ism/metadata/builder.ts diff --git a/typescript/sdk/src/aws/s3.ts b/typescript/sdk/src/aws/s3.ts new file mode 100644 index 0000000000..73836da1a1 --- /dev/null +++ b/typescript/sdk/src/aws/s3.ts @@ -0,0 +1,76 @@ +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Readable } from 'stream'; + +import { streamToString } from '@hyperlane-xyz/utils'; + +export const S3_BUCKET_REGEX = + /^(?:https?:\/\/)?(.*)\.s3\.(.*)\.amazonaws.com\/?$/; + +export interface S3Receipt { + data: T; + modified: Date; +} + +export class S3Wrapper { + private readonly client: S3Client; + readonly bucket: string; + readonly region: string; + readonly folder: string | undefined; + + private cache: Record> | undefined; + + static fromBucketUrl(bucketUrl: string): S3Wrapper { + const match = bucketUrl.match(S3_BUCKET_REGEX); + if (!match) throw new Error('Could not parse bucket url'); + return new S3Wrapper(match[1], match[2], undefined); + } + + constructor( + bucket: string, + region: string, + folder: string | undefined, + caching = false, + ) { + this.bucket = bucket; + this.region = region; + this.folder = folder; + this.client = new S3Client({ region }); + if (caching) { + this.cache = {}; + } + } + + async getS3Obj(key: string): Promise | undefined> { + const Key = this.folder ? `${this.folder}/${key}` : key; + if (this.cache?.[Key]) { + return this.cache![Key]; + } + + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key, + }); + try { + const response = await this.client.send(command); + const body: string = await streamToString(response.Body as Readable); + const result = { + data: JSON.parse(body), + modified: response.LastModified!, + }; + if (this.cache) { + this.cache[Key] = result; + } + return result; + } catch (e: any) { + if (e.message.includes('The specified key does not exist.')) { + return; + } + throw e; + } + } + + url(key: string): string { + const Key = this.folder ? `${this.folder}/${key}` : key; + return `https://${this.bucket}.${this.region}.s3.amazonaws.com/${Key}`; + } +} diff --git a/typescript/sdk/src/aws/validator.ts b/typescript/sdk/src/aws/validator.ts new file mode 100644 index 0000000000..9f5c0db776 --- /dev/null +++ b/typescript/sdk/src/aws/validator.ts @@ -0,0 +1,103 @@ +import { + Announcement, + BaseValidator, + S3Announcement, + S3CheckpointWithId, + isS3CheckpointWithId, +} from '@hyperlane-xyz/utils'; + +import { 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( + address: string, + localDomain: number, + mailbox: string, + s3Bucket: string, + s3Region: string, + s3Folder: string | undefined, + ) { + super(address, localDomain, mailbox); + this.s3Bucket = new S3Wrapper(s3Bucket, s3Region, s3Folder, true); // caching enabled + } + + static async fromStorageLocation( + storageLocation: string, + ): Promise { + if (storageLocation.startsWith(LOCATION_PREFIX)) { + const suffix = storageLocation.slice(LOCATION_PREFIX.length); + const pieces = suffix.split('/'); + if (pieces.length >= 2) { + const s3Bucket = new S3Wrapper(pieces[0], pieces[1], pieces[2]); + const announcement = await s3Bucket.getS3Obj( + ANNOUNCEMENT_KEY, + ); + if (!announcement) { + throw new Error('No announcement found'); + } + + const address = announcement.data.value.validator; + const mailbox = announcement.data.value.mailbox_address; + const localDomain = announcement.data.value.mailbox_domain; + + return new S3Validator( + address, + localDomain, + mailbox, + pieces[0], + pieces[1], + pieces[2], + ); + } + } + throw new Error(`Unable to parse location ${storageLocation}`); + } + + async getAnnouncement(): Promise { + const resp = await this.s3Bucket.getS3Obj(ANNOUNCEMENT_KEY); + if (!resp) { + throw new Error('No announcement found'); + } + + return resp.data.value; + } + + async getCheckpoint(index: number) { + const key = checkpointWithMessageIdKey(index); + const s3Object = await this.s3Bucket.getS3Obj(key); + if (!s3Object) { + return; + } + + if (isS3CheckpointWithId(s3Object.data)) { + return s3Object.data; + } else { + throw new Error('Failed to parse checkpoint'); + } + } + + async getLatestCheckpointIndex() { + const latestCheckpointIndex = await this.s3Bucket.getS3Obj( + LATEST_KEY, + ); + + if (!latestCheckpointIndex) return -1; + + return latestCheckpointIndex.data; + } + + storageLocation(): string { + return `${LOCATION_PREFIX}/${this.s3Bucket.bucket}/${this.s3Bucket.region}`; + } +} diff --git a/typescript/sdk/src/hook/read.ts b/typescript/sdk/src/hook/read.ts index 2f49bf9451..d3c6df4bfd 100644 --- a/typescript/sdk/src/hook/read.ts +++ b/typescript/sdk/src/hook/read.ts @@ -8,7 +8,6 @@ import { FallbackDomainRoutingHook__factory, IPostDispatchHook__factory, InterchainGasPaymaster__factory, - MerkleTreeHook__factory, OPStackHook__factory, PausableHook__factory, ProtocolFee__factory, @@ -42,6 +41,8 @@ import { RoutingHookConfig, } from './types.js'; +export type DerivedHookConfigWithAddress = WithAddress; + export interface HookReader { deriveHookConfig(address: Address): Promise>; deriveMerkleTreeConfig( @@ -82,9 +83,12 @@ export class EvmHookReader implements HookReader { this.provider = multiProvider.getProvider(chain); } - async deriveHookConfig(address: Address): Promise> { + async deriveHookConfig( + address: Address, + ): Promise { const hook = IPostDispatchHook__factory.connect(address, this.provider); const onchainHookType: OnchainHookType = await hook.hookType(); + this.logger.debug('Deriving HookConfig', { address, onchainHookType }); switch (onchainHookType) { case OnchainHookType.ROUTING: @@ -115,8 +119,8 @@ export class EvmHookReader implements HookReader { async deriveMerkleTreeConfig( address: Address, ): Promise> { - const hook = MerkleTreeHook__factory.connect(address, this.provider); - assert((await hook.hookType()) === OnchainHookType.MERKLE_TREE); + // const hook = MerkleTreeHook__factory.connect(address, this.provider); + // assert((await hook.hookType()) === OnchainHookType.MERKLE_TREE); return { address, @@ -128,7 +132,7 @@ export class EvmHookReader implements HookReader { address: Address, ): Promise> { const hook = StaticAggregationHook__factory.connect(address, this.provider); - assert((await hook.hookType()) === OnchainHookType.AGGREGATION); + // assert((await hook.hookType()) === OnchainHookType.AGGREGATION); const hooks = await hook.hooks(ethers.constants.AddressZero); const hookConfigs = await concurrentMap( @@ -149,9 +153,9 @@ export class EvmHookReader implements HookReader { address, this.provider, ); - assert( - (await hook.hookType()) === OnchainHookType.INTERCHAIN_GAS_PAYMASTER, - ); + // assert( + // (await hook.hookType()) === OnchainHookType.INTERCHAIN_GAS_PAYMASTER, + // ); const owner = await hook.owner(); const beneficiary = await hook.beneficiary(); @@ -220,7 +224,7 @@ export class EvmHookReader implements HookReader { address: Address, ): Promise> { const hook = ProtocolFee__factory.connect(address, this.provider); - assert((await hook.hookType()) === OnchainHookType.PROTOCOL_FEE); + // assert((await hook.hookType()) === OnchainHookType.PROTOCOL_FEE); const owner = await hook.owner(); const maxProtocolFee = await hook.MAX_PROTOCOL_FEE(); @@ -242,7 +246,7 @@ export class EvmHookReader implements HookReader { ): Promise> { const hook = OPStackHook__factory.connect(address, this.provider); const owner = await hook.owner(); - assert((await hook.hookType()) === OnchainHookType.ID_AUTH_ISM); + // assert((await hook.hookType()) === OnchainHookType.ID_AUTH_ISM); const messengerContract = await hook.l1Messenger(); const destinationDomain = await hook.destinationDomain(); @@ -262,7 +266,7 @@ export class EvmHookReader implements HookReader { address: Address, ): Promise> { const hook = DomainRoutingHook__factory.connect(address, this.provider); - assert((await hook.hookType()) === OnchainHookType.ROUTING); + // assert((await hook.hookType()) === OnchainHookType.ROUTING); const owner = await hook.owner(); const domainHooks = await this.fetchDomainHooks(hook); @@ -282,7 +286,7 @@ export class EvmHookReader implements HookReader { address, this.provider, ); - assert((await hook.hookType()) === OnchainHookType.FALLBACK_ROUTING); + // assert((await hook.hookType()) === OnchainHookType.FALLBACK_ROUTING); const owner = await hook.owner(); const domainHooks = await this.fetchDomainHooks(hook); @@ -328,7 +332,7 @@ export class EvmHookReader implements HookReader { address: Address, ): Promise> { const hook = PausableHook__factory.connect(address, this.provider); - assert((await hook.hookType()) === OnchainHookType.PAUSABLE); + // assert((await hook.hookType()) === OnchainHookType.PAUSABLE); const owner = await hook.owner(); return { diff --git a/typescript/sdk/src/ism/metadata/aggregation.ts b/typescript/sdk/src/ism/metadata/aggregation.ts index 7b73682e27..53a9396e96 100644 --- a/typescript/sdk/src/ism/metadata/aggregation.ts +++ b/typescript/sdk/src/ism/metadata/aggregation.ts @@ -1,4 +1,19 @@ -import { fromHexString, toHexString } from '@hyperlane-xyz/utils'; +import { TransactionReceipt } from '@ethersproject/providers'; + +import { + WithAddress, + assert, + fromHexString, + rootLogger, + toHexString, +} from '@hyperlane-xyz/utils'; + +import { DispatchedMessage } from '../../core/types.js'; +import { DerivedHookConfigWithAddress } from '../../hook/read.js'; +import { DerivedIsmConfigWithAddress } from '../read.js'; +import { AggregationIsmConfig } from '../types.js'; + +import { BaseMetadataBuilder, MetadataBuilder } from './builder.js'; // null indicates that metadata is NOT INCLUDED for this submodule // empty or 0x string indicates that metadata is INCLUDED but NULL @@ -9,7 +24,54 @@ export interface AggregationIsmMetadata { const RANGE_SIZE = 4; // adapted from rust/agents/relayer/src/msg/metadata/aggregation.rs -export class AggregationIsmMetadataBuilder { +export class AggregationIsmMetadataBuilder + implements + MetadataBuilder< + WithAddress, + DerivedHookConfigWithAddress + > +{ + protected logger = rootLogger.child({ + module: 'AggregationIsmMetadataBuilder', + }); + + constructor(protected readonly base: BaseMetadataBuilder) {} + + async build( + message: DispatchedMessage, + context: { + ism: WithAddress; + hook: DerivedHookConfigWithAddress; + dispatchTx: TransactionReceipt; + }, + maxDepth = 10, + ): Promise { + assert(maxDepth > 0, 'Max depth reached'); + const promises = await Promise.allSettled( + context.ism.modules.map((module) => + this.base.build( + message, + { + ...context, + ism: module as DerivedIsmConfigWithAddress, + }, + maxDepth - 1, + ), + ), + ); + const submoduleMetadata = promises.map((r) => + r.status === 'fulfilled' ? r.value : null, + ); + const included = submoduleMetadata.filter((m) => m !== null).length; + if (included < context.ism.threshold) { + throw new Error( + `Only built ${included} of ${context.ism.threshold} required modules`, + ); + } + + return AggregationIsmMetadataBuilder.encode({ submoduleMetadata }); + } + static rangeIndex(index: number): number { return index * 2 * RANGE_SIZE; } diff --git a/typescript/sdk/src/ism/metadata/builder.ts b/typescript/sdk/src/ism/metadata/builder.ts new file mode 100644 index 0000000000..97bdf9be20 --- /dev/null +++ b/typescript/sdk/src/ism/metadata/builder.ts @@ -0,0 +1,140 @@ +import { TransactionReceipt } from '@ethersproject/providers'; + +import { + WithAddress, + assert, + eqAddress, + 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 { DerivedHookConfigWithAddress } from '../../hook/read.js'; +import { HookType, MerkleTreeHookConfig } from '../../hook/types.js'; +import { DerivedIsmConfigWithAddress } from '../read.js'; +import { IsmType } from '../types.js'; + +import { + AggregationIsmMetadata, + AggregationIsmMetadataBuilder, +} from './aggregation.js'; +import { MultisigMetadata, MultisigMetadataBuilder } from './multisig.js'; + +type NullMetadata = { + type: + | IsmType.PAUSABLE + | IsmType.TEST_ISM + | IsmType.OP_STACK + | IsmType.TRUSTED_RELAYER; +}; + +export type StructuredMetadata = + | AggregationIsmMetadata + | MultisigMetadata + | NullMetadata; + +export interface MetadataBuilder< + I extends DerivedIsmConfigWithAddress, + H extends DerivedHookConfigWithAddress, +> { + build( + message: DispatchedMessage, + context: { + dispatchTx: TransactionReceipt; + hook: H; + ism: I; + }, + ): Promise; +} + +export class BaseMetadataBuilder + implements + MetadataBuilder +{ + private multisigMetadataBuilder: MultisigMetadataBuilder; + private aggregationIsmMetadataBuilder: AggregationIsmMetadataBuilder; + protected logger = rootLogger.child({ module: 'BaseMetadataBuilder' }); + + constructor(protected readonly core: HyperlaneCore) { + this.multisigMetadataBuilder = new MultisigMetadataBuilder(core); + this.aggregationIsmMetadataBuilder = new AggregationIsmMetadataBuilder( + this, + ); + } + + // assumes that all post dispatch hooks are included in dispatchTx logs + async build( + message: DispatchedMessage, + context: { + dispatchTx: TransactionReceipt; + hook: DerivedHookConfigWithAddress; + ism: DerivedIsmConfigWithAddress; + }, + maxDepth = 10, + ): Promise { + assert(maxDepth > 0, 'Max depth reached'); + this.logger.debug( + { maxDepth, context }, + `Building ${context.ism.type} metadata`, + ); + + if (context.ism.type === IsmType.TRUSTED_RELAYER) { + const destinationSigner = await this.core.multiProvider.getSignerAddress( + message.parsed.destination, + ); + assert( + eqAddress(destinationSigner, context.ism.relayer), + `Destination signer ${destinationSigner} does not match trusted relayer ${context.ism.relayer}`, + ); + } + + const { ism, hook, dispatchTx } = context; + switch (ism.type) { + // Null + case IsmType.TRUSTED_RELAYER: + case IsmType.PAUSABLE: + case IsmType.TEST_ISM: + case IsmType.OP_STACK: + return '0x'; + + // Multisig + case IsmType.MERKLE_ROOT_MULTISIG: + case IsmType.MESSAGE_ID_MULTISIG: + // eslint-disable-next-line no-case-declarations + const merkleTreeHook = deepFind( + hook, + (v): v is WithAddress => + v.type === HookType.MERKLE_TREE && v.address !== undefined, + ); + assert(merkleTreeHook, 'Merkle tree hook context not found'); + return this.multisigMetadataBuilder.build(message, { + ism, + hook: merkleTreeHook, + dispatchTx, + }); + + // Routing + case IsmType.ROUTING: + case IsmType.FALLBACK_ROUTING: + return this.build( + message, + { + ...context, + ism: ism.domains[ + this.core.multiProvider.getChainName(message.parsed.origin) + ] as DerivedIsmConfigWithAddress, + }, + maxDepth - 1, + ); + + // Aggregation + case IsmType.AGGREGATION: + return this.aggregationIsmMetadataBuilder.build( + message, + { ...context, ism }, + maxDepth - 1, + ); + } + } +} diff --git a/typescript/sdk/src/ism/metadata/multisig.ts b/typescript/sdk/src/ism/metadata/multisig.ts index f27e7114fe..b6bc6e7443 100644 --- a/typescript/sdk/src/ism/metadata/multisig.ts +++ b/typescript/sdk/src/ism/metadata/multisig.ts @@ -1,18 +1,35 @@ +import { TransactionReceipt } from '@ethersproject/providers'; import { joinSignature, splitSignature } from 'ethers/lib/utils.js'; +import { MerkleTreeHook__factory } from '@hyperlane-xyz/core'; import { + Address, Checkpoint, MerkleProof, + S3CheckpointWithId, SignatureLike, + WithAddress, assert, + bytes32ToAddress, chunk, ensure0x, + eqAddress, + eqAddressEvm, fromHexString, + rootLogger, strip0x, toHexString, } from '@hyperlane-xyz/utils'; -import { ModuleType } from '../types.js'; +import '../../../../utils/dist/types.js'; +import { S3Validator } from '../../aws/validator.js'; +import { HyperlaneCore } from '../../core/HyperlaneCore.js'; +import { DispatchedMessage } from '../../core/types.js'; +import { MerkleTreeHookConfig } from '../../hook/types.js'; +import { ChainName, ChainNameOrId } from '../../types.js'; +import { IsmType, ModuleType, MultisigIsmConfig } from '../types.js'; + +import { MetadataBuilder } from './builder.js'; interface MessageIdMultisigMetadata { type: ModuleType.MESSAGE_ID_MULTISIG; @@ -26,14 +43,180 @@ interface MerkleRootMultisigMetadata proof: MerkleProof; } +const MerkleTreeInterface = MerkleTreeHook__factory.createInterface(); + const SIGNATURE_LENGTH = 65; export type MultisigMetadata = | MessageIdMultisigMetadata | MerkleRootMultisigMetadata; -export class MultisigMetadataBuilder { - static encodeSimplePrefix(metadata: MessageIdMultisigMetadata): string { +export class MultisigMetadataBuilder + implements + MetadataBuilder< + WithAddress, + WithAddress + > +{ + private validatorCache: Record> = {}; + + constructor( + protected readonly core: HyperlaneCore, + protected readonly logger = rootLogger.child({ + module: 'MultisigMetadataBuilder', + }), + ) {} + + private async s3Validators( + originChain: ChainName, + validators: string[], + ): Promise { + this.validatorCache[originChain] ??= {}; + const toFetch = validators.filter( + (v) => !(v in this.validatorCache[originChain]), + ); + + if (toFetch.length > 0) { + const storageLocations = await this.core + .getContracts(originChain) + .validatorAnnounce.getAnnouncedStorageLocations(toFetch); + + this.logger.debug({ storageLocations }, 'Fetched storage locations'); + + const s3Validators = await Promise.all( + storageLocations.map((locations) => { + const latestLocation = locations.slice(-1)[0]; + return S3Validator.fromStorageLocation(latestLocation); + }), + ); + + this.logger.debug({ s3Validators }, 'Fetched validators'); + + toFetch.forEach((validator, index) => { + this.validatorCache[originChain][validator] = s3Validators[index]; + }); + } + + return validators.map((v) => this.validatorCache[originChain][v]); + } + + private async getS3Checkpoints( + validators: Address[], + match: { + origin: ChainNameOrId; + merkleTree: Address; + messageId: string; + index: number; + }, + ): Promise { + this.logger.debug({ match, validators }, 'Fetching checkpoints'); + + const originChain = this.core.multiProvider.getChainName(match.origin); + const s3Validators = await this.s3Validators(originChain, validators); + + const results = await Promise.allSettled( + s3Validators.map((v) => v.getCheckpoint(match.index)), + ); + const checkpoints = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' && result.value !== undefined, + ) + .map((result) => result.value); + + this.logger.debug({ checkpoints }, 'Fetched checkpoints'); + + if (checkpoints.length < validators.length) { + this.logger.debug( + { checkpoints, validators, match }, + `Found ${checkpoints.length} checkpoints out of ${validators.length} validators`, + ); + } + + const matchingCheckpoints = checkpoints.filter( + ({ value }) => + eqAddress( + bytes32ToAddress(value.checkpoint.merkle_tree_hook_address), + match.merkleTree, + ) && + value.message_id === match.messageId && + value.checkpoint.index === match.index && + value.checkpoint.mailbox_domain === match.origin, + ); + + if (matchingCheckpoints.length !== checkpoints.length) { + this.logger.warn( + { matchingCheckpoints, checkpoints, match }, + 'Mismatched checkpoints', + ); + } + + return matchingCheckpoints; + } + + async build( + message: DispatchedMessage, + context: { + ism: WithAddress; + hook: WithAddress; + dispatchTx: TransactionReceipt; + }, + ): Promise { + assert( + context.ism.type === IsmType.MESSAGE_ID_MULTISIG, + 'Merkle proofs are not yet supported', + ); + + const merkleTree = context.hook.address; + + const matchingInsertion = context.dispatchTx.logs + .filter((log) => eqAddressEvm(log.address, merkleTree)) + .map((log) => MerkleTreeInterface.parseLog(log)) + .find((event) => event.args.messageId === message.id); + + assert( + matchingInsertion, + `No merkle tree insertion of ${message.id} to ${merkleTree} found in dispatch tx`, + ); + this.logger.debug({ matchingInsertion }, 'Found matching insertion event'); + + const checkpoints = await this.getS3Checkpoints(context.ism.validators, { + origin: message.parsed.origin, + messageId: message.id, + merkleTree, + index: matchingInsertion.args.index, + }); + assert( + checkpoints.length >= context.ism.threshold, + `Only ${checkpoints.length} of ${context.ism.threshold} required signatures found`, + ); + + this.logger.debug( + { checkpoints }, + `Found ${checkpoints.length} checkpoints for message ${message.id}`, + ); + + const signatures = checkpoints + .map((checkpoint) => checkpoint.signature) + .slice(0, context.ism.threshold); + + this.logger.debug( + { signatures, message }, + `Taking ${signatures.length} (threshold) signatures for message ${message.id}`, + ); + + const metadata: MessageIdMultisigMetadata = { + type: ModuleType.MESSAGE_ID_MULTISIG, + checkpoint: checkpoints[0].value.checkpoint, + signatures, + }; + + return MultisigMetadataBuilder.encode(metadata); + } + + private 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'); @@ -42,7 +225,7 @@ export class MultisigMetadataBuilder { return toHexString(buf); } - static decodeSimplePrefix(metadata: string) { + private static decodeSimplePrefix(metadata: string) { const buf = fromHexString(metadata); const merkleTree = toHexString(buf.subarray(0, 32)); const root = toHexString(buf.subarray(32, 64)); @@ -59,7 +242,9 @@ export class MultisigMetadataBuilder { }; } - static encodeProofPrefix(metadata: MerkleRootMultisigMetadata): string { + private 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'); @@ -73,7 +258,7 @@ export class MultisigMetadataBuilder { return toHexString(buf); } - static decodeProofPrefix(metadata: string) { + private static decodeProofPrefix(metadata: string) { const buf = fromHexString(metadata); const merkleTree = toHexString(buf.subarray(0, 32)); const messageIndex = buf.readUint32BE(32); @@ -114,7 +299,7 @@ export class MultisigMetadataBuilder { return encoded; } - static signatureAt( + private static signatureAt( metadata: string, offset: number, index: number, diff --git a/typescript/sdk/src/ism/read.ts b/typescript/sdk/src/ism/read.ts index d52c5586ab..979550bcc4 100644 --- a/typescript/sdk/src/ism/read.ts +++ b/typescript/sdk/src/ism/read.ts @@ -79,6 +79,7 @@ export class EvmIsmReader implements IsmReader { this.provider, ); const moduleType: ModuleType = await ism.moduleType(); + this.logger.debug('Deriving ISM config', { address, moduleType }); switch (moduleType) { case ModuleType.UNUSED: @@ -94,7 +95,11 @@ export class EvmIsmReader implements IsmReader { case ModuleType.MESSAGE_ID_MULTISIG: return this.deriveMultisigConfig(address); case ModuleType.NULL: - return this.deriveNullConfig(address); + return { + type: IsmType.TEST_ISM, + address, + }; + // return this.deriveNullConfig(address); case ModuleType.CCIP_READ: throw new Error('CCIP_READ does not have a corresponding IsmType'); default: @@ -116,12 +121,18 @@ export class EvmIsmReader implements IsmReader { const domainIds = await ism.domains(); await concurrentMap(this.concurrency, domainIds, async (domainId) => { - const chainName = this.multiProvider.getChainName(domainId.toNumber()); + const chainName = this.multiProvider.tryGetChainName(domainId.toNumber()); + if (!chainName) { + this.logger.warn( + `Unknown domain ID ${domainId}, skipping domain configuration`, + ); + return; + } const module = await ism.module(domainId); domains[chainName] = await this.deriveIsmConfig(module); }); - // Fallback routing ISM extends from MailboxClient, default routign + // Fallback routing ISM extends from MailboxClient, default routing let ismType = IsmType.FALLBACK_ROUTING; try { await ism.mailbox(); diff --git a/typescript/utils/src/async.ts b/typescript/utils/src/async.ts index 86903a5069..c90f059b6d 100644 --- a/typescript/utils/src/async.ts +++ b/typescript/utils/src/async.ts @@ -1,3 +1,5 @@ +import { rootLogger } from './logging.js'; + /** * Return a promise that resolves in ms milliseconds. * @param ms Time to wait @@ -95,6 +97,7 @@ export async function pollAsync( const ret = await runner(); return ret; } catch (error) { + rootLogger.debug(`Error in pollAsync`, { error }); saveError = error; attempts += 1; await sleep(delayMs); diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index 0f42023e8e..70da60cac0 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -127,13 +127,14 @@ export { Address, AddressBytes32, Annotated, + Announcement, CallData, ChainCaip2Id, ChainId, Checkpoint, + CheckpointWithId, Domain, HexString, - InterchainSecurityModuleType, MerkleProof, MessageStatus, Numberish, @@ -144,6 +145,7 @@ export { ProtocolTypeValue, S3Checkpoint, S3CheckpointWithId, + S3Announcement, SignatureLike, TokenCaip19Id, WithAddress, diff --git a/typescript/utils/src/objects.ts b/typescript/utils/src/objects.ts index 21e122d867..fede65f971 100644 --- a/typescript/utils/src/objects.ts +++ b/typescript/utils/src/objects.ts @@ -1,6 +1,7 @@ import { stringify as yamlStringify } from 'yaml'; import { ethersBigNumberSerializer } from './logging.js'; +import { assert } from './validation.js'; export function isObject(item: any) { return item && typeof item === 'object' && !Array.isArray(item); @@ -57,6 +58,25 @@ export function objFilter( ) as Record; } +export function deepFind( + obj: I, + func: (v: I) => v is O, + maxDepth = 10, +): O | undefined { + assert(maxDepth > 0, 'deepFind max depth reached'); + if (func(obj)) { + return obj; + } + const entries = isObject(obj) + ? Object.values(obj) + : Array.isArray(obj) + ? obj + : []; + return entries + .map((e) => deepFind(e as any, func, maxDepth - 1)) + .find((v) => v); +} + // promiseObjectAll :: {k: Promise a} -> Promise {k: a} export function promiseObjAll(obj: { [key in K]: Promise; diff --git a/typescript/utils/src/types.ts b/typescript/utils/src/types.ts index 55e3c46003..e926965d53 100644 --- a/typescript/utils/src/types.ts +++ b/typescript/utils/src/types.ts @@ -1,3 +1,4 @@ +import type { SignatureLike } from '@ethersproject/bytes'; import type { BigNumber, ethers } from 'ethers'; export enum ProtocolType { @@ -28,17 +29,6 @@ export type WithAddress = T & { address: Address; }; -// copied from node_modules/@ethersproject/bytes/src.ts/index.ts -export type SignatureLike = - | { - r: string; - s?: string; - _vs?: string; - recoveryParam?: number; - v?: number; - } - | ethers.utils.BytesLike; - export type MerkleProof = { branch: ethers.utils.BytesLike[]; leaf: ethers.utils.BytesLike; @@ -46,6 +36,13 @@ export type MerkleProof = { }; /********* HYPERLANE CORE *********/ +export type Announcement = { + mailbox_domain: Domain; + mailbox_address: Address; + validator: Address; + storage_location: string; +}; + export type Checkpoint = { root: string; index: number; // safe because 2 ** 32 leaves < Number.MAX_VALUE @@ -53,14 +50,23 @@ export type Checkpoint = { merkle_tree_hook_address: Address; }; +export type CheckpointWithId = { + checkpoint: Checkpoint; + message_id: HexString; +}; + +export { SignatureLike }; + /** * Shape of a checkpoint in S3 as published by the agent. */ export type S3CheckpointWithId = { - value: { - checkpoint: Checkpoint; - message_id: HexString; - }; + value: CheckpointWithId; + signature: SignatureLike; +}; + +export type S3Announcement = { + value: Announcement; signature: SignatureLike; }; @@ -99,10 +105,6 @@ export type ParsedLegacyMultisigIsmMetadata = { validators: ethers.utils.BytesLike[]; }; -export enum InterchainSecurityModuleType { - MULTISIG = 3, -} - export type Annotated = T & { annotation?: string; };