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(); 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