Skip to content

Commit 0fa73ea

Browse files
feat: check for protocol version for interop
1 parent a88d0d6 commit 0fa73ea

File tree

6 files changed

+182
-2
lines changed

6 files changed

+182
-2
lines changed

src/adapters/__tests__/adapter-harness.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const IL1AssetRouter = new Interface(IL1AssetRouterABI as any);
3838
const IL1Nullifier = new Interface(IL1NullifierABI as any);
3939
const IERC20 = new Interface(IERC20ABI as any);
4040
const L2NativeTokenVault = new Interface(L2NativeTokenVaultABI as any);
41+
const IChainTypeManager = new Interface([
42+
'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
43+
]);
4144

4245
const lower = (value: string) => value.toLowerCase();
4346
type ResultValue = unknown | unknown[];
@@ -77,6 +80,7 @@ export const ADAPTER_TEST_ADDRESSES = {
7780
l1Nullifier: '0xc000000000000000000000000000000000000000' as Address,
7881
l1NativeTokenVault: '0xd000000000000000000000000000000000000000' as Address,
7982
baseTokenFor324: '0xbee0000000000000000000000000000000000000' as Address,
83+
chainTypeManager: '0xe000000000000000000000000000000000000000' as Address,
8084
signer: '0x1111111111111111111111111111111111111111' as Address,
8185
} as const;
8286

@@ -365,6 +369,26 @@ function seedDefaults(registry: CallRegistry, baseToken: Address) {
365369
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
366370
);
367371
registry.set(ADAPTER_TEST_ADDRESSES.bridgehub, IBridgehub, 'baseToken', baseToken, [324n]);
372+
registry.set(
373+
ADAPTER_TEST_ADDRESSES.bridgehub,
374+
IBridgehub,
375+
'chainTypeManager',
376+
ADAPTER_TEST_ADDRESSES.chainTypeManager,
377+
[324n],
378+
);
379+
registry.set(
380+
ADAPTER_TEST_ADDRESSES.bridgehub,
381+
IBridgehub,
382+
'chainTypeManager',
383+
ADAPTER_TEST_ADDRESSES.chainTypeManager,
384+
[325n],
385+
);
386+
registry.set(
387+
ADAPTER_TEST_ADDRESSES.chainTypeManager,
388+
IChainTypeManager,
389+
'getSemverProtocolVersion',
390+
[0n, 31n, 0n],
391+
);
368392
}
369393

