@@ -4,6 +4,73 @@ const { logTxDetails } = require("../utils/txLogger");
44const { api : cctpApi } = require ( "../utils/cctp" ) ;
55const log = require ( "../utils/logger" ) ( "task:crossChain" ) ;
66
7+ // 0x-prefixed 32-byte tx hash
8+ const TX_HASH_REGEX = / ^ 0 x ( [ A - F a - f 0 - 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+
774const 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
158226const 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
304411module . exports = {
305412 processCctpBridgeTransactions,
413+ decodeOriginNonce,
414+ TX_HASH_REGEX ,
306415} ;
0 commit comments