From 219c222f3e215040136adfc13e857c4765f9e1e8 Mon Sep 17 00:00:00 2001 From: buy doge Date: Thu, 21 May 2026 01:12:02 +0900 Subject: [PATCH 1/2] test: strengthen cross-chain strategy invariants --- ...chain-master-strategy.mainnet.fork-test.js | 24 ++++++++++++++++++- ...osschain-remote-strategy.base.fork-test.js | 2 ++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js b/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js index 9a96415eca..22cb775f0d 100644 --- a/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js +++ b/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js @@ -70,11 +70,18 @@ describe("ForkTest: CrossChainMasterStrategy", function () { const strategyBalanceAfter = await crossChainMasterStrategy.checkBalance( usdc.address ); + // Pending bridged deposits remain counted in the master strategy's total + // value until the remote confirmation updates the cached remote balance. expect(strategyBalanceAfter).to.eq(strategyBalanceBefore); + const lastTransferNonce = await crossChainMasterStrategy.lastTransferNonce(); expect(await crossChainMasterStrategy.pendingAmount()).to.eq( usdcUnits("1000") ); + expect(await crossChainMasterStrategy.isTransferPending()).to.eq(true); + expect( + await crossChainMasterStrategy.isNonceProcessed(lastTransferNonce) + ).to.eq(false); // Check for message sent event const receipt = await tx.wait(); @@ -262,6 +269,11 @@ describe("ForkTest: CrossChainMasterStrategy", function () { payload ); + expect(await crossChainMasterStrategy.pendingAmount()).to.eq( + usdcUnits("1000") + ); + expect(await crossChainMasterStrategy.isTransferPending()).to.eq(true); + // Relay the message with fake attestation await crossChainMasterStrategy.connect(relayer).relay(message, "0x"); @@ -273,6 +285,10 @@ describe("ForkTest: CrossChainMasterStrategy", function () { expect(await crossChainMasterStrategy.pendingAmount()).to.eq( usdcUnits("0") ); + expect(await crossChainMasterStrategy.isTransferPending()).to.eq(false); + expect(await crossChainMasterStrategy.checkBalance(usdc.address)).to.eq( + usdcUnits("10000") + ); }); it("Should accept tokens for a pending withdrawal", async function () { @@ -391,7 +407,12 @@ describe("ForkTest: CrossChainMasterStrategy", function () { // Relay the message with fake attestation await crossChainMasterStrategy.connect(relayer).relay(message, "0x"); - // Should've ignore the message + // The out-of-order non-confirmation balance update must not clear the + // pending transfer or cached remote balance. pendingAmount tracks pending + // deposits only, so a pending withdrawal keeps it at zero. + expect(await crossChainMasterStrategy.pendingAmount()).to.eq(0); + expect(await crossChainMasterStrategy.isTransferPending()).to.eq(true); + const remoteStrategyBalance = await crossChainMasterStrategy.remoteStrategyBalance(); expect(remoteStrategyBalance).to.eq(remoteStrategyBalanceBefore); @@ -493,6 +514,7 @@ describe("ForkTest: CrossChainMasterStrategy", function () { const remoteStrategyBalanceAfter = await crossChainMasterStrategy.remoteStrategyBalance(); expect(remoteStrategyBalanceAfter).to.eq(remoteStrategyBalanceBefore); + expect(await crossChainMasterStrategy.isTransferPending()).to.eq(false); }); it("Should revert if the burn token is not peer USDC", async function () { diff --git a/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js b/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js index b2ed8d3a15..4e3c78c360 100644 --- a/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js +++ b/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js @@ -176,6 +176,7 @@ describe("ForkTest: CrossChainRemoteStrategy", function () { const nonceAfter = await crossChainRemoteStrategy.lastTransferNonce(); expect(nonceAfter).to.eq(nextNonce); + expect(await crossChainRemoteStrategy.isTransferPending()).to.eq(false); const balanceAfter = await crossChainRemoteStrategy.checkBalance( usdc.address @@ -242,6 +243,7 @@ describe("ForkTest: CrossChainRemoteStrategy", function () { const nonceAfter = await crossChainRemoteStrategy.lastTransferNonce(); expect(nonceAfter).to.eq(nextNonce); + expect(await crossChainRemoteStrategy.isTransferPending()).to.eq(false); const balanceAfter = await crossChainRemoteStrategy.checkBalance( usdc.address From 3456ed143d49ac87dbbc8175c8308883f9c28806 Mon Sep 17 00:00:00 2001 From: m0n3r0 <34853915+m0n3r0@users.noreply.github.com> Date: Sat, 23 May 2026 00:24:11 +0900 Subject: [PATCH 2/2] test: cover CCTP replay rejection in cross-chain mock --- .../crosschain/CCTPMessageTransmitterMock.sol | 8 +++- .../crosschain/cross-chain-strategy.js | 38 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol index a48667f974..964414f1a5 100644 --- a/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol +++ b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol @@ -44,6 +44,8 @@ contract CCTPMessageTransmitterMock is ICCTPMessageTransmitter { Message[] public messages; // map of encoded messages to the corresponding message structs mapping(bytes32 => Message) public encodedMessages; + // tracks relayed messages to mirror CCTP replay protection in tests + mapping(bytes32 => bool) public processedMessages; constructor(address _usdc) { usdc = IERC20(_usdc); @@ -120,7 +122,11 @@ contract CCTPMessageTransmitterMock is ICCTPMessageTransmitter { override returns (bool) { - Message memory storedMsg = encodedMessages[keccak256(message)]; + bytes32 messageHash = keccak256(message); + require(!processedMessages[messageHash], "Message already processed"); + processedMessages[messageHash] = true; + + Message memory storedMsg = encodedMessages[messageHash]; AbstractCCTPIntegrator recipient = AbstractCCTPIntegrator( address(uint160(uint256(storedMsg.recipient))) ); diff --git a/contracts/test/strategies/crosschain/cross-chain-strategy.js b/contracts/test/strategies/crosschain/cross-chain-strategy.js index 7f61f5d457..8f19a9c306 100644 --- a/contracts/test/strategies/crosschain/cross-chain-strategy.js +++ b/contracts/test/strategies/crosschain/cross-chain-strategy.js @@ -7,7 +7,10 @@ const { const { setERC20TokenBalance } = require("../../_fund"); const { units, usdcUnits } = require("../../helpers"); const { impersonateAndFund } = require("../../../utils/signers"); -const { encodeBalanceCheckMessageBody } = require("./_crosschain-helpers"); +const { + encodeBalanceCheckMessageBody, + encodeCCTPMessage, +} = require("./_crosschain-helpers"); const loadFixture = createFixtureLoader(crossChainFixtureUnit); const DAY_IN_SECONDS = 86400; @@ -456,6 +459,39 @@ describe("ForkTest: CrossChainRemoteStrategy", function () { await assertVaultTotalValue("1000"); }); + it("Should reject direct replay of an already-relayed balance update message", async function () { + const { messageTransmitter } = fixture; + await sendBalanceUpdateToMaster(); + + const nonce = await crossChainMasterStrategy.lastTransferNonce(); + const balance = await crossChainRemoteStrategy.checkBalance(usdc.address); + const latestBlock = await ethers.provider.getBlock("latest"); + const body = encodeBalanceCheckMessageBody( + nonce, + balance, + false, + latestBlock.timestamp + ); + const encodedMessage = encodeCCTPMessage( + await crossChainMasterStrategy.peerDomainID(), + crossChainRemoteStrategy.address, + crossChainMasterStrategy.address, + body + ); + + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(balance); + await expect(await messageTransmitter.messagesInQueue()).to.eq(0); + + const transmitterSigner = await impersonateAndFund(messageTransmitter.address); + await expect( + crossChainMasterStrategy + .connect(transmitterSigner) + .relay(encodedMessage, "0x") + ).to.be.revertedWith("Message already processed"); + }); + it("Should emit a BalanceCheckIgnored event if balance update message is too old", async function () { const { messageTransmitter, crossChainMasterStrategy } = fixture; await sendBalanceUpdateToMaster();