Skip to content

Commit 9e3d02b

Browse files
authored
Check nonce before relaying message (#2938)
1 parent 3e1c272 commit 9e3d02b

5 files changed

Lines changed: 215 additions & 10 deletions

File tree

contracts/tasks/actions/crossChainRelay.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ethers } from "ethers";
2+
import { types } from "hardhat/config";
23
import { configuration } from "../../utils/cctp";
34
import { keyValueStoreLocalClient } from "../../utils/defender";
45
import { getNetworkName } from "../../utils/hardhat-helpers";
@@ -9,7 +10,15 @@ action({
910
name: "crossChainRelay",
1011
description: "Relay CCTP bridge transactions between mainnet and Base",
1112
chains: [1, 8453],
12-
run: async ({ signer, chainId, log }) => {
13+
params: (t) => {
14+
t.addOptionalParam(
15+
"txHash",
16+
"Source-chain tx hash to relay. When set, skips the recent-events scan and relays only this transaction's message(s). Must be run on the destination chain.",
17+
undefined,
18+
types.string
19+
);
20+
},
21+
run: async ({ signer, chainId, log, args }) => {
1322
let sourceProvider: ethers.providers.JsonRpcProvider;
1423

1524
if (chainId === 1) {
@@ -45,6 +54,7 @@ action({
4554
});
4655

4756
await processCctpBridgeTransactions({
57+
txHash: args.txHash,
4858
destinationChainSigner: signer,
4959
sourceChainProvider: sourceProvider,
5060
store,

contracts/tasks/actions/crossChainRelayHyperEVM.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ethers } from "ethers";
2+
import { types } from "hardhat/config";
23
import { configuration } from "../../utils/cctp";
34
import { keyValueStoreLocalClient } from "../../utils/defender";
45
import { getNetworkName } from "../../utils/hardhat-helpers";
@@ -9,7 +10,15 @@ action({
910
name: "crossChainRelayHyperEVM",
1011
description: "Relay CCTP bridge transactions between mainnet and HyperEVM",
1112
chains: [1, 999],
12-
run: async ({ signer, chainId, log }) => {
13+
params: (t) => {
14+
t.addOptionalParam(
15+
"txHash",
16+
"Source-chain tx hash to relay. When set, skips the recent-events scan and relays only this transaction's message(s). Must be run on the destination chain.",
17+
undefined,
18+
types.string
19+
);
20+
},
21+
run: async ({ signer, chainId, log, args }) => {
1322
let sourceProvider: ethers.providers.JsonRpcProvider;
1423

1524
if (chainId === 1) {
@@ -45,6 +54,7 @@ action({
4554
});
4655

4756
await processCctpBridgeTransactions({
57+
txHash: args.txHash,
4858
destinationChainSigner: signer,
4959
sourceChainProvider: sourceProvider,
5060
store,

contracts/tasks/actions/relayCCTPMessage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ action({
1414
"Fetches CCTP attested Messages via Circle Gateway API and relays it to the integrator contract",
1515
chains: [1, 8453],
1616
params: (t) => {
17+
t.addOptionalParam(
18+
"txHash",
19+
"Source-chain tx hash to relay. When set, skips the recent-events scan and relays only this transaction's message(s). Must be run on the destination chain.",
20+
undefined,
21+
types.string
22+
);
1723
t.addOptionalParam(
1824
"block",
1925
"Override the block number at which the message emission transaction happened",

contracts/tasks/crossChain.js

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,73 @@ const { logTxDetails } = require("../utils/txLogger");
44
const { api: cctpApi } = require("../utils/cctp");
55
const log = require("../utils/logger")("task:crossChain");
66

7+
// 0x-prefixed 32-byte tx hash
8+
const TX_HASH_REGEX = /^0x([A-Fa-f0-9]{64})$/;
9+
10+
// Layout constants mirror the on-chain decoders so the script extracts the
11+
// same Origin nonce the contract would.
12+
// Ref: contracts/strategies/crosschain/CrossChainStrategyHelper.sol
13+
// and AbstractCCTPIntegrator.sol
14+
const ORIGIN_MESSAGE_VERSION = 1010; // CrossChainStrategyHelper.ORIGIN_MESSAGE_VERSION
15+
const CCTP_MESSAGE_BODY_INDEX = 148; // CrossChainStrategyHelper.MESSAGE_BODY_INDEX
16+
const BURN_MESSAGE_V2_HOOK_DATA_INDEX = 228; // AbstractCCTPIntegrator.BURN_MESSAGE_V2_HOOK_DATA_INDEX
17+
// Origin message: 4 bytes version + 4 bytes type, then the abi-encoded payload
18+
const ORIGIN_PAYLOAD_INDEX = 8;
19+
20+
// Read a big-endian uint32 from a Uint8Array at the given byte offset.
21+
const readUint32 = (bytes, start) =>
22+
(bytes[start] << 24) |
23+
(bytes[start + 1] << 16) |
24+
(bytes[start + 2] << 8) |
25+
bytes[start + 3];
26+
27+
/**
28+
* Decode the Origin transfer nonce from a raw CCTP message, mirroring the
29+
* on-chain relay decoding. Returns the nonce as an ethers BigNumber, or null
30+
* if the message is not one of our Origin messages (deposit / withdraw /
31+
* balance check).
32+
*
33+
* The Origin message is either:
34+
* - the CCTP message body directly (plain message: withdraw / balance check), or
35+
* - the burn message hook data (deposit), located at byte 228 of the body.
36+
* The nonce is the first abi-encoded word of the Origin payload.
37+
*/
38+
const decodeOriginNonce = (messageHex) => {
39+
if (!messageHex) {
40+
return null;
41+
}
42+
const bytes = ethers.utils.arrayify(messageHex);
43+
if (bytes.length < CCTP_MESSAGE_BODY_INDEX + 4) {
44+
return null;
45+
}
46+
const body = bytes.slice(CCTP_MESSAGE_BODY_INDEX);
47+
48+
let originMessage;
49+
if (readUint32(body, 0) === ORIGIN_MESSAGE_VERSION) {
50+
// Plain message (withdraw / balance check): body is the Origin message
51+
originMessage = body;
52+
} else if (
53+
body.length >= BURN_MESSAGE_V2_HOOK_DATA_INDEX + 4 &&
54+
readUint32(body, BURN_MESSAGE_V2_HOOK_DATA_INDEX) === ORIGIN_MESSAGE_VERSION
55+
) {
56+
// Burn message (deposit): Origin message is the hook data
57+
originMessage = body.slice(BURN_MESSAGE_V2_HOOK_DATA_INDEX);
58+
} else {
59+
return null;
60+
}
61+
62+
if (originMessage.length < ORIGIN_PAYLOAD_INDEX + 32) {
63+
return null;
64+
}
65+
66+
// Nonce is the first 32-byte abi word of the payload (a left-padded uint64)
67+
const nonceWord = originMessage.slice(
68+
ORIGIN_PAYLOAD_INDEX,
69+
ORIGIN_PAYLOAD_INDEX + 32
70+
);
71+
return ethers.BigNumber.from(nonceWord);
72+
};
73+
774
const cctpOperationsConfig = async ({
875
destinationChainSigner,
976
sourceChainProvider,
@@ -15,6 +82,7 @@ const cctpOperationsConfig = async ({
1582
"event TokensBridged(uint32 peerDomainID,address peerStrategy,address usdcToken,uint256 tokenAmount,uint256 maxFee,uint32 minFinalityThreshold,bytes hookData)",
1683
"event MessageTransmitted(uint32 peerDomainID,address peerStrategy,uint32 minFinalityThreshold,bytes message)",
1784
"function relay(bytes message, bytes attestation) external",
85+
"function isNonceProcessed(uint64 nonce) view returns (bool)",
1886
];
1987

2088
const cctpIntegrationContractSource = new ethers.Contract(
@@ -157,6 +225,7 @@ const fetchTxHashesFromCctpTransactions = async ({
157225

158226
const processCctpBridgeTransactions = async ({
159227
block = undefined,
228+
txHash = undefined,
160229
dryrun = false,
161230
destinationChainSigner,
162231
sourceChainProvider,
@@ -168,6 +237,10 @@ const processCctpBridgeTransactions = async ({
168237
cctpIntegrationContractAddress,
169238
cctpIntegrationContractAddressDestination,
170239
}) => {
240+
// When a tx hash is passed we relay only that transaction (skipping the
241+
// recent-events scan) and bypass the local store dedup, since the operator
242+
// explicitly asked for it. On-chain isNonceProcessed is the real safety net.
243+
const manualRun = Boolean(txHash);
171244
const config = await cctpOperationsConfig({
172245
destinationChainSigner,
173246
sourceChainProvider,
@@ -181,19 +254,33 @@ const processCctpBridgeTransactions = async ({
181254
}`
182255
);
183256

184-
const { allTxHashes } = await fetchTxHashesFromCctpTransactions({
185-
config,
186-
overrideBlock: block,
187-
sourceChainProvider,
188-
blockLookback,
189-
});
257+
let allTxHashes;
258+
if (manualRun) {
259+
if (!TX_HASH_REGEX.test(txHash)) {
260+
throw new Error(`Invalid tx hash: ${txHash}`);
261+
}
262+
allTxHashes = [txHash.toLowerCase()];
263+
log(
264+
`Relaying only tx ${allTxHashes[0]} (manual). Skipping recent-events scan.`
265+
);
266+
} else {
267+
({ allTxHashes } = await fetchTxHashesFromCctpTransactions({
268+
config,
269+
overrideBlock: block,
270+
sourceChainProvider,
271+
blockLookback,
272+
}));
273+
}
190274
for (const txHash of allTxHashes) {
191275
const txStoreKey = `cctp_message_${txHash}_${cctpDestinationDomainId}`;
192276
// TODO: Legacy key can be removed after a few days of code deployment
193277
const txStoreKey_Legacy = `cctp_message_${txHash}`;
194278
const txStoredValue = await store.get(txStoreKey);
195279
const txStoredValue_Legacy = await store.get(txStoreKey_Legacy);
196-
if (txStoredValue === "processed" || txStoredValue_Legacy === "processed") {
280+
if (
281+
!manualRun &&
282+
(txStoredValue === "processed" || txStoredValue_Legacy === "processed")
283+
) {
197284
log(
198285
`Transaction with hash ${txHash} has already been processed via tx-level key ${txStoreKey}. Skipping...`
199286
);
@@ -242,13 +329,33 @@ const processCctpBridgeTransactions = async ({
242329
}
243330
hasEligibleMessage = true;
244331

245-
if (storedValue === "processed") {
332+
if (!manualRun && storedValue === "processed") {
246333
log(
247334
`Message with key ${storeKey} has already been processed. Skipping...`
248335
);
249336
continue;
250337
}
251338

339+
// Check on-chain whether this transfer nonce was already processed on the
340+
// destination strategy. If so, relaying would revert, so skip it and
341+
// reconcile the local store.
342+
const originNonce = decodeOriginNonce(cctpMessage.message);
343+
if (
344+
originNonce !== null &&
345+
(await config.cctpIntegrationContractDestination.isNonceProcessed(
346+
originNonce
347+
))
348+
) {
349+
log(
350+
`Nonce ${originNonce.toString()} for message ${messageId} from tx ${txHash} is already processed on-chain. Skipping relay...`
351+
);
352+
if (storedValue !== "processed") {
353+
await store.put(storeKey, "processed");
354+
log(`Marked message with key ${storeKey} as processed in store`);
355+
}
356+
continue;
357+
}
358+
252359
if (!cctpMessage.message || !cctpMessage.attestation) {
253360
log(
254361
`Message ${messageId} from tx ${txHash} is missing message payload or attestation. Skipping...`
@@ -303,4 +410,6 @@ const processCctpBridgeTransactions = async ({
303410

304411
module.exports = {
305412
processCctpBridgeTransactions,
413+
decodeOriginNonce,
414+
TX_HASH_REGEX,
306415
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const { expect } = require("chai");
2+
3+
const { decodeOriginNonce } = require("../../../tasks/crossChain");
4+
const {
5+
encodeCCTPMessage,
6+
encodeDepositMessageBody,
7+
encodeWithdrawMessageBody,
8+
encodeBurnMessageBody,
9+
encodeBalanceCheckMessageBody,
10+
} = require("./_crosschain-helpers");
11+
12+
// Unit test for the JS nonce decoder used by the CCTP relay actions. The
13+
// fixtures are built with the same encoders the on-chain contracts use, so the
14+
// decoder is exercised against production-shaped messages.
15+
describe("Unit: decodeOriginNonce (CCTP relay)", () => {
16+
const sourceDomain = 6; // Base
17+
const sender = "0x0000000000000000000000000000000000000001";
18+
const recipient = "0x0000000000000000000000000000000000000002";
19+
const usdc = "0x0000000000000000000000000000000000000003";
20+
const amount = "1000000"; // 1 USDC
21+
22+
it("decodes nonce from a deposit (burn message with hook data)", () => {
23+
const nonce = 7;
24+
const hookData = encodeDepositMessageBody(nonce, amount);
25+
const burnBody = encodeBurnMessageBody(
26+
sender,
27+
recipient,
28+
usdc,
29+
amount,
30+
hookData
31+
);
32+
const message = encodeCCTPMessage(
33+
sourceDomain,
34+
sender,
35+
recipient,
36+
burnBody
37+
);
38+
39+
expect(decodeOriginNonce(message).toNumber()).to.eq(nonce);
40+
});
41+
42+
it("decodes nonce from a withdraw (plain message)", () => {
43+
const nonce = 42;
44+
const body = encodeWithdrawMessageBody(nonce, amount);
45+
const message = encodeCCTPMessage(sourceDomain, sender, recipient, body);
46+
47+
expect(decodeOriginNonce(message).toNumber()).to.eq(nonce);
48+
});
49+
50+
it("decodes nonce from a balance check (plain message)", () => {
51+
const nonce = 123;
52+
const body = encodeBalanceCheckMessageBody(nonce, amount, true, 1700000000);
53+
const message = encodeCCTPMessage(sourceDomain, sender, recipient, body);
54+
55+
expect(decodeOriginNonce(message).toNumber()).to.eq(nonce);
56+
});
57+
58+
it("returns null for a non-Origin message body", () => {
59+
// Version != 1010 and too short to be a burn message with Origin hook data
60+
const body = "0xdeadbeef00000000";
61+
const message = encodeCCTPMessage(sourceDomain, sender, recipient, body);
62+
63+
expect(decodeOriginNonce(message)).to.eq(null);
64+
});
65+
66+
it("returns null for empty or missing input", () => {
67+
expect(decodeOriginNonce(undefined)).to.eq(null);
68+
expect(decodeOriginNonce("0x")).to.eq(null);
69+
});
70+
});

0 commit comments

Comments
 (0)