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 BaseMetadataBuilder
pull/3854/head
Yorke Rhodes 6 months ago committed by GitHub
parent babe816f86
commit 0cf692e731
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/witty-vans-return.md
  2. 18
      typescript/infra/scripts/announce-validators.ts
  3. 4
      typescript/infra/scripts/list-validator-checkpoint-indices.ts
  4. 8
      typescript/infra/scripts/verify-validators.ts
  5. 75
      typescript/infra/src/agents/aws/validator.ts
  6. 7
      typescript/infra/src/config/agent/validator.ts
  7. 1
      typescript/sdk/package.json
  8. 51
      typescript/sdk/src/aws/s3.ts
  9. 101
      typescript/sdk/src/aws/validator.ts
  10. 6
      typescript/sdk/src/core/CoreDeployer.hardhat-test.ts
  11. 66
      typescript/sdk/src/core/HyperlaneCore.ts
  12. 5
      typescript/sdk/src/hook/EvmHookReader.ts
  13. 2
      typescript/sdk/src/index.ts
  14. 32
      typescript/sdk/src/ism/EvmIsmReader.ts
  15. 47
      typescript/sdk/src/ism/HyperlaneIsmFactory.hardhat-test.ts
  16. 24
      typescript/sdk/src/ism/metadata/aggregation.test.ts
  17. 103
      typescript/sdk/src/ism/metadata/aggregation.ts
  18. 180
      typescript/sdk/src/ism/metadata/builder.hardhat-test.ts
  19. 133
      typescript/sdk/src/ism/metadata/builder.ts
  20. 8
      typescript/sdk/src/ism/metadata/multisig.test.ts
  21. 198
      typescript/sdk/src/ism/metadata/multisig.ts
  22. 35
      typescript/sdk/src/ism/metadata/null.ts
  23. 58
      typescript/sdk/src/ism/metadata/routing.ts
  24. 13
      typescript/sdk/src/ism/types.ts
  25. 4
      typescript/sdk/src/test/testUtils.ts
  26. 6
      typescript/utils/src/arrays.ts
  27. 14
      typescript/utils/src/index.ts
  28. 4
      typescript/utils/src/math.ts
  29. 18
      typescript/utils/src/objects.ts
  30. 42
      typescript/utils/src/types.ts
  31. 66
      typescript/utils/src/validator.ts
  32. 1
      yarn.lock

@ -0,0 +1,7 @@
---
'@hyperlane-xyz/infra': minor
'@hyperlane-xyz/utils': minor
'@hyperlane-xyz/sdk': minor
---
Implement metadata builder fetching from message