370394
export function createEthersHarness(opts: BaseOpts = {}): EthersHarness {

src/adapters/__tests__/client.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { describe, it, expect } from 'bun:test';
2+
import { Interface } from 'ethers';
23

34
import {
45
ADAPTER_TEST_ADDRESSES,
56
type AdapterHarness,
67
describeForAdapters,
78
} from './adapter-harness';
89
import type { Address } from '../../core/types/primitives';
10+
import { IBridgehubABI } from '../../core/abi';
911

1012
const toLower = (value: Address) => value.toLowerCase();
13+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
14+
const IBridgehub = new Interface(IBridgehubABI as any);
1115

1216
function assertContractAddress(harness: AdapterHarness, contract: any, expected: Address) {
1317
if (harness.kind === 'ethers') {
@@ -66,6 +70,36 @@ describeForAdapters('adapters client', (kind, factory) => {
6670
expect(toLower(baseToken)).toBe(toLower(ADAPTER_TEST_ADDRESSES.baseTokenFor324));
6771
});
6872

73+
it('getSemverProtocolVersion resolves the registered CTM semver', async () => {
74+
const harness = factory();
75+
if (harness.kind !== 'ethers') {
76+
expect('getSemverProtocolVersion' in harness.client).toBe(false);
77+
return;
78+
}
79+
80+
const semver = await harness.client.getSemverProtocolVersion();
81+
expect(semver).toEqual([0, 31, 0]);
82+
});
83+
84+
it('getSemverProtocolVersion returns null when chain CTM is not registered', async () => {
85+
const harness = factory();
86+
if (harness.kind !== 'ethers') {
87+
expect('getSemverProtocolVersion' in harness.client).toBe(false);
88+
return;
89+
}
90+
91+
harness.registry.set(
92+
ADAPTER_TEST_ADDRESSES.bridgehub,
93+
IBridgehub,
94+
'chainTypeManager',
95+
ZERO_ADDRESS,
96+
[324n],
97+
);
98+
99+
const semver = await harness.client.getSemverProtocolVersion();
100+
expect(semver).toBeNull();
101+
});
102+
69103
it('respects manual overrides without hitting discovery calls', async () => {
70104
const overrides = {
71105
bridgehub: '0x1000000000000000000000000000000000000001',

src/adapters/__tests__/interop/resource.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, it, expect } from 'bun:test';
2+
import { Interface } from 'ethers';
23

34
import { createInteropResource } from '../../ethers/resources/interop/index.ts';
45
import {
6+
ADAPTER_TEST_ADDRESSES,
57
createEthersHarness,
68
setErc20Allowance,
79
setL2TokenRegistration,
@@ -12,6 +14,9 @@ const RECIPIENT = '0x2222222222222222222222222222222222222222' as Address;
1214
const TX_HASH = `0x${'aa'.repeat(32)}` as Hex;
1315
const ERC20_TOKEN = '0x3333333333333333333333333333333333333333' as Address;
1416
const ASSET_ID = `0x${'11'.repeat(32)}` as Hex;
17+
const IChainTypeManager = new Interface([
18+
'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
19+
]);
1520

1621
describe('adapters/interop/resource', () => {
1722
it('status returns SENT when source receipt is not yet available', async () => {
@@ -125,4 +130,29 @@ describe('adapters/interop/resource', () => {
125130
expect(txCountCalls).toBe(0);
126131
expect(sentNonces).toEqual([42, 43, 44]);
127132
});
133+
134+
it('prepare fails when protocol minor version is below 31', async () => {
135+
const harness = createEthersHarness();
136+
const interop = createInteropResource(harness.client);
137+
138+
harness.registry.set(
139+
ADAPTER_TEST_ADDRESSES.chainTypeManager,
140+
IChainTypeManager,
141+
'getSemverProtocolVersion',
142+
[0n, 30n, 0n],
143+
);
144+
145+
let caught: unknown;
146+
try {
147+
await interop.prepare({
148+
dstChain: harness.l2 as any,
149+
actions: [{ type: 'sendNative', to: RECIPIENT, amount: 1n }],
150+
});
151+
} catch (err) {
152+
caught = err;
153+
}
154+
155+
expect(caught).toBeDefined();
156+
expect(String(caught)).toMatch(/interop requires protocol version 31\+/i);
157+
});
128158
});

src/adapters/ethers/client.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ import {
2828
import { createError } from '../../core/errors/factory';
2929
import { OP_DEPOSITS, OP_CLIENT } from '../../core/types';
3030
import { createErrorHandlers } from './errors/error-ops';
31+
import { FORMAL_ETH_ADDRESS } from '../../core/constants';
3132

3233
// error handling
3334
const { wrapAs, wrap } = createErrorHandlers('client');
35+
// Single function, instead of the whole ABI. If more fns are needed, consider adding the whole ABI.
36+
const ChainTypeManagerABI = [
37+
'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
38+
] as const;
39+
40+
export type ProtocolVersion = readonly [number, number, number];
3441

3542
export interface ResolvedAddresses {
3643
bridgehub: Address;
@@ -84,6 +91,9 @@ export interface EthersClient {
8491
/** Lookup the base token for a given chain ID via Bridgehub.baseToken(chainId) */
8592
baseToken(chainId: bigint): Promise<Address>;
8693

94+
/** Read semver protocol version for the CTM of a chain. */
95+
getProtocolVersion(chainId?: bigint): Promise<ProtocolVersion>;
96+
8797
/** Get a signer connected to a specific provider */
8898
signerFor(target?: 'l1' | AbstractProvider): Signer;
8999
}
@@ -298,6 +308,48 @@ export function createEthersClient(args: InitArgs): EthersClient {
298308
})) as Address;
299309
}
300310

311+
async function getProtocolVersion(chainId?: bigint): Promise<ProtocolVersion> {
312+
const targetChainId = chainId ?? (await l2.getNetwork()).chainId;
313+
const { bridgehub } = await ensureAddresses();
314+
const bh = new Contract(bridgehub, IBridgehubABI, l1);
315+
316+
const chainTypeManager = (await wrapAs(
317+
'CONTRACT',
318+
OP_CLIENT.getSemverProtocolVersion,
319+
() => bh.chainTypeManager(targetChainId),
320+
{
321+
ctx: { where: 'bridgehub.chainTypeManager', bridgehub, chainId: targetChainId },
322+
message: 'Failed to read chain type manager.',
323+
},
324+
)) as Address;
325+
326+
if (chainTypeManager.toLowerCase() === FORMAL_ETH_ADDRESS) {
327+
throw createError('STATE', {
328+
resource: 'client',
329+
operation: OP_CLIENT.getSemverProtocolVersion,
330+
message: 'No registered chain type manager for the chain.',
331+
context: { chainId },
332+
});
333+
}
334+
335+
const ctm = new Contract(chainTypeManager, ChainTypeManagerABI, l1);
336+
const semver = (await wrapAs(
337+
'CONTRACT',
338+
OP_CLIENT.getSemverProtocolVersion,
339+
() => ctm.getSemverProtocolVersion(),
340+
{
341+
ctx: {
342+
where: 'chainTypeManager.getSemverProtocolVersion',
343+
chainId: targetChainId,
344+
chainTypeManager,
345+
},
346+
message: 'Failed to read semver protocol version.',
347+
},
348+
)) as [number, number, number];
349+
350+
return semver;
351+
}
352+
301353
/** Signer helpers */
302354
function signerFor(target?: 'l1' | AbstractProvider): Signer {
303355
if (target === 'l1') {
@@ -323,6 +375,7 @@ export function createEthersClient(args: InitArgs): EthersClient {
323375
contracts,
324376
refresh,
325377
baseToken,
378+
getProtocolVersion,
326379
signerFor,
327380
};
328381

src/adapters/ethers/resources/interop/context.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/adapters/ethers/resources/interop/context.ts
22
import type { AbstractProvider } from 'ethers';
33
import { Interface } from 'ethers';
4-
import type { EthersClient } from '../../client';
4+
import type { EthersClient, ProtocolVersion } from '../../client';
55
import type { Address } from '../../../../core/types/primitives';
66
import type { CommonCtx } from '../../../../core/types/flows/base';
77
import type { InteropParams } from '../../../../core/types/flows/interop';
@@ -10,6 +10,39 @@ import type { TokensResource } from '../../../../core/types/flows/token';
1010
import type { AttributesResource } from '../../../../core/resources/interop/attributes/resource';
1111
import type { ContractsResource } from '../contracts';
1212
import { IInteropHandlerABI, InteropCenterABI } from '../../../../core/abi';
13+
import { createError } from '../../../../core/errors/factory';
14+
import { OP_INTEROP } from '../../../../core/types/errors';
15+
16+
const MIN_INTEROP_PROTOCOL = 31;
17+
18+
async function assertInteropProtocolVersion(
19+
client: EthersClient,
20+
srcChainId: bigint,
21+
dstChainId: bigint,
22+
): Promise<void> {
23+
const [srcProtocolVersion, dstProtocolVersion] = await Promise.all([
24+
client.getProtocolVersion(srcChainId),
25+
client.getProtocolVersion(dstChainId),
26+
]);
27+
28+
const assertProtocolVersion = (chainId: bigint, protocolVersion: ProtocolVersion): void => {
29+
if (protocolVersion[1] < MIN_INTEROP_PROTOCOL) {
30+
throw createError('VALIDATION', {
31+
resource: 'interop',
32+
operation: OP_INTEROP.context.protocolVersion,
33+
message: `Interop requires protocol version 31.0+. Found: ${protocolVersion[1]}.${protocolVersion[2]} for chain: ${chainId}.`,
34+
context: {
35+
chainId,
36+
requiredMinor: MIN_INTEROP_PROTOCOL,
37+
semver: protocolVersion,
38+
},
39+
});
40+
}
41+
};
42+
43+
assertProtocolVersion(srcChainId, srcProtocolVersion);
44+
assertProtocolVersion(dstChainId, dstProtocolVersion);
45+
}
1346

1447
// Common context for building interop (L2 -> L2) transactions
1548
export interface BuildCtx extends CommonCtx {
@@ -54,6 +87,8 @@ export async function commonCtx(
5487
l2MessageVerification,
5588
} = await contracts.addresses();
5689

90+
await assertInteropProtocolVersion(client, chainId, dstChainId);
91+
5792
const [srcBaseToken, dstBaseToken] = await Promise.all([
5893
client.baseToken(chainId),
5994
client.baseToken(dstChainId),

src/core/types/errors.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export type TryResult<T> = { ok: true; value: T } | { ok: false; error: ZKsyncEr
195195

196196
export const OP_CLIENT = {
197197
ensureAddresses: 'client.ensureAddresses',
198+
getSemverProtocolVersion: 'client.getSemverProtocolVersion',
198199
} as const;
199200

200201
// Operation constants for Deposit error contexts
@@ -314,7 +315,10 @@ export const OP_INTEROP = {
314315
tryWait: 'interop.tryWait',
315316
finalize: 'interop.finalize',
316317
tryFinalize: 'interop.tryFinalize',
317-
318+
context: {
319+
chainTypeManager: 'interop.chainTypeManager',
320+
protocolVersion: 'interop.protocolVersion',
321+
},
318322
// route-specific ops (keep names aligned with files)
319323
routes: {
320324
direct: {

0 commit comments

Comments
 (0)