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)