@ -6,7 +6,7 @@ import * as path from 'path';
import { ChainName } from '@hyperlane-xyz/sdk';
import { getChains } from '../config/registry.js';
import { S3Validator } from '../src/agents/aws/validator.js';
import { InfraS3Validator } from '../src/agents/aws/validator.js';
import { CheckpointSyncerType } from '../src/config/agent/validator.js';
import { isEthereumProtocolChain } from '../src/utils/utils.js';
@ -47,7 +47,7 @@ async function main() {
chains.push(chain!);
if (location.startsWith('s3://')) {
const validator = await S3Validator.fromStorageLocation(location);
const validator = await InfraS3Validator.fromStorageLocation(location);
announcements.push({
storageLocation: validator.storageLocation(),
announcement: await validator.getAnnouncement(),
@ -87,13 +87,13 @@ async function main() {
) {
const contracts = core.getContracts(validatorChain);
const localDomain = multiProvider.getDomainId(validatorChain);
const validator = new S3Validator(
validatorBaseConfig.address,
localDomain,
contracts.mailbox.address,
validatorBaseConfig.checkpointSyncer.bucket,
validatorBaseConfig.checkpointSyncer.region,
undefined,
const validator = new InfraS3Validator(
{
localDomain,
address: validatorBaseConfig.address,
mailbox: contracts.mailbox.address,
},
validatorBaseConfig.checkpointSyncer,
);
announcements.push({
storageLocation: validator.storageLocation(),

@ -1,6 +1,6 @@
import { concurrentMap } from '@hyperlane-xyz/utils';
import { S3Validator } from '../src/agents/aws/validator.js';
import { InfraS3Validator } from '../src/agents/aws/validator.js';
import { getArgs, getValidatorsByChain } from './agent-utils.js';
import { getEnvironmentConfig, getHyperlaneCore } from './core-utils.js';
@ -26,7 +26,7 @@ async function main() {
let identifier = validator;
if (storageLocations.length == 1 && storageLocations[0].length == 1) {
try {
const s3Validator = await S3Validator.fromStorageLocation(
const s3Validator = await InfraS3Validator.fromStorageLocation(
storageLocations[0][0],
);
identifier = storageLocations[0][0];

@ -1,6 +1,6 @@
import { objMap, promiseObjAll } from '@hyperlane-xyz/utils';
import { S3Validator } from '../src/agents/aws/validator.js';
import { InfraS3Validator } from '../src/agents/aws/validator.js';
import { getArgs, getValidatorsByChain } from './agent-utils.js';
import { getEnvironmentConfig, getHyperlaneCore } from './core-utils.js';
@ -21,20 +21,20 @@ async function main() {
if (storageLocations[i].length != 1) {
throw new Error('Only support single announcement');
}
return S3Validator.fromStorageLocation(storageLocations[i][0]);
return InfraS3Validator.fromStorageLocation(storageLocations[i][0]);
}),
);
const controlValidator = validators[0];
await Promise.all(
validators.slice(1).map(async (prospectiveValidator) => {
const address = prospectiveValidator.address;
const bucket = prospectiveValidator.s3Bucket.bucket;
const bucket = prospectiveValidator.s3Bucket;
try {
const metrics = await prospectiveValidator.compare(
controlValidator,
);
console.log(
`${chain} ${bucket} validators against control ${controlValidator.s3Bucket.bucket}`,
`${chain} ${bucket} validators against control ${controlValidator.s3Bucket}`,
);
console.table(metrics);
} catch (error) {

@ -1,5 +1,5 @@
import { S3Receipt, S3Validator } from '@hyperlane-xyz/sdk';
import {
BaseValidator,
Checkpoint,
HexString,
S3Checkpoint,
@ -8,8 +8,6 @@ import {
isS3CheckpointWithId,
} from '@hyperlane-xyz/utils';
import { S3Receipt, S3Wrapper } from './s3.js';
export enum CheckpointStatus {
EXTRA = '➕',
MISSING = '❓',
@ -35,71 +33,22 @@ type S3CheckpointReceipt = S3Receipt<SignedCheckpoint>;
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 {
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);
}
export class InfraS3Validator extends S3Validator {
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 s3Bucket = new S3Wrapper(pieces[0], pieces[1], pieces[2]);
const announcement = await s3Bucket.getS3Obj<any>(ANNOUNCEMENT_KEY);
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}`);
): Promise<InfraS3Validator> {
const inner = await S3Validator.fromStorageLocation(storageLocation);
return new InfraS3Validator(inner.validatorConfig, inner.s3Config);
}
async getAnnouncement(): Promise<any> {
const data = await this.s3Bucket.getS3Obj<any>(ANNOUNCEMENT_KEY);
if (data) {
return data.data;
}
}
async getLatestCheckpointIndex() {
const latestCheckpointIndex = await this.s3Bucket.getS3Obj<number>(
LATEST_KEY,
);
if (!latestCheckpointIndex) return -1;
return latestCheckpointIndex.data;
}
async compare(other: S3Validator, count = 5): Promise<CheckpointMetric[]> {
async compare(
other: InfraS3Validator,
count = 5,
): Promise<CheckpointMetric[]> {
const latestCheckpointIndex = await this.s3Bucket.getS3Obj<number>(
LATEST_KEY,
);
@ -153,7 +102,7 @@ export class S3Validator extends BaseValidator {
actual.data.messageId,
)
) {
const signerAddress = this.recoverAddressFromCheckpoint(
const signerAddress = InfraS3Validator.recoverAddressFromCheckpoint(
actual.data.checkpoint,
actual.data.signature,
actual.data.messageId,
@ -196,10 +145,6 @@ export class S3Validator extends BaseValidator {
return checkpointMetrics.slice(-1 * count);
}
storageLocation(): string {
return `${LOCATION_PREFIX}/${this.s3Bucket.bucket}/${this.s3Bucket.region}`;
}
private async getCheckpointReceipt(
index: number,
): Promise<S3CheckpointReceipt | undefined> {

@ -4,6 +4,7 @@ import {
ValidatorConfig as AgentValidatorConfig,
ChainMap,
ChainName,
S3Config,
} from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
@ -75,11 +76,9 @@ export interface LocalCheckpointSyncerConfig {
path: string;
}
export interface S3CheckpointSyncerConfig {
export type S3CheckpointSyncerConfig = S3Config & {
type: CheckpointSyncerType.S3;
bucket: string;
region: string;
}
};
export class ValidatorConfigHelper extends AgentConfigHelper<ValidatorConfig> {
readonly #validatorsConfig: ValidatorBaseChainConfigMap;

@ -3,6 +3,7 @@
"description": "The official SDK for the Hyperlane Network",
"version": "3.12.2",
"dependencies": {
"@aws-sdk/client-s3": "^3.74.0",
"@cosmjs/cosmwasm-stargate": "^0.31.3",
"@cosmjs/stargate": "^0.31.3",
"@hyperlane-xyz/core": "3.12.2",

@ -11,38 +11,60 @@ export interface S3Receipt<T = unknown> {
modified: Date;
}
export interface S3Config {
bucket: string;
region: string;
folder?: string;
caching?: boolean;
}
export class S3Wrapper {
private readonly client: S3Client;
readonly bucket: string;
readonly region: string;
readonly folder: string | undefined;
private cache: Record<string, S3Receipt<any>> | 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);
return new S3Wrapper({
bucket: match[1],
region: match[2],
caching: true,
});
}
constructor(readonly config: S3Config) {
this.client = new S3Client(config);
if (config.caching) {
this.cache = {};
}
}
constructor(bucket: string, region: string, folder: string | undefined) {
this.bucket = bucket;
this.region = region;
this.folder = folder;
this.client = new S3Client({ region });
formatKey(key: string): string {
return this.config.folder ? `${this.config.folder}/${key}` : key;
}
async getS3Obj<T>(key: string): Promise<S3Receipt<T> | undefined> {
const Key = this.folder ? `${this.folder}/${key}` : key;
const Key = this.formatKey(key);
if (this.cache?.[Key]) {
return this.cache![Key];
}
const command = new GetObjectCommand({
Bucket: this.bucket,
Bucket: this.config.bucket,
Key,
});
try {
const response = await this.client.send(command);
const body: string = await streamToString(response.Body as Readable);
return {
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;
@ -50,4 +72,9 @@ export class S3Wrapper {
throw e;
}
}
url(key: string): string {
const Key = this.formatKey(key);
return `https://${this.config.bucket}.${this.config.region}.s3.amazonaws.com/${Key}`;
}
}

@ -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}`;
}
}

@ -9,7 +9,7 @@ import { TestChainName, testChains } from '../consts/testChains.js';
import { HyperlaneContractsMap } from '../contracts/types.js';
import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js';
import { HookConfig } from '../hook/types.js';
import { DerivedIsmConfigWithAddress } from '../ism/EvmIsmReader.js';
import { DerivedIsmConfig } from '../ism/EvmIsmReader.js';
import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js';
import { AggregationIsmConfig, IsmType } from '../ism/types.js';
import { MultiProvider } from '../providers/MultiProvider.js';
@ -141,9 +141,9 @@ describe('core', async () => {
// Cast because we don't expect the 'string' type
const defaultIsmOnchain =
coreConfigOnChain.defaultIsm as DerivedIsmConfigWithAddress;
coreConfigOnChain.defaultIsm as DerivedIsmConfig;
const defaultIsmTest = coreConfig[chainName]
.defaultIsm as DerivedIsmConfigWithAddress;
.defaultIsm as DerivedIsmConfig;
expect(defaultIsmOnchain.type).to.be.equal(defaultIsmTest.type);
}),

@ -1,3 +1,4 @@
import { TransactionReceipt } from '@ethersproject/providers';
import { ethers } from 'ethers';
import type { TransactionReceipt as ViemTxReceipt } from 'viem';
@ -6,6 +7,7 @@ import {
Address,
AddressBytes32,
ProtocolType,
addressToBytes32,
bytes32ToAddress,
eqAddress,
messageId,
@ -19,10 +21,7 @@ import { HyperlaneApp } from '../app/HyperlaneApp.js';
import { appFromAddressesMapHelper } from '../contracts/contracts.js';
import { HyperlaneAddressesMap } from '../contracts/types.js';
import { OwnableConfig } from '../deploy/types.js';
import {
DerivedIsmConfigWithAddress,
EvmIsmReader,
} from '../ism/EvmIsmReader.js';
import { DerivedIsmConfig, EvmIsmReader } from '../ism/EvmIsmReader.js';
import { IsmType, ModuleType, ismTypeToModuleType } from '../ism/types.js';
import { MultiProvider } from '../providers/MultiProvider.js';
import { RouterConfig } from '../router/types.js';
@ -68,11 +67,19 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
destination: ChainName,
recipient: AddressBytes32,
body: string,
metadata?: string,
hook?: Address,
): Promise<ethers.BigNumber> => {
const destinationId = this.multiProvider.getDomainId(destination);
return this.contractsMap[origin].mailbox[
'quoteDispatch(uint32,bytes32,bytes)'
](destinationId, recipient, body);
'quoteDispatch(uint32,bytes32,bytes,bytes,address)'
](
destinationId,
recipient,
body,
metadata || '0x',
hook || ethers.constants.AddressZero,
);
};
protected getDestination(message: DispatchedMessage): ChainName {
@ -87,7 +94,7 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
async getRecipientIsmConfig(
message: DispatchedMessage,
): Promise<DerivedIsmConfigWithAddress> {
): Promise<DerivedIsmConfig> {
const destinationChain = this.getDestination(message);
const ismReader = new EvmIsmReader(this.multiProvider, destinationChain);
const address = await this.getRecipientIsmAddress(message);
@ -121,6 +128,42 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
}
}
async sendMessage(
origin: ChainName,
destination: ChainName,
recipient: Address,
body: string,
hook?: Address,
metadata?: string,
): Promise<{ dispatchTx: TransactionReceipt; message: DispatchedMessage }> {
const mailbox = this.getContracts(origin).mailbox;
const destinationDomain = this.multiProvider.getDomainId(destination);
const recipientBytes32 = addressToBytes32(recipient);
const quote = await this.quoteGasPayment(
origin,
destination,
recipientBytes32,
body,
metadata,
hook,
);
const dispatchTx = await this.multiProvider.handleTx(
origin,
mailbox['dispatch(uint32,bytes32,bytes,bytes,address)'](
destinationDomain,
recipientBytes32,
body,
metadata || '0x',
hook || ethers.constants.AddressZero,
{ value: quote },
),
);
return {
dispatchTx,
message: this.getDispatchedMessages(dispatchTx)[0],
};
}
async relayMessage(
message: DispatchedMessage,
): Promise<ethers.ContractReceipt> {
@ -212,7 +255,14 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
getDispatchedMessages(
sourceTx: ethers.ContractReceipt | ViemTxReceipt,
): DispatchedMessage[] {
return HyperlaneCore.getDispatchedMessages(sourceTx);
const messages = HyperlaneCore.getDispatchedMessages(sourceTx);
return messages.map(({ parsed, ...other }) => {
const originChain =
this.multiProvider.tryGetChainName(parsed.origin) ?? undefined;
const destinationChain =
this.multiProvider.tryGetChainName(parsed.destination) ?? undefined;
return { parsed: { ...parsed, originChain, destinationChain }, ...other };
});
}
async getDispatchTx(

@ -42,6 +42,8 @@ import {
RoutingHookConfig,
} from './types.js';
export type DerivedHookConfig = WithAddress<HookConfig>;
export interface HookReader {
deriveHookConfig(address: Address): Promise<WithAddress<HookConfig>>;
deriveMerkleTreeConfig(
@ -82,9 +84,10 @@ export class EvmHookReader implements HookReader {
this.provider = multiProvider.getProvider(chain);
}
async deriveHookConfig(address: Address): Promise<WithAddress<HookConfig>> {
async deriveHookConfig(address: Address): Promise<DerivedHookConfig> {
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:

@ -468,6 +468,8 @@ export {
} from './token/schemas.js';
export { isCompliant } from './utils/schemas.js';
export { TokenRouterConfig, WarpRouteDeployConfig } from './token/types.js';
export { S3Validator } from './aws/validator.js';
export { S3Config, S3Wrapper, S3Receipt } from './aws/s3.js';
// prettier-ignore
// @ts-ignore

@ -28,25 +28,14 @@ import {
IsmType,
ModuleType,
MultisigIsmConfig,
OpStackIsmConfig,
PausableIsmConfig,
NullIsmConfig,
RoutingIsmConfig,
TestIsmConfig,
TrustedRelayerIsmConfig,
} from './types.js';
type NullIsmConfig =
| PausableIsmConfig
| TestIsmConfig
| OpStackIsmConfig
| TrustedRelayerIsmConfig;
export type DerivedIsmConfigWithAddress = WithAddress<
Exclude<IsmConfig, Address>
>;
export type DerivedIsmConfig = WithAddress<Exclude<IsmConfig, Address>>;
export interface IsmReader {
deriveIsmConfig(address: Address): Promise<DerivedIsmConfigWithAddress>;
deriveIsmConfig(address: Address): Promise<DerivedIsmConfig>;
deriveRoutingConfig(address: Address): Promise<WithAddress<RoutingIsmConfig>>;
deriveAggregationConfig(
address: Address,
@ -71,14 +60,13 @@ export class EvmIsmReader implements IsmReader {
this.provider = multiProvider.getProvider(chain);
}
async deriveIsmConfig(
address: Address,
): Promise<DerivedIsmConfigWithAddress> {
async deriveIsmConfig(address: Address): Promise<DerivedIsmConfig> {
const ism = IInterchainSecurityModule__factory.connect(
address,
this.provider,
);
const moduleType: ModuleType = await ism.moduleType();
this.logger.debug('Deriving ISM config', { address, moduleType });
switch (moduleType) {
case ModuleType.UNUSED:
@ -116,12 +104,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();

@ -3,14 +3,14 @@ import { expect } from 'chai';
import hre from 'hardhat';
import { DomainRoutingIsm, TrustedRelayerIsm } from '@hyperlane-xyz/core';
import { Address } from '@hyperlane-xyz/utils';
import { Address, randomElement, randomInt } from '@hyperlane-xyz/utils';
import { TestChainName, testChains } from '../consts/testChains.js';
import { TestCoreApp } from '../core/TestCoreApp.js';
import { TestCoreDeployer } from '../core/TestCoreDeployer.js';
import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js';
import { MultiProvider } from '../providers/MultiProvider.js';
import { randomAddress, randomInt } from '../test/testUtils.js';
import { randomAddress } from '../test/testUtils.js';
import { HyperlaneIsmFactory } from './HyperlaneIsmFactory.js';
import {
@ -27,43 +27,58 @@ import { moduleMatchesConfig } from './utils.js';
function randomModuleType(): ModuleType {
const choices = [
ModuleType.AGGREGATION,
ModuleType.MERKLE_ROOT_MULTISIG,
ModuleType.MESSAGE_ID_MULTISIG,
ModuleType.ROUTING,
ModuleType.NULL,
];
return choices[randomInt(choices.length)];
return randomElement(choices);
}
const randomMultisigIsmConfig = (m: number, n: number): MultisigIsmConfig => {
const randomMultisigIsmConfig = (
m: number,
n: number,
addresses?: string[],
): MultisigIsmConfig => {
const emptyArray = new Array<number>(n).fill(0);
const validators = emptyArray.map(() => randomAddress());
const validators = emptyArray
.map(() => (addresses ? randomElement(addresses) : randomAddress()))
.sort();
return {
type: IsmType.MERKLE_ROOT_MULTISIG,
type: IsmType.MESSAGE_ID_MULTISIG,
validators,
threshold: m,
};
};
const randomIsmConfig = (depth = 0, maxDepth = 2): IsmConfig => {
export const randomIsmConfig = (
maxDepth = 5,
validatorAddresses?: string[],
relayerAddress?: string,
): Exclude<IsmConfig, Address> => {
const moduleType =
depth == maxDepth ? ModuleType.MERKLE_ROOT_MULTISIG : randomModuleType();
if (moduleType === ModuleType.MERKLE_ROOT_MULTISIG) {
const n = randomInt(5, 1);
return randomMultisigIsmConfig(randomInt(n, 1), n);
maxDepth === 0 ? ModuleType.MESSAGE_ID_MULTISIG : randomModuleType();
if (moduleType === ModuleType.MESSAGE_ID_MULTISIG) {
const n = randomInt(validatorAddresses?.length ?? 5, 1);
return randomMultisigIsmConfig(randomInt(n, 1), n, validatorAddresses);
} else if (moduleType === ModuleType.ROUTING) {
const config: RoutingIsmConfig = {
type: IsmType.ROUTING,
owner: randomAddress(),
domains: Object.fromEntries(
testChains.map((c) => [c, randomIsmConfig(depth + 1)]),
testChains.map((c) => [
c,
randomIsmConfig(maxDepth - 1, validatorAddresses, relayerAddress),
]),
),
};
return config;
} else if (moduleType === ModuleType.AGGREGATION) {
const n = randomInt(5, 1);
const n = randomInt(5, 2);
const modules = new Array<number>(n)
.fill(0)
.map(() => randomIsmConfig(depth + 1));
.map(() =>
randomIsmConfig(maxDepth - 1, validatorAddresses, relayerAddress),
);
const config: AggregationIsmConfig = {
type: IsmType.AGGREGATION,
threshold: randomInt(n, 1),
@ -73,7 +88,7 @@ const randomIsmConfig = (depth = 0, maxDepth = 2): IsmConfig => {
} else if (moduleType === ModuleType.NULL) {
const config: TrustedRelayerIsmConfig = {
type: IsmType.TRUSTED_RELAYER,
relayer: randomAddress(),
relayer: relayerAddress ?? randomAddress(),
};
return config;
} else {

@ -1,21 +1,25 @@
import { expect } from 'chai';
import { ethers } from 'ethers';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { IsmType } from '../types.js';
import {
AggregationIsmMetadata,
AggregationIsmMetadataBuilder,
AggregationMetadata,
AggregationMetadataBuilder,
} from './aggregation.js';
import { Fixture } from './types.test.js';
const path = '../../solidity/fixtures/aggregation';
const files = existsSync(path) ? readdirSync(path) : [];
const fixtures: Fixture<AggregationIsmMetadata>[] = files
const fixtures: Fixture<AggregationMetadata>[] = files
.map((f) => JSON.parse(readFileSync(`${path}/${f}`, 'utf8')))
.map((contents) => {
const { encoded, ...values } = contents;
return {
encoded,
decoded: {
type: IsmType.AGGREGATION,
submoduleMetadata: Object.values(values),
},
};
@ -24,17 +28,21 @@ const fixtures: Fixture<AggregationIsmMetadata>[] = files
describe('AggregationMetadataBuilder', () => {
fixtures.forEach((fixture, i) => {
it(`should encode fixture ${i}`, () => {
expect(AggregationIsmMetadataBuilder.encode(fixture.decoded)).to.equal(
expect(AggregationMetadataBuilder.encode(fixture.decoded)).to.equal(
fixture.encoded,
);
});
it(`should decode fixture ${i}`, () => {
const count = fixture.decoded.submoduleMetadata.length;
expect(
AggregationIsmMetadataBuilder.decode(
fixture.encoded,
fixture.decoded.submoduleMetadata.length,
),
AggregationMetadataBuilder.decode(fixture.encoded, {
ism: {
type: IsmType.AGGREGATION,
modules: new Array(count).fill(ethers.constants.AddressZero),
threshold: count,
},
} as any),
).to.deep.equal(fixture.decoded);
});
});

@ -1,20 +1,91 @@
import { fromHexString, toHexString } from '@hyperlane-xyz/utils';
import {
WithAddress,
assert,
fromHexString,
rootLogger,
timeout,
toHexString,
} from '@hyperlane-xyz/utils';
import { DerivedIsmConfig } from '../EvmIsmReader.js';
import { AggregationIsmConfig, IsmType } from '../types.js';
import {
BaseMetadataBuilder,
MetadataBuilder,
MetadataContext,
StructuredMetadata,
} from './builder.js';
// 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>;
export interface AggregationMetadata<T = string> {
type: IsmType.AGGREGATION;
submoduleMetadata: Array<T | null>;
}
const RANGE_SIZE = 4;
// adapted from rust/agents/relayer/src/msg/metadata/aggregation.rs
export class AggregationIsmMetadataBuilder {
export class AggregationMetadataBuilder implements MetadataBuilder {
protected logger = rootLogger.child({
module: 'AggregationIsmMetadataBuilder',
});
constructor(protected readonly base: BaseMetadataBuilder) {}
async build(
context: MetadataContext<WithAddress<AggregationIsmConfig>>,
maxDepth = 10,
timeoutMs = maxDepth * 1000,
): Promise<string> {
this.logger.debug(
{ context, maxDepth, timeoutMs },
'Building aggregation metadata',
);
assert(maxDepth > 0, 'Max depth reached');
const promises = await Promise.allSettled(
context.ism.modules.map((module) =>
timeout(
this.base.build(
{
...context,
ism: module as DerivedIsmConfig,
},
maxDepth - 1,
),
timeoutMs,
),
),
);
const metadatas = promises.map((r) =>
r.status === 'fulfilled' ? r.value ?? null : null,
);
const included = metadatas.filter((m) => m !== null).length;
assert(
included >= context.ism.threshold,
`Only built ${included} of ${context.ism.threshold} required modules`,
);
// only include the first threshold metadatas
let count = 0;
for (let i = 0; i < metadatas.length; i++) {
if (metadatas[i] === null) continue;
count += 1;
if (count > context.ism.threshold) metadatas[i] = null;
}
return AggregationMetadataBuilder.encode({
...context.ism,
submoduleMetadata: metadatas,
});
}
static rangeIndex(index: number): number {
return index * 2 * RANGE_SIZE;
}
static encode(metadata: AggregationIsmMetadata): string {
static encode(metadata: AggregationMetadata<string>): string {
const rangeSize = this.rangeIndex(metadata.submoduleMetadata.length);
let encoded = Buffer.alloc(rangeSize, 0);
@ -48,13 +119,19 @@ export class AggregationIsmMetadataBuilder {
};
}
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 };
static decode(
metadata: string,
context: MetadataContext<AggregationIsmConfig>,
): AggregationMetadata<StructuredMetadata | string> {
const submoduleMetadata = context.ism.modules.map((ism, index) => {
const range = this.metadataRange(metadata, index);
if (range.start == 0) return null;
if (typeof ism === 'string') return range.encoded;
return BaseMetadataBuilder.decode(range.encoded, {
...context,
ism: ism as DerivedIsmConfig,
});
});
return { type: IsmType.AGGREGATION, submoduleMetadata };
}
}

@ -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}`);
}
}
}

@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
import { SignatureLike } from '@hyperlane-xyz/utils';
import { ModuleType } from '../types.js';
import { IsmType, ModuleType } from '../types.js';
import { MultisigMetadata, MultisigMetadataBuilder } from './multisig.js';
import { Fixture } from './types.test.js';
@ -13,7 +13,7 @@ const files = existsSync(path) ? 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 type = contents.type as ModuleType;
const { dummy: _dummy, ...signatureValues } = contents.signatures;
const signatures = Object.values<SignatureLike>(signatureValues);
@ -23,7 +23,7 @@ const fixtures: Fixture<MultisigMetadata>[] = files
const { dummy: _dummy, ...branchValues } = contents.prefix.proof;
const branch = Object.values<string>(branchValues);
decoded = {
type,
type: IsmType.MERKLE_ROOT_MULTISIG,
proof: {
branch,
leaf: contents.prefix.id,
@ -38,7 +38,7 @@ const fixtures: Fixture<MultisigMetadata>[] = files
};
} else {
decoded = {
type,
type: IsmType.MESSAGE_ID_MULTISIG,
checkpoint: {
root: contents.prefix.root,
index: contents.prefix.signedIndex,

@ -1,39 +1,217 @@
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 { S3Validator } from '../../aws/validator.js';
import { HyperlaneCore } from '../../core/HyperlaneCore.js';
import { MerkleTreeHookConfig } from '../../hook/types.js';
import { ChainName } from '../../types.js';
import { IsmType, MultisigIsmConfig } from '../types.js';
import { MetadataBuilder, MetadataContext } from './builder.js';
interface MessageIdMultisigMetadata {
type: ModuleType.MESSAGE_ID_MULTISIG;
type: IsmType.MESSAGE_ID_MULTISIG;
signatures: SignatureLike[];
checkpoint: Omit<Checkpoint, 'mailbox_domain'>;
}
interface MerkleRootMultisigMetadata
extends Omit<MessageIdMultisigMetadata, 'type'> {
type: ModuleType.MERKLE_ROOT_MULTISIG;
type: IsmType.MERKLE_ROOT_MULTISIG;
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 {
protected validatorCache: Record<ChainName, Record<string, S3Validator>> = {};
constructor(
protected readonly core: HyperlaneCore,
protected readonly logger = rootLogger.child({
module: 'MultisigMetadataBuilder',
}),
) {}
protected async s3Validators(
originChain: ChainName,
validators: string[],
): Promise<S3Validator[]> {
this.validatorCache[originChain] ??= {};
const toFetch = validators.filter(
(v) => !(v in this.validatorCache[originChain]),
);
if (toFetch.length > 0) {
const validatorAnnounce =
this.core.getContracts(originChain).validatorAnnounce;
const storageLocations =
await 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]);
}
async getS3Checkpoints(
validators: Address[],
match: {
origin: number;
merkleTree: Address;
messageId: string;
index: number;
},
): Promise<S3CheckpointWithId[]> {
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)),
);
results
.filter((r) => r.status === 'rejected')
.forEach((r) => {
this.logger.error({ error: r }, 'Failed to fetch checkpoint');
});
const checkpoints = results
.filter(
(result): result is PromiseFulfilledResult<S3CheckpointWithId> =>
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(
context: MetadataContext<
WithAddress<MultisigIsmConfig>,
WithAddress<MerkleTreeHookConfig>
>,
): Promise<string> {
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 === context.message.id);
assert(
matchingInsertion,
`No merkle tree insertion of ${context.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: context.message.parsed.origin,
messageId: context.message.id,
merkleTree,
index: matchingInsertion.args.index,
});
assert(
checkpoints.length >= context.ism.threshold,
`Only ${checkpoints.length} of ${context.ism.threshold} required checkpoints found`,
);
this.logger.debug(
{ checkpoints },
`Found ${checkpoints.length} checkpoints for message ${context.message.id}`,
);
const signatures = checkpoints
.map((checkpoint) => checkpoint.signature)
.slice(0, context.ism.threshold);
this.logger.debug(
{ signatures, ism: context.ism },
`Taking ${signatures.length} (threshold) signatures for message ${context.message.id}`,
);
const metadata: MessageIdMultisigMetadata = {
type: IsmType.MESSAGE_ID_MULTISIG,
checkpoint: checkpoints[0].value.checkpoint,
signatures,
};
return MultisigMetadataBuilder.encode(metadata);
}
protected 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');
@ -54,7 +232,7 @@ export class MultisigMetadataBuilder {
};
return {
signatureOffset: 68,
type: ModuleType.MESSAGE_ID_MULTISIG,
type: IsmType.MESSAGE_ID_MULTISIG,
checkpoint,
};
}
@ -93,7 +271,7 @@ export class MultisigMetadataBuilder {
};
return {
signatureOffset: 1096,
type: ModuleType.MERKLE_ROOT_MULTISIG,
type: IsmType.MERKLE_ROOT_MULTISIG,
checkpoint,
proof,
};
@ -101,7 +279,7 @@ export class MultisigMetadataBuilder {
static encode(metadata: MultisigMetadata): string {
let encoded =
metadata.type === ModuleType.MESSAGE_ID_MULTISIG
metadata.type === IsmType.MESSAGE_ID_MULTISIG
? this.encodeSimplePrefix(metadata)
: this.encodeProofPrefix(metadata);
@ -131,10 +309,10 @@ export class MultisigMetadataBuilder {
static decode(
metadata: string,
type: ModuleType.MERKLE_ROOT_MULTISIG | ModuleType.MESSAGE_ID_MULTISIG,
type: IsmType.MERKLE_ROOT_MULTISIG | IsmType.MESSAGE_ID_MULTISIG,
): MultisigMetadata {
const prefix: any =
type === ModuleType.MERKLE_ROOT_MULTISIG
type === IsmType.MERKLE_ROOT_MULTISIG
? this.decodeProofPrefix(metadata)
: this.decodeSimplePrefix(metadata);

@ -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,
};
}
}

@ -100,15 +100,18 @@ export type TrustedRelayerIsmConfig = {
relayer: Address;
};
export type NullIsmConfig =
| PausableIsmConfig
| TestIsmConfig
| OpStackIsmConfig
| TrustedRelayerIsmConfig;
export type IsmConfig =
| Address
| NullIsmConfig
| RoutingIsmConfig
| MultisigIsmConfig
| AggregationIsmConfig
| OpStackIsmConfig
| TestIsmConfig
| PausableIsmConfig
| TrustedRelayerIsmConfig;
| AggregationIsmConfig;
export type DeployedIsmType = {
[IsmType.ROUTING]: IRoutingIsm;

@ -12,10 +12,6 @@ import { IsmType } from '../ism/types.js';
import { RouterConfig } from '../router/types.js';
import { ChainMap, ChainName } from '../types.js';
export function randomInt(max: number, min = 0): number {
return Math.floor(Math.random() * (max - min)) + min;
}
export function randomAddress(): Address {
return ethers.utils.hexlify(ethers.utils.randomBytes(20));
}

@ -1,3 +1,5 @@
import { randomInt } from './math.js';
interface Sliceable {
length: number;
slice: (i: number, j: number) => any;
@ -14,3 +16,7 @@ export function chunk<T extends Sliceable>(str: T, size: number) {
export function exclude<T>(item: T, list: T[]) {
return list.filter((i) => i !== item);
}
export function randomElement<T>(list: T[]) {
return list[randomInt(list.length)];
}

@ -46,7 +46,7 @@ export {
toWei,
tryParseAmount,
} from './amount.js';
export { chunk, exclude } from './arrays.js';
export { chunk, exclude, randomElement } from './arrays.js';
export {
concurrentMap,
pollAsync,
@ -88,7 +88,7 @@ export {
rootLogger,
setRootLogger,
} from './logging.js';
export { mean, median, stdDev, sum } from './math.js';
export { mean, median, randomInt, stdDev, sum } from './math.js';
export { formatMessage, messageId, parseMessage } from './messages.js';
export {
formatLegacyMultisigIsmMetadata,
@ -115,25 +115,26 @@ export {
export { difference, setEquality, symmetricDifference } from './sets.js';
export {
errorToString,
fromHexString,
sanitizeString,
streamToString,
toHexString,
toTitleCase,
trimToLength,
fromHexString,
toHexString,
} from './strings.js';
export { isNullish, isNumeric } from './typeof.js';
export {
Address,
AddressBytes32,
Annotated,
Announcement,
CallData,
ChainCaip2Id,
ChainId,
Checkpoint,
CheckpointWithId,
Domain,
HexString,
InterchainSecurityModuleType,
MerkleProof,
MessageStatus,
Numberish,
@ -142,6 +143,7 @@ export {
ProtocolSmallestUnit,
ProtocolType,
ProtocolTypeValue,
S3Announcement,
S3Checkpoint,
S3CheckpointWithId,
SignatureLike,
@ -150,4 +152,4 @@ export {
} from './types.js';
export { isHttpsUrl } from './url.js';
export { assert } from './validation.js';
export { BaseValidator } from './validator.js';
export { BaseValidator, ValidatorConfig } from './validator.js';

@ -19,3 +19,7 @@ export function stdDev(a: number[]): number {
const squaredDifferences = a.map((x) => Math.pow(x - xbar, 2));
return Math.sqrt(mean(squaredDifferences));
}
export function randomInt(max: number, min = 0): number {
return Math.floor(Math.random() * (max - min)) + min;
}

@ -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,23 @@ export function objFilter<K extends string, I, O extends I>(
) as Record<K, O>;
}
export function deepFind<I extends object, O extends I>(
obj: I,
func: (v: I) => v is O,
depth = 10,
): O | undefined {
assert(depth > 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, depth - 1)).find((v) => v);
}
// promiseObjectAll :: {k: Promise a} -> Promise {k: a}
export function promiseObjAll<K extends string, V>(obj: {
[key in K]: Promise<V>;

@ -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> = 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;
};
@ -84,8 +90,10 @@ export type ParsedMessage = {
version: number;
nonce: number;
origin: number;
originChain?: string;
sender: string;
destination: number;
destinationChain?: string;
recipient: string;
body: string;
};
@ -99,10 +107,6 @@ export type ParsedLegacyMultisigIsmMetadata = {
validators: ethers.utils.BytesLike[];
};
export enum InterchainSecurityModuleType {
MULTISIG = 3,
}
export type Annotated<T> = T & {
annotation?: string;
};

@ -1,36 +1,50 @@
import { ethers } from 'ethers';
import { eqAddress } from './addresses.js';
import { domainHash } from './domains.js';
import {
Address,
Checkpoint,
Domain,
CheckpointWithId,
HexString,
S3CheckpointWithId,
SignatureLike,
} from './types.js';
export interface ValidatorConfig {
address: string;
localDomain: number;
mailbox: string;
}
/**
* Utilities for validators to construct and verify checkpoints.
*/
export class BaseValidator {
constructor(
public readonly address: Address,
public readonly localDomain: Domain,
public readonly mailbox_address: Address,
) {}
constructor(protected readonly config: ValidatorConfig) {}
get address() {
return this.config.address;
}
announceDomainHash() {
return domainHash(this.localDomain, this.mailbox_address);
return domainHash(this.config.localDomain, this.config.mailbox);
}
checkpointDomainHash(merkle_tree_address: Address) {
return domainHash(this.localDomain, merkle_tree_address);
static checkpointDomainHash(
localDomain: number,
merkle_tree_address: Address,
) {
return domainHash(localDomain, merkle_tree_address);
}
message(checkpoint: Checkpoint, messageId: HexString) {
static message(checkpoint: Checkpoint, messageId: HexString) {
const types = ['bytes32', 'bytes32', 'uint32', 'bytes32'];
const values = [
this.checkpointDomainHash(checkpoint.merkle_tree_hook_address),
this.checkpointDomainHash(
checkpoint.mailbox_domain,
checkpoint.merkle_tree_hook_address,
),
checkpoint.root,
checkpoint.index,
messageId,
@ -38,12 +52,12 @@ export class BaseValidator {
return ethers.utils.solidityPack(types, values);
}
messageHash(checkpoint: Checkpoint, messageId: HexString) {
static messageHash(checkpoint: Checkpoint, messageId: HexString) {
const message = this.message(checkpoint, messageId);
return ethers.utils.arrayify(ethers.utils.keccak256(message));
}
recoverAddressFromCheckpoint(
static recoverAddressFromCheckpoint(
checkpoint: Checkpoint,
signature: SignatureLike,
messageId: HexString,
@ -52,17 +66,31 @@ export class BaseValidator {
return ethers.utils.verifyMessage(msgHash, signature);
}
static recoverAddressFromCheckpointWithId(
{ checkpoint, message_id }: CheckpointWithId,
signature: SignatureLike,
): Address {
return BaseValidator.recoverAddressFromCheckpoint(
checkpoint,
signature,
message_id,
);
}
static recoverAddress({ value, signature }: S3CheckpointWithId): Address {
return BaseValidator.recoverAddressFromCheckpointWithId(value, signature);
}
matchesSigner(
checkpoint: Checkpoint,
signature: SignatureLike,
messageId: HexString,
) {
return (
this.recoverAddressFromCheckpoint(
checkpoint,
signature,
messageId,
).toLowerCase() === this.address.toLowerCase()
const address = BaseValidator.recoverAddressFromCheckpoint(
checkpoint,
signature,
messageId,
);
return eqAddress(address, this.config.address);
}
}

@ -5891,6 +5891,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@hyperlane-xyz/sdk@workspace:typescript/sdk"
dependencies:
"@aws-sdk/client-s3": "npm:^3.74.0"
"@cosmjs/cosmwasm-stargate": "npm:^0.31.3"
"@cosmjs/stargate": "npm:^0.31.3"
"@hyperlane-xyz/core": "npm:3.12.2"

Loading…
Cancel
Save