Skip to content

Commit 03ac4d9

Browse files
authored
feat: Add typescript relayer support for CCIP-read ISM (hyperlane-xyz#6141)
### Description Adds CCIP-read ISM support for the typescript relayer (will rename in a follow-up PR). Adds sdk test for it. ### Backward compatibility Yes ### Testing Unit Tests
1 parent 248d2e1 commit 03ac4d9

6 files changed

Lines changed: 249 additions & 1 deletion

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: MIT OR Apache-2.0
2+
pragma solidity >=0.8.0;
3+
4+
error OffchainLookup(
5+
address sender,
6+
string[] urls,
7+
bytes callData,
8+
bytes4 callbackFunction,
9+
bytes extraData
10+
);
11+
12+
import "../../interfaces/isms/ICcipReadIsm.sol";
13+
import "../../interfaces/IInterchainSecurityModule.sol";
14+
import "../../interfaces/IMailbox.sol";
15+
import "../../libs/Message.sol";
16+
import "./AbstractCcipReadIsm.sol";
17+
18+
/**
19+
* @title TestCcipReadIsm
20+
* @notice A test CCIP-Read ISM that simply checks the passed metadata as a boolean.
21+
*/
22+
contract TestCcipReadIsm is AbstractCcipReadIsm {
23+
string[] public urls;
24+
25+
constructor(string[] memory _urls) {
26+
urls = _urls;
27+
}
28+
29+
function getOffchainVerifyInfo(
30+
bytes calldata _message
31+
) external view override {
32+
// Revert with OffchainLookup to instruct off-chain resolution
33+
revert OffchainLookup(address(this), urls, _message, bytes4(0), "");
34+
}
35+
36+
function verify(
37+
bytes calldata metadata,
38+
bytes calldata
39+
) external view override returns (bool) {
40+
bool ok = abi.decode(metadata, (bool));
41+
require(ok, "TestCcipReadIsm: invalid metadata");
42+
return true;
43+
}
44+
}

typescript/sdk/src/ism/EvmIsmReader.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { HyperlaneReader } from '../utils/HyperlaneReader.js';
3434
import {
3535
AggregationIsmConfig,
3636
ArbL2ToL1IsmConfig,
37+
CCIPReadIsmConfig,
3738
DerivedIsmConfig,
3839
DomainRoutingIsmConfig,
3940
IsmType,
@@ -118,7 +119,11 @@ export class EvmIsmReader extends HyperlaneReader implements IsmReader {
118119
derivedIsmConfig = await this.deriveNullConfig(address);
119120
break;
120121
case ModuleType.CCIP_READ:
121-
throw new Error('CCIP_READ does not have a corresponding IsmType');
122+
// CCIP-Read ISM: metadata fetched off-chain
123+
return {
124+
address,
125+
type: IsmType.CCIP_READ,
126+
} as WithAddress<CCIPReadIsmConfig>;
122127
case ModuleType.ARB_L2_TO_L1:
123128
return this.deriveArbL2ToL1Config(address);
124129
default:

typescript/sdk/src/ism/metadata/builder.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { IsmType } from '../types.js';
1616

1717
import { AggregationMetadataBuilder } from './aggregation.js';
1818
import { ArbL2ToL1MetadataBuilder } from './arbL2ToL1.js';
19+
import { CcipReadMetadataBuilder } from './ccipread.js';
1920
import { decodeIsmMetadata } from './decode.js';
2021
import { MultisigMetadataBuilder } from './multisig.js';
2122
import { NullMetadataBuilder } from './null.js';
@@ -32,6 +33,7 @@ export class BaseMetadataBuilder implements MetadataBuilder {
3233
public aggregationMetadataBuilder: AggregationMetadataBuilder;
3334
public routingMetadataBuilder: DefaultFallbackRoutingMetadataBuilder;
3435
public arbL2ToL1MetadataBuilder: ArbL2ToL1MetadataBuilder;
36+
public ccipReadMetadataBuilder: CcipReadMetadataBuilder;
3537

3638
public multiProvider: MultiProvider;
3739
protected logger = rootLogger.child({ module: 'BaseMetadataBuilder' });
@@ -44,6 +46,7 @@ export class BaseMetadataBuilder implements MetadataBuilder {
4446
);
4547
this.nullMetadataBuilder = new NullMetadataBuilder(core.multiProvider);
4648
this.arbL2ToL1MetadataBuilder = new ArbL2ToL1MetadataBuilder(core);
49+
this.ccipReadMetadataBuilder = new CcipReadMetadataBuilder(core);
4750
this.multiProvider = core.multiProvider;
4851
}
4952

@@ -108,6 +111,12 @@ export class BaseMetadataBuilder implements MetadataBuilder {
108111
});
109112
}
110113

114+
case IsmType.CCIP_READ:
115+
return this.ccipReadMetadataBuilder.build({
116+
...context,
117+
ism,
118+
});
119+
111120
default:
112121
throw new Error(`Unsupported ISM: ${ism}`);
113122
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { expect } from 'chai';
2+
import hre from 'hardhat';
3+
import sinon from 'sinon';
4+
5+
import { TestCcipReadIsm__factory } from '@hyperlane-xyz/core';
6+
import { WithAddress } from '@hyperlane-xyz/utils';
7+
8+
import { HyperlaneCore } from '../../core/HyperlaneCore.js';
9+
import { TestCoreDeployer } from '../../core/TestCoreDeployer.js';
10+
import { TestRecipientDeployer } from '../../core/TestRecipientDeployer.js';
11+
import { HyperlaneProxyFactoryDeployer } from '../../deploy/HyperlaneProxyFactoryDeployer.js';
12+
import { MultiProvider } from '../../providers/MultiProvider.js';
13+
import { EvmIsmReader } from '../EvmIsmReader.js';
14+
import { HyperlaneIsmFactory } from '../HyperlaneIsmFactory.js';
15+
import { CCIPReadIsmConfig } from '../types.js';
16+
17+
import { BaseMetadataBuilder } from './builder.js';
18+
import type { MetadataContext } from './types.js';
19+
20+
describe('CCIP-Read ISM Integration', () => {
21+
let core: HyperlaneCore;
22+
let multiProvider: MultiProvider;
23+
let testRecipient: any;
24+
let ccipReadIsm: any;
25+
let metadataBuilder: BaseMetadataBuilder;
26+
let ismFactory: HyperlaneIsmFactory;
27+
let fetchStub: sinon.SinonStub;
28+
29+
before(async () => {
30+
// Set up a local test multi-provider and Hyperlane core
31+
const signers = await hre.ethers.getSigners();
32+
multiProvider = MultiProvider.createTestMultiProvider({
33+
signer: signers[0],
34+
});
35+
const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider);
36+
const contractsMap = await ismFactoryDeployer.deploy(
37+
multiProvider.mapKnownChains(() => ({})),
38+
);
39+
ismFactory = new HyperlaneIsmFactory(contractsMap, multiProvider);
40+
core = await new TestCoreDeployer(multiProvider, ismFactory).deployApp();
41+
42+
// Deploy a TestRecipient on test1
43+
const deployments = await new TestRecipientDeployer(multiProvider).deploy({
44+
test2: {},
45+
});
46+
testRecipient = (deployments.test2 as any).testRecipient;
47+
48+
// Deploy the TestCcipReadIsm on test1 domain
49+
const domain = multiProvider.getDomainId('test1');
50+
ccipReadIsm = await multiProvider.handleDeploy(
51+
domain,
52+
new TestCcipReadIsm__factory(),
53+
// Pass in desired offchain URLs for the ISM constructor:
54+
[['http://example.com/{data}']],
55+
);
56+
57+
// Configure the TestRecipient to use the CCIP-Read ISM
58+
await testRecipient.setInterchainSecurityModule(ccipReadIsm.address);
59+
60+
// Prepare the metadata builder
61+
metadataBuilder = new BaseMetadataBuilder(core);
62+
63+
fetchStub = sinon.stub(global, 'fetch').resolves({
64+
ok: true,
65+
json: async () => ({
66+
data: '0x0000000000000000000000000000000000000000000000000000000000000001',
67+
}),
68+
} as Response);
69+
});
70+
71+
it('should process a message protected by CCIP-Read ISM', async () => {
72+
// Send a message from test1 to test2
73+
const { dispatchTx, message } = await core.sendMessage(
74+
'test1',
75+
'test2',
76+
testRecipient.address,
77+
'0x1234',
78+
);
79+
80+
// Derive the on-chain ISM config for CCIP-Read
81+
const derivedIsm = (await new EvmIsmReader(
82+
multiProvider,
83+
'test2',
84+
).deriveIsmConfig(ccipReadIsm.address)) as WithAddress<CCIPReadIsmConfig>;
85+
86+
// Build the metadata using the CCIP-Read builder
87+
const context: MetadataContext<WithAddress<CCIPReadIsmConfig>> = {
88+
ism: derivedIsm,
89+
message,
90+
dispatchTx,
91+
hook: {} as any,
92+
};
93+
const metadata = await metadataBuilder.build(context);
94+
95+
// Finally, call mailbox.process on test2 with the metadata and message
96+
const mailbox = core.getContracts('test2').mailbox;
97+
await expect(mailbox.process(metadata, message.message)).to.not.be.reverted;
98+
});
99+
100+
after(() => {
101+
fetchStub.restore();
102+
});
103+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { utils } from 'ethers';
2+
3+
import { ICcipReadIsm__factory } from '@hyperlane-xyz/core';
4+
import { WithAddress } from '@hyperlane-xyz/utils';
5+
6+
import { HyperlaneCore } from '../../core/HyperlaneCore.js';
7+
import { CCIPReadIsmConfig, IsmType } from '../types.js';
8+
9+
import type { MetadataBuilder, MetadataContext } from './types.js';
10+
11+
export class CcipReadMetadataBuilder implements MetadataBuilder {
12+
readonly type = IsmType.CCIP;
13+
private core: HyperlaneCore;
14+
15+
constructor(core: HyperlaneCore) {
16+
this.core = core;
17+
}
18+
19+
async build(
20+
context: MetadataContext<WithAddress<CCIPReadIsmConfig>>,
21+
): Promise<string> {
22+
const { ism, message } = context;
23+
const provider = this.core.multiProvider.getProvider(message.parsed.origin);
24+
const contract = ICcipReadIsm__factory.connect(ism.address, provider);
25+
26+
let revertData: string;
27+
try {
28+
// Should revert with OffchainLookup
29+
await contract.getOffchainVerifyInfo(message.message);
30+
throw new Error('Expected OffchainLookup revert');
31+
} catch (err: any) {
32+
revertData = err.error?.data || err.data;
33+
if (!revertData) throw err;
34+
}
35+
36+
const parsed = contract.interface.parseError(revertData);
37+
if (parsed.name !== 'OffchainLookup') {
38+
throw new Error(`Unexpected error ${parsed.name}`);
39+
}
40+
const [sender, urls, callData] = parsed.args as [
41+
string,
42+
string[],
43+
Uint8Array,
44+
];
45+
const callDataHex = utils.hexlify(callData);
46+
47+
for (const urlTemplate of urls) {
48+
const url = urlTemplate
49+
.replace('{sender}', sender)
50+
.replace('{data}', callDataHex);
51+
try {
52+
let responseJson: any;
53+
if (urlTemplate.includes('{data}')) {
54+
const res = await fetch(url);
55+
responseJson = await res.json();
56+
} else {
57+
const res = await fetch(url, {
58+
method: 'POST',
59+
headers: { 'Content-Type': 'application/json' },
60+
body: JSON.stringify({ sender, data: callDataHex }),
61+
});
62+
responseJson = await res.json();
63+
}
64+
const rawHex = responseJson.data as string;
65+
return rawHex.startsWith('0x') ? rawHex : `0x${rawHex}`;
66+
} catch (error: any) {
67+
this.core.logger.warn(
68+
`CCIP-read metadata fetch failed for ${url}: ${error}`,
69+
);
70+
// try next URL
71+
}
72+
}
73+
74+
throw new Error('Could not fetch CCIP-read metadata');
75+
}
76+
}

typescript/sdk/src/ism/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ArbL2ToL1Ism,
55
CCIPIsm,
66
IAggregationIsm,
7+
ICcipReadIsm,
78
IInterchainSecurityModule,
89
IMultisigIsm,
910
IRoutingIsm,
@@ -66,6 +67,7 @@ export enum IsmType {
6667
WEIGHTED_MERKLE_ROOT_MULTISIG = 'weightedMerkleRootMultisigIsm',
6768
WEIGHTED_MESSAGE_ID_MULTISIG = 'weightedMessageIdMultisigIsm',
6869
CCIP = 'ccipIsm',
70+
CCIP_READ = 'ccipReadIsm',
6971
}
7072

7173
// ISM types that can be updated in-place
@@ -118,6 +120,8 @@ export function ismTypeToModuleType(ismType: IsmType): ModuleType {
118120
return ModuleType.WEIGHTED_MERKLE_ROOT_MULTISIG;
119121
case IsmType.WEIGHTED_MESSAGE_ID_MULTISIG:
120122
return ModuleType.WEIGHTED_MESSAGE_ID_MULTISIG;
123+
case IsmType.CCIP_READ:
124+
return ModuleType.CCIP_READ;
121125
}
122126
}
123127

@@ -143,6 +147,7 @@ export type TrustedRelayerIsmConfig = z.infer<
143147
>;
144148
export type CCIPIsmConfig = z.infer<typeof CCIPIsmConfigSchema>;
145149
export type ArbL2ToL1IsmConfig = z.infer<typeof ArbL2ToL1IsmConfigSchema>;
150+
export type CCIPReadIsmConfig = z.infer<typeof CCIPReadIsmConfigSchema>;
146151

147152
export type NullIsmConfig =
148153
| TestIsmConfig
@@ -210,6 +215,7 @@ export type DeployedIsmType = {
210215
[IsmType.ARB_L2_TO_L1]: ArbL2ToL1Ism;
211216
[IsmType.WEIGHTED_MERKLE_ROOT_MULTISIG]: IStaticWeightedMultisigIsm;
212217
[IsmType.WEIGHTED_MESSAGE_ID_MULTISIG]: IStaticWeightedMultisigIsm;
218+
[IsmType.CCIP_READ]: ICcipReadIsm;
213219
};
214220

215221
export type DeployedIsm = ValueOf<DeployedIsmType>;
@@ -262,6 +268,10 @@ export const ArbL2ToL1IsmConfigSchema = z.object({
262268
bridge: z.string(),
263269
});
264270

271+
export const CCIPReadIsmConfigSchema = z.object({
272+
type: z.literal(IsmType.CCIP_READ),
273+
});
274+
265275
export const PausableIsmConfigSchema = PausableSchema.and(
266276
z.object({
267277
type: z.literal(IsmType.PAUSABLE),
@@ -335,4 +345,5 @@ export const IsmConfigSchema = z.union([
335345
RoutingIsmConfigSchema,
336346
AggregationIsmConfigSchema,
337347
ArbL2ToL1IsmConfigSchema,
348+
CCIPReadIsmConfigSchema,
338349
]);

0 commit comments

Comments
 (0)