From 480f7443740ef745c819dec0eff308d8902da8a2 Mon Sep 17 00:00:00 2001 From: droplet-rl <284132418+droplet-rl@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:14:32 +0000 Subject: [PATCH] fix(fx-tunnel-relayer): relay all MessageSent events in a transaction A Polygon transaction bridging multiple price requests emits multiple MessageSent events, but the exit proof was always built for the first matching log (matic.js buildPayloadForExit defaults to index 0). Every event in such a transaction produced a proof for message #1; the first relay succeeded and the rest reverted with EXIT_ALREADY_PROCESSED, which was swallowed silently, so the remaining messages never reached the DVM. Compute each event's position among the MessageSent logs in its transaction receipt and prove exactly that log, and log skips caused by EXIT_ALREADY_PROCESSED at debug level instead of returning silently. Fixes #4962 Co-Authored-By: Claude Fable 5 Signed-off-by: droplet-rl <284132418+droplet-rl@users.noreply.github.com> --- packages/fx-tunnel-relayer/src/Relayer.ts | 43 +++++++++++++++-- packages/fx-tunnel-relayer/test/Relayer.ts | 55 +++++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/fx-tunnel-relayer/src/Relayer.ts b/packages/fx-tunnel-relayer/src/Relayer.ts index 488e0a2016..7fd6f9d8d7 100644 --- a/packages/fx-tunnel-relayer/src/Relayer.ts +++ b/packages/fx-tunnel-relayer/src/Relayer.ts @@ -54,8 +54,29 @@ export class Relayer { // For any found events, grab its block number and check whether it has been checkpointed yet to the // CheckpointManager on Ethereum. if (messageSentEvents.length > 0) { + // A single Polygon transaction can contain multiple MessageSent events, e.g. two price requests bridged + // atomically. matic.js builds the exit proof for the i-th log matching the MessageSent signature within the + // transaction (defaulting to the first one), so each event must be proven at its own index or every event in + // the transaction would produce a proof for the first message and the rest would never be relayed. + const messageSentLogIndicesByTxHash: { [transactionHash: string]: number[] } = {}; for (const e of messageSentEvents) { - await this._relayMessage(e); + if (messageSentLogIndicesByTxHash[e.transactionHash] === undefined) { + const receipt = await this.web3.eth.getTransactionReceipt(e.transactionHash); + messageSentLogIndicesByTxHash[e.transactionHash] = receipt.logs + .filter((log) => log.topics.length > 0 && log.topics[0].toLowerCase() === POLYGON_MESSAGE_SENT_EVENT_SIG) + .map((log) => log.logIndex); + } + const messageIndex = messageSentLogIndicesByTxHash[e.transactionHash].indexOf(e.logIndex); + if (messageIndex === -1) { + this.logger.error({ + at: "Relayer#relayMessage", + message: "Could not find MessageSent event's log index in its transaction receipt 📛", + transactionHash: e.transactionHash, + logIndex: e.logIndex, + }); + continue; + } + await this._relayMessage(e, messageIndex); } } else { this.logger.debug({ @@ -68,8 +89,9 @@ export class Relayer { // First check if the transaction hash corresponding to the MessageSent event has been checkpointed to Ethereum // Mainnet yet. If it has, then derive a Polygon-specific proof for it and execute OracleRootTunnel.receiveMessage - // by passing in the proof as input. - async _relayMessage(messageEvent: EventData): Promise { + // by passing in the proof as input. `messageIndex` is the position of this event among all logs matching the + // MessageSent signature in the transaction, used to prove the correct log when a transaction contains several. + async _relayMessage(messageEvent: EventData, messageIndex: number): Promise { const transactionHash = messageEvent.transactionHash; const blockNumber = messageEvent.blockNumber; @@ -91,6 +113,7 @@ export class Relayer { message: "Deriving proof for transaction that emitted MessageSent", transactionHash: transactionHash, blockNumber, + messageIndex, }); let chainBlockInfo; // Only used for debugging purposes upon error. @@ -102,7 +125,8 @@ export class Relayer { proof = await this.maticPosClient.exitUtil.buildPayloadForExit( transactionHash, POLYGON_MESSAGE_SENT_EVENT_SIG, // SEND_MESSAGE_EVENT_SIG, do not change - false + false, + messageIndex ); if (!proof) throw new Error("Proof construction succeeded but returned undefined"); } catch (error) { @@ -135,10 +159,19 @@ export class Relayer { message: "Submitted relay proof!🕴🏼", tx: transactionHash, messageEvent, + messageIndex, }); } catch (error) { // If the proof was already submitted, then don't emit an error level log. - if ((error as Error)?.message.includes("EXIT_ALREADY_PROCESSED")) return; + if ((error as Error)?.message.includes("EXIT_ALREADY_PROCESSED")) { + this.logger.debug({ + at: "Relayer#relayMessage", + message: "Exit proof already processed by root tunnel, skipping", + transactionHash, + messageIndex, + }); + return; + } this.logger.error({ at: "Relayer#relayMessage", message: "Failed to submit proof to root tunnel🚨", diff --git a/packages/fx-tunnel-relayer/test/Relayer.ts b/packages/fx-tunnel-relayer/test/Relayer.ts index 2cdf294a4e..7efcf600bd 100644 --- a/packages/fx-tunnel-relayer/test/Relayer.ts +++ b/packages/fx-tunnel-relayer/test/Relayer.ts @@ -11,6 +11,7 @@ const { getContract, deployments, web3 } = require("hardhat"); const { utf8ToHex } = web3.utils; const Finder = getContract("Finder"); +const Multicall = getContract("Multicall3"); const OracleChildTunnel = getContract("OracleChildTunnel"); const Registry = getContract("Registry"); const OracleRootTunnel = getContract("OracleRootTunnelMock"); @@ -20,7 +21,7 @@ const FxChild = getContract("FxChildMock"); const FxRoot = getContract("FxRootMock"); // This function should return a bytes string. -type customPayloadFn = () => Promise; +type customPayloadFn = (burnTxHash?: string, logEventSig?: string, isFast?: boolean, index?: number) => Promise; interface MaticPosClient { exitUtil: { buildPayloadForExit: customPayloadFn; @@ -141,6 +142,58 @@ describe("Relayer unit tests", function () { assert.equal(nonDebugEvents.length, 1); assert.isTrue(lastSpyLogIncludes(spy, "Submitted relay proof")); }); + it("relays all messages when a transaction contains multiple MessageSent events", async function () { + // Bundle two price requests into a single transaction via a registered multicall contract so that one + // transaction emits two MessageSent events. + const multicall = await Multicall.new().send({ from: owner }); + await registry.methods.registerContract([], multicall.options.address).send({ from: owner }); + await multicall.methods + .aggregate([ + [ + oracleChild.options.address, + oracleChild.methods.requestPrice(testIdentifier, testTimestamp, utf8ToHex("request:1")).encodeABI(), + ], + [ + oracleChild.options.address, + oracleChild.methods.requestPrice(testIdentifier, testTimestamp, utf8ToHex("request:2")).encodeABI(), + ], + ]) + .send({ from: owner }); + const eventsEmitted = await oracleChild.getPastEvents("MessageSent", { fromBlock: 0 }); + assert.equal(eventsEmitted.length, 2); + assert.equal(eventsEmitted[0].transactionHash, eventsEmitted[1].transactionHash); + + // Record the message index passed to the proof builder for each relayed message: each message must be proven + // at its own position among the transaction's MessageSent logs, not just the first one. + const provenIndices: (number | undefined)[] = []; + const _maticPosClient: MaticPosClient = { + exitUtil: { + buildPayloadForExit: async (_burnTxHash?: string, _logEventSig?: string, _isFast?: boolean, index?: number) => { + provenIndices.push(index); + return utf8ToHex("Test proof"); + }, + isCheckPointed: async () => new Promise((resolve) => resolve(true)), + getChainBlockInfo: async () => new Promise((resolve) => resolve({ lastChildBlock: 0, txBlockNumber: 0 })), + }, + }; + const _relayer: any = new Relayer( + spyLogger, + owner, + gasEstimator, + _maticPosClient, + oracleChild, + oracleRoot, + web3, + 0, + 100 + ); + await _relayer.fetchAndRelayMessages(); + + assert.deepEqual(provenIndices, [0, 1]); + const infoEvents = spy.getCalls().filter((log: any) => log.lastArg.level === "info"); + assert.equal(infoEvents.length, 2); + assert.isTrue(lastSpyLogIncludes(spy, "Submitted relay proof")); + }); it("ignores events older than earliest polygon block to query", async function () { const txn = await oracleChild.methods .requestPrice(testIdentifier, testTimestamp, testAncillaryData)