diff --git a/packages/contracts-rfq/contracts/Admin.sol b/packages/contracts-rfq/contracts/Admin.sol index 881b5bea6d..ffb352b28a 100644 --- a/packages/contracts-rfq/contracts/Admin.sol +++ b/packages/contracts-rfq/contracts/Admin.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; diff --git a/packages/contracts-rfq/contracts/FastBridgeV2.sol b/packages/contracts-rfq/contracts/FastBridgeV2.sol new file mode 100644 index 0000000000..7f938f28f1 --- /dev/null +++ b/packages/contracts-rfq/contracts/FastBridgeV2.sol @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./libs/Errors.sol"; +import {UniversalTokenLib} from "./libs/UniversalToken.sol"; + +import {Admin} from "./Admin.sol"; +import {IFastBridge} from "./interfaces/IFastBridge.sol"; +import {IFastBridgeV2} from "./interfaces/IFastBridgeV2.sol"; + +contract FastBridgeV2 is Admin, IFastBridgeV2 { + using SafeERC20 for IERC20; + using UniversalTokenLib for address; + + /// @notice Dispute period for relayed transactions + uint256 public constant DISPUTE_PERIOD = 30 minutes; + + /// @notice Delay for a transaction after which it could be permisionlessly refunded + uint256 public constant REFUND_DELAY = 7 days; + + /// @notice Minimum deadline period to relay a requested bridge transaction + uint256 public constant MIN_DEADLINE_PERIOD = 30 minutes; + + enum BridgeStatus { + NULL, // doesn't exist yet + REQUESTED, + RELAYER_PROVED, + RELAYER_CLAIMED, + REFUNDED + } + + /// @notice Status of the bridge tx on origin chain + mapping(bytes32 => BridgeStatus) public bridgeStatuses; + /// @notice Proof of relayed bridge tx on origin chain + mapping(bytes32 => BridgeProof) public bridgeProofs; + /// @notice Whether bridge has been relayed on destination chain + mapping(bytes32 => bool) public bridgeRelays; + + /// @dev to prevent replays + uint256 public nonce; + // @dev the block the contract was deployed at + uint256 public immutable deployBlock; + + constructor(address _owner) Admin(_owner) { + deployBlock = block.number; + } + + /// @notice Pulls a requested token from the user to the requested recipient. + /// @dev Be careful of re-entrancy issues when msg.value > 0 and recipient != address(this) + function _pullToken(address recipient, address token, uint256 amount) internal returns (uint256 amountPulled) { + if (token != UniversalTokenLib.ETH_ADDRESS) { + token.assertIsContract(); + // Record token balance before transfer + amountPulled = IERC20(token).balanceOf(recipient); + // Token needs to be pulled only if msg.value is zero + // This way user can specify WETH as the origin asset + IERC20(token).safeTransferFrom(msg.sender, recipient, amount); + // Use the difference between the recorded balance and the current balance as the amountPulled + amountPulled = IERC20(token).balanceOf(recipient) - amountPulled; + } else { + // Otherwise, we need to check that ETH amount matches msg.value + if (amount != msg.value) revert MsgValueIncorrect(); + // Transfer value to recipient if not this address + if (recipient != address(this)) token.universalTransfer(recipient, amount); + // We will forward msg.value in the external call later, if recipient is not this contract + amountPulled = msg.value; + } + } + + /// @inheritdoc IFastBridge + function getBridgeTransaction(bytes memory request) public pure returns (BridgeTransaction memory) { + return abi.decode(request, (BridgeTransaction)); + } + + /// @inheritdoc IFastBridge + function bridge(BridgeParams memory params) external payable { + // check bridge params + if (params.dstChainId == block.chainid) revert ChainIncorrect(); + if (params.originAmount == 0 || params.destAmount == 0) revert AmountIncorrect(); + if (params.originToken == address(0) || params.destToken == address(0)) revert ZeroAddress(); + if (params.deadline < block.timestamp + MIN_DEADLINE_PERIOD) revert DeadlineTooShort(); + + // transfer tokens to bridge contract + // @dev use returned originAmount in request in case of transfer fees + uint256 originAmount = _pullToken(address(this), params.originToken, params.originAmount); + + // track amount of origin token owed to protocol + uint256 originFeeAmount; + if (protocolFeeRate > 0) originFeeAmount = (originAmount * protocolFeeRate) / FEE_BPS; + originAmount -= originFeeAmount; // remove from amount used in request as not relevant for relayers + + // set status to requested + bytes memory request = abi.encode( + BridgeTransaction({ + originChainId: uint32(block.chainid), + destChainId: params.dstChainId, + originSender: params.sender, + destRecipient: params.to, + originToken: params.originToken, + destToken: params.destToken, + originAmount: originAmount, + destAmount: params.destAmount, + originFeeAmount: originFeeAmount, + sendChainGas: params.sendChainGas, + deadline: params.deadline, + nonce: nonce++ // increment nonce on every bridge + }) + ); + bytes32 transactionId = keccak256(request); + bridgeStatuses[transactionId] = BridgeStatus.REQUESTED; + + emit BridgeRequested( + transactionId, + params.sender, + request, + params.dstChainId, + params.originToken, + params.destToken, + originAmount, + params.destAmount, + params.sendChainGas + ); + } + + /// @inheritdoc IFastBridge + function relay(bytes memory request) external payable { + relay(request, msg.sender); + } + + /// @inheritdoc IFastBridgeV2 + function relay(bytes memory request, address relayer) public payable { + bytes32 transactionId = keccak256(request); + BridgeTransaction memory transaction = getBridgeTransaction(request); + if (transaction.destChainId != uint32(block.chainid)) revert ChainIncorrect(); + + // check haven't exceeded deadline for relay to happen + if (block.timestamp > transaction.deadline) revert DeadlineExceeded(); + + // mark bridge transaction as relayed + if (bridgeRelays[transactionId]) revert TransactionRelayed(); + bridgeRelays[transactionId] = true; + + // transfer tokens to recipient on destination chain and gas rebate if requested + address to = transaction.destRecipient; + address token = transaction.destToken; + uint256 amount = transaction.destAmount; + + uint256 rebate = chainGasAmount; + if (!transaction.sendChainGas) { + // forward erc20 + rebate = 0; + _pullToken(to, token, amount); + } else if (token == UniversalTokenLib.ETH_ADDRESS) { + // lump in gas rebate into amount in native gas token + _pullToken(to, token, amount + rebate); + } else { + // forward erc20 then forward gas rebate in native gas token + _pullToken(to, token, amount); + _pullToken(to, UniversalTokenLib.ETH_ADDRESS, rebate); + } + + emit BridgeRelayed( + transactionId, + relayer, + to, + transaction.originChainId, + transaction.originToken, + transaction.destToken, + transaction.originAmount, + transaction.destAmount, + rebate + ); + } + + /// @inheritdoc IFastBridge + function prove(bytes memory request, bytes32 destTxHash) external { + prove(request, destTxHash, msg.sender); + } + + /// @inheritdoc IFastBridgeV2 + function prove(bytes memory request, bytes32 destTxHash, address relayer) public onlyRole(RELAYER_ROLE) { + bytes32 transactionId = keccak256(request); + // update bridge tx status given proof provided + if (bridgeStatuses[transactionId] != BridgeStatus.REQUESTED) revert StatusIncorrect(); + bridgeStatuses[transactionId] = BridgeStatus.RELAYER_PROVED; + bridgeProofs[transactionId] = BridgeProof({timestamp: uint96(block.timestamp), relayer: relayer}); // overflow ok + + emit BridgeProofProvided(transactionId, relayer, destTxHash); + } + + /// @notice Calculates time since proof submitted + /// @dev proof.timestamp stores casted uint96(block.timestamp) block timestamps for gas optimization + /// _timeSince(proof) can accomodate rollover case when block.timestamp > type(uint96).max but + /// proof.timestamp < type(uint96).max via unchecked statement + /// @param proof The bridge proof + /// @return delta Time delta since proof submitted + function _timeSince(BridgeProof memory proof) internal view returns (uint256 delta) { + unchecked { + delta = uint96(block.timestamp) - proof.timestamp; + } + } + + /// @inheritdoc IFastBridge + function canClaim(bytes32 transactionId, address relayer) external view returns (bool) { + if (bridgeStatuses[transactionId] != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect(); + BridgeProof memory proof = bridgeProofs[transactionId]; + if (proof.relayer != relayer) revert SenderIncorrect(); + return _timeSince(proof) > DISPUTE_PERIOD; + } + + /// @inheritdoc IFastBridgeV2 + function claim(bytes memory request) external { + claim(request, address(0)); + } + + /// @inheritdoc IFastBridge + function claim(bytes memory request, address to) public { + bytes32 transactionId = keccak256(request); + BridgeTransaction memory transaction = getBridgeTransaction(request); + + // update bridge tx status if able to claim origin collateral + if (bridgeStatuses[transactionId] != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect(); + + BridgeProof memory proof = bridgeProofs[transactionId]; + + // if "to" is zero addr, permissionlessly send funds to proven relayer + if (to == address(0)) { + to = proof.relayer; + } else if (proof.relayer != msg.sender) { + revert SenderIncorrect(); + } + + if (_timeSince(proof) <= DISPUTE_PERIOD) revert DisputePeriodNotPassed(); + + bridgeStatuses[transactionId] = BridgeStatus.RELAYER_CLAIMED; + + // update protocol fees if origin fee amount exists + if (transaction.originFeeAmount > 0) protocolFees[transaction.originToken] += transaction.originFeeAmount; + + // transfer origin collateral less fee to specified address + address token = transaction.originToken; + uint256 amount = transaction.originAmount; + token.universalTransfer(to, amount); + + emit BridgeDepositClaimed(transactionId, proof.relayer, to, token, amount); + } + + /// @inheritdoc IFastBridge + function dispute(bytes32 transactionId) external onlyRole(GUARD_ROLE) { + if (bridgeStatuses[transactionId] != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect(); + if (_timeSince(bridgeProofs[transactionId]) > DISPUTE_PERIOD) revert DisputePeriodPassed(); + + // @dev relayer gets slashed effectively if dest relay has gone thru + bridgeStatuses[transactionId] = BridgeStatus.REQUESTED; + delete bridgeProofs[transactionId]; + + emit BridgeProofDisputed(transactionId, msg.sender); + } + + /// @inheritdoc IFastBridge + function refund(bytes memory request) external { + bytes32 transactionId = keccak256(request); + BridgeTransaction memory transaction = getBridgeTransaction(request); + + if (hasRole(REFUNDER_ROLE, msg.sender)) { + // Refunder can refund if deadline has passed + if (block.timestamp <= transaction.deadline) revert DeadlineNotExceeded(); + } else { + // Permissionless refund is allowed after REFUND_DELAY + if (block.timestamp <= transaction.deadline + REFUND_DELAY) revert DeadlineNotExceeded(); + } + + // set status to refunded if still in requested state + if (bridgeStatuses[transactionId] != BridgeStatus.REQUESTED) revert StatusIncorrect(); + bridgeStatuses[transactionId] = BridgeStatus.REFUNDED; + + // transfer origin collateral back to original sender + address to = transaction.originSender; + address token = transaction.originToken; + uint256 amount = transaction.originAmount + transaction.originFeeAmount; + token.universalTransfer(to, amount); + + emit BridgeDepositRefunded(transactionId, to, token, amount); + } +} diff --git a/packages/contracts-rfq/contracts/interfaces/IAdmin.sol b/packages/contracts-rfq/contracts/interfaces/IAdmin.sol index 10d866d499..c6f398191d 100644 --- a/packages/contracts-rfq/contracts/interfaces/IAdmin.sol +++ b/packages/contracts-rfq/contracts/interfaces/IAdmin.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.20; interface IAdmin { // ============ Events ============ diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridge.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridge.sol index 0176d557bb..b691dfb5b4 100644 --- a/packages/contracts-rfq/contracts/interfaces/IFastBridge.sol +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridge.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.20; interface IFastBridge { struct BridgeTransaction { diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol new file mode 100644 index 0000000000..979551919a --- /dev/null +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IFastBridge} from "./IFastBridge.sol"; + +interface IFastBridgeV2 is IFastBridge { + + /// @notice Relays destination side of bridge transaction by off-chain relayer + /// @param request The encoded bridge transaction to relay on destination chain + /// @param relayer The address of the relaying entity which should have control of the origin funds when claimed + function relay(bytes memory request, address relayer) external payable; + + /// @notice Provides proof on origin side that relayer provided funds on destination side of bridge transaction + /// @param request The encoded bridge transaction to prove on origin chain + /// @param destTxHash The destination tx hash proving bridge transaction was relayed + /// @param relayer The address of the relaying entity which should have control of the origin funds when claimed + function prove(bytes memory request, bytes32 destTxHash, address relayer) external; + + /// @notice Completes bridge transaction on origin chain by claiming originally deposited capital. Can only send funds to the relayer address on the proof. + /// @param request The encoded bridge transaction to claim on origin chain + function claim(bytes memory request) external; + +} diff --git a/packages/contracts-rfq/contracts/interfaces/IMulticallTarget.sol b/packages/contracts-rfq/contracts/interfaces/IMulticallTarget.sol index 1f48e59609..d112057ad6 100644 --- a/packages/contracts-rfq/contracts/interfaces/IMulticallTarget.sol +++ b/packages/contracts-rfq/contracts/interfaces/IMulticallTarget.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.20; /// @notice Interface for a contract that can be called multiple times by the same caller. Inspired by MulticallV3: /// https://github.com/mds1/multicall/blob/master/src/Multicall3.sol diff --git a/packages/contracts-rfq/contracts/libs/Errors.sol b/packages/contracts-rfq/contracts/libs/Errors.sol index f2efb94304..b165dd46d4 100644 --- a/packages/contracts-rfq/contracts/libs/Errors.sol +++ b/packages/contracts-rfq/contracts/libs/Errors.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity ^0.8.20; error DeadlineExceeded(); error DeadlineNotExceeded(); diff --git a/packages/contracts-rfq/contracts/libs/UniversalToken.sol b/packages/contracts-rfq/contracts/libs/UniversalToken.sol index 98e6de0325..c57bf141e0 100644 --- a/packages/contracts-rfq/contracts/libs/UniversalToken.sol +++ b/packages/contracts-rfq/contracts/libs/UniversalToken.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import {TokenNotContract} from "./Errors.sol"; diff --git a/packages/contracts-rfq/contracts/utils/MulticallTarget.sol b/packages/contracts-rfq/contracts/utils/MulticallTarget.sol index bed9266c33..d259b6c3db 100644 --- a/packages/contracts-rfq/contracts/utils/MulticallTarget.sol +++ b/packages/contracts-rfq/contracts/utils/MulticallTarget.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.20; import {IMulticallTarget} from "../interfaces/IMulticallTarget.sol"; diff --git a/packages/contracts-rfq/script/ConfigureFastBridge.s.sol b/packages/contracts-rfq/script/ConfigureFastBridge.s.sol index 4349fae615..4be61d98b1 100644 --- a/packages/contracts-rfq/script/ConfigureFastBridge.s.sol +++ b/packages/contracts-rfq/script/ConfigureFastBridge.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import {FastBridge} from "../contracts/FastBridge.sol"; diff --git a/packages/contracts-rfq/script/DeployFastBridge.CREATE2.s.sol b/packages/contracts-rfq/script/DeployFastBridge.CREATE2.s.sol index 3369914b69..5d42a636b3 100644 --- a/packages/contracts-rfq/script/DeployFastBridge.CREATE2.s.sol +++ b/packages/contracts-rfq/script/DeployFastBridge.CREATE2.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import {FastBridge} from "../contracts/FastBridge.sol"; diff --git a/packages/contracts-rfq/test/FastBridge.t.sol b/packages/contracts-rfq/test/FastBridge.t.sol index 264ebe3b79..2d90779c96 100644 --- a/packages/contracts-rfq/test/FastBridge.t.sol +++ b/packages/contracts-rfq/test/FastBridge.t.sol @@ -28,15 +28,19 @@ contract FastBridgeTest is Test { MockERC20 arbUSDC; MockERC20 ethUSDC; - function setUp() public { + function setUp() public virtual { vm.chainId(42_161); - fastBridge = new FastBridge(owner); + fastBridge = FastBridge(deployFastBridge()); arbUSDC = new MockERC20("arbUSDC", 6); ethUSDC = new MockERC20("ethUSDC", 6); _mintTokensToActors(); } - function _mintTokensToActors() internal { + function deployFastBridge() internal virtual returns (address) { + return address(new FastBridge(owner)); + } + + function _mintTokensToActors() internal virtual { arbUSDC.mint(relayer, 100 * 10 ** 6); arbUSDC.mint(guard, 100 * 10 ** 6); arbUSDC.mint(user, 100 * 10 ** 6); @@ -1297,7 +1301,7 @@ contract FastBridgeTest is Test { vm.stopPrank(); } - function test_failedRelayNotRelayer() public { + function test_failedRelayNotRelayer() public virtual { // Set up the roles for the test setUpRoles(); @@ -1642,7 +1646,7 @@ contract FastBridgeTest is Test { vm.stopPrank(); } - function test_failedClaimNotOldRelayer() public { + function test_failedClaimNotOldRelayer() public virtual { setUpRoles(); test_successfulBridge(); @@ -1679,7 +1683,7 @@ contract FastBridgeTest is Test { vm.stopPrank(); } - function test_failedClaimNotRelayer() public { + function test_failedClaimNotRelayer() public virtual { setUpRoles(); test_successfulRelayProof(); diff --git a/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol new file mode 100644 index 0000000000..926f05c8c0 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ChainIncorrect, DeadlineExceeded, TransactionRelayed} from "../contracts/libs/Errors.sol"; + +import {FastBridgeV2, FastBridgeV2Test, IFastBridge} from "./FastBridgeV2.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2DstTest is FastBridgeV2Test { + event BridgeRelayed( + bytes32 indexed transactionId, + address indexed relayer, + address indexed to, + uint32 originChainId, + address originToken, + address destToken, + uint256 originAmount, + uint256 destAmount, + uint256 chainGasAmount + ); + + uint256 public constant LEFTOVER_BALANCE = 1 ether; + + function setUp() public override { + vm.chainId(DST_CHAIN_ID); + super.setUp(); + } + + function deployFastBridge() public override returns (FastBridgeV2) { + return new FastBridgeV2(address(this)); + } + + function mintTokens() public override { + dstToken.mint(address(relayerA), LEFTOVER_BALANCE + tokenParams.destAmount); + deal(relayerB, LEFTOVER_BALANCE + ethParams.destAmount); + vm.prank(relayerA); + dstToken.approve(address(fastBridge), type(uint256).max); + } + + function expectBridgeRelayed(IFastBridge.BridgeTransaction memory bridgeTx, bytes32 txId, address relayer) public { + vm.expectEmit(address(fastBridge)); + emit BridgeRelayed({ + transactionId: txId, + relayer: relayer, + to: bridgeTx.destRecipient, + originChainId: bridgeTx.originChainId, + originToken: bridgeTx.originToken, + destToken: bridgeTx.destToken, + originAmount: bridgeTx.originAmount, + destAmount: bridgeTx.destAmount, + chainGasAmount: 0 + }); + } + + function relay(address caller, uint256 msgValue, IFastBridge.BridgeTransaction memory bridgeTx) public { + bytes memory request = abi.encode(bridgeTx); + vm.prank(caller); + fastBridge.relay{value: msgValue}(request); + } + + function relayWithAddress( + address caller, + address relayer, + uint256 msgValue, + IFastBridge.BridgeTransaction memory bridgeTx + ) + public + { + bytes memory request = abi.encode(bridgeTx); + vm.prank(caller); + fastBridge.relay{value: msgValue}(request, relayer); + } + + /// @notice RelayerA completes the ERC20 bridge request + function test_relay_token() public { + bytes32 txId = getTxId(tokenTx); + expectBridgeRelayed(tokenTx, txId, address(relayerA)); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + assertTrue(fastBridge.bridgeRelays(txId)); + assertEq(dstToken.balanceOf(address(userB)), tokenParams.destAmount); + assertEq(dstToken.balanceOf(address(relayerA)), LEFTOVER_BALANCE); + assertEq(dstToken.balanceOf(address(fastBridge)), 0); + } + + /// @notice RelayerA completes the ERC20 bridge request, using relayerB's address + function test_relay_token_withRelayerAddress() public { + bytes32 txId = getTxId(tokenTx); + expectBridgeRelayed(tokenTx, txId, address(relayerB)); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: 0, bridgeTx: tokenTx}); + assertTrue(fastBridge.bridgeRelays(txId)); + assertEq(dstToken.balanceOf(address(userB)), tokenParams.destAmount); + assertEq(dstToken.balanceOf(address(relayerA)), LEFTOVER_BALANCE); + assertEq(dstToken.balanceOf(address(fastBridge)), 0); + } + + /// @notice RelayerB completes the ETH bridge request + function test_relay_eth() public { + bytes32 txId = getTxId(ethTx); + expectBridgeRelayed(ethTx, txId, address(relayerB)); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + assertTrue(fastBridge.bridgeRelays(txId)); + assertEq(address(userB).balance, ethParams.destAmount); + assertEq(address(relayerB).balance, LEFTOVER_BALANCE); + assertEq(address(fastBridge).balance, 0); + } + + /// @notice RelayerB completes the ETH bridge request, using relayerA's address + function test_relay_eth_withRelayerAddress() public { + bytes32 txId = getTxId(ethTx); + expectBridgeRelayed(ethTx, txId, address(relayerA)); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + assertTrue(fastBridge.bridgeRelays(txId)); + assertEq(address(userB).balance, ethParams.destAmount); + assertEq(address(relayerB).balance, LEFTOVER_BALANCE); + assertEq(address(fastBridge).balance, 0); + } + + // ══════════════════════════════════════════════════ REVERTS ══════════════════════════════════════════════════════ + + function test_relay_revert_chainIncorrect() public { + vm.chainId(SRC_CHAIN_ID); + vm.expectRevert(ChainIncorrect.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_revert_transactionRelayed() public { + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + vm.expectRevert(TransactionRelayed.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_revert_deadlineExceeded() public { + skip(DEADLINE + 1); + vm.expectRevert(DeadlineExceeded.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_withRelayerAddress_revert_chainIncorrect() public { + vm.chainId(SRC_CHAIN_ID); + vm.expectRevert(ChainIncorrect.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_withRelayerAddress_revert_transactionRelayed() public { + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + vm.expectRevert(TransactionRelayed.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_withRelayerAddress_revert_deadlineExceeded() public { + skip(DEADLINE + 1); + vm.expectRevert(DeadlineExceeded.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: 0, bridgeTx: tokenTx}); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol new file mode 100644 index 0000000000..58502618b9 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {FastBridgeV2, FastBridgeV2Test} from "./FastBridgeV2.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2ManagementTest is FastBridgeV2Test { + uint256 public constant FEE_RATE_MAX = 1e4; // 1% + bytes32 public constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); + + address public admin = makeAddr("Admin"); + address public governorA = makeAddr("Governor A"); + + event FeeRateUpdated(uint256 oldFeeRate, uint256 newFeeRate); + event FeesSwept(address token, address recipient, uint256 amount); + event ChainGasAmountUpdated(uint256 oldChainGasAmount, uint256 newChainGasAmount); + + function deployFastBridge() public override returns (FastBridgeV2) { + return new FastBridgeV2(admin); + } + + function configureFastBridge() public override { + setGovernor(admin, governor); + } + + function mintTokens() public override { + srcToken.mint(address(fastBridge), 100); + deal(address(fastBridge), 200); + cheatCollectedProtocolFees(address(srcToken), 100); + cheatCollectedProtocolFees(ETH_ADDRESS, 200); + } + + function setGovernor(address caller, address newGovernor) public { + vm.prank(caller); + fastBridge.grantRole(GOVERNOR_ROLE, newGovernor); + } + + function setProtocolFeeRate(address caller, uint256 newFeeRate) public { + vm.prank(caller); + fastBridge.setProtocolFeeRate(newFeeRate); + } + + function sweepProtocolFees(address caller, address token, address recipient) public { + vm.prank(caller); + fastBridge.sweepProtocolFees(token, recipient); + } + + function setChainGasAmount(address caller, uint256 newChainGasAmount) public { + vm.prank(caller); + fastBridge.setChainGasAmount(newChainGasAmount); + } + + function test_grantGovernorRole() public { + assertFalse(fastBridge.hasRole(GOVERNOR_ROLE, governorA)); + setGovernor(admin, governorA); + assertTrue(fastBridge.hasRole(GOVERNOR_ROLE, governorA)); + } + + function test_grantGovernorRole_revertNotAdmin(address caller) public { + vm.assume(caller != admin); + expectUnauthorized(caller, fastBridge.DEFAULT_ADMIN_ROLE()); + setGovernor(caller, governorA); + } + + // ═══════════════════════════════════════════ SET PROTOCOL FEE RATE ═══════════════════════════════════════════════ + + function test_setProtocolFeeRate() public { + vm.expectEmit(address(fastBridge)); + emit FeeRateUpdated(0, 123); + setProtocolFeeRate(governor, 123); + assertEq(fastBridge.protocolFeeRate(), 123); + } + + function test_setProtocolFeeRate_twice() public { + test_setProtocolFeeRate(); + vm.expectEmit(address(fastBridge)); + emit FeeRateUpdated(123, FEE_RATE_MAX); + setProtocolFeeRate(governor, FEE_RATE_MAX); + assertEq(fastBridge.protocolFeeRate(), FEE_RATE_MAX); + } + + function test_setProtocolFeeRate_revert_tooHigh() public { + vm.expectRevert("newFeeRate > max"); + setProtocolFeeRate(governor, FEE_RATE_MAX + 1); + } + + function test_setProtocolFeeRate_revert_notGovernor(address caller) public { + vm.assume(caller != governor); + expectUnauthorized(caller, fastBridge.GOVERNOR_ROLE()); + setProtocolFeeRate(caller, 123); + } + + // ════════════════════════════════════════════ SWEEP PROTOCOL FEES ════════════════════════════════════════════════ + + function test_sweepProtocolFees_erc20() public { + vm.expectEmit(address(fastBridge)); + emit FeesSwept(address(srcToken), governorA, 100); + sweepProtocolFees(governor, address(srcToken), governorA); + assertEq(srcToken.balanceOf(address(fastBridge)), 0); + assertEq(srcToken.balanceOf(governorA), 100); + assertEq(fastBridge.protocolFees(address(srcToken)), 0); + } + + function test_sweepProtocolFees_eth() public { + vm.expectEmit(address(fastBridge)); + emit FeesSwept(ETH_ADDRESS, governorA, 200); + sweepProtocolFees(governor, ETH_ADDRESS, governorA); + assertEq(address(fastBridge).balance, 0); + assertEq(governorA.balance, 200); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), 0); + } + + function test_sweepProtocolFees_revertNotGovernor(address caller) public { + vm.assume(caller != governor); + expectUnauthorized(caller, fastBridge.GOVERNOR_ROLE()); + sweepProtocolFees(caller, address(srcToken), governorA); + } + + // ═══════════════════════════════════════════ SET CHAIN GAS AMOUNT ════════════════════════════════════════════════ + + function test_setChainGasAmount() public { + vm.expectEmit(address(fastBridge)); + emit ChainGasAmountUpdated(0, 123); + setChainGasAmount(governor, 123); + assertEq(fastBridge.chainGasAmount(), 123); + } + + function test_setChainGasAmount_twice() public { + test_setChainGasAmount(); + vm.expectEmit(address(fastBridge)); + emit ChainGasAmountUpdated(123, 456); + setChainGasAmount(governor, 456); + assertEq(fastBridge.chainGasAmount(), 456); + } + + function test_setChainGasAmount_revertNotGovernor(address caller) public { + vm.assume(caller != governor); + expectUnauthorized(caller, fastBridge.GOVERNOR_ROLE()); + setChainGasAmount(caller, 123); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Parity.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Parity.t.sol new file mode 100644 index 0000000000..d92e4f8ac5 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Parity.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {FastBridgeTest, SenderIncorrect} from "./FastBridge.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2ParityTest is FastBridgeTest { + address public anotherRelayer = makeAddr("Another Relayer"); + + function deployFastBridge() internal virtual override returns (address) { + // Use the cheatcode to deploy 0.8.24 contract within a 0.8.20 test + return deployCode({what: "FastBridgeV2", args: abi.encode(owner)}); + } + + /// @notice Relay function is no longer permissioned, so we skip this test + function test_failedRelayNotRelayer() public virtual override { + vm.skip(true); + } + + /// @notice Claim function is no longer permissioned by the role (but still by proven address), + /// so we skip this test + function test_failedClaimNotRelayer() public virtual override { + vm.skip(true); + } + + /// @notice Claim function is no longer permissioned by the role (but still by proven address), + /// so we modify the parent test by removing the role assignment. + function test_failedClaimNotOldRelayer() public virtual override { + setUpRoles(); + test_successfulBridge(); + (bytes memory request,) = _getBridgeRequestAndId(block.chainid, 0, 0); + vm.warp(block.timestamp + 31 minutes); + vm.prank(relayer); + fastBridge.prove(request, bytes32("0x04")); + + vm.expectRevert(abi.encodeWithSelector(SenderIncorrect.selector)); + vm.prank(anotherRelayer); + fastBridge.claim(request, relayer); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.ProtocolFees.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.ProtocolFees.t.sol new file mode 100644 index 0000000000..d9a5324ed1 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.ProtocolFees.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {FastBridgeV2SrcTest} from "./FastBridgeV2.Src.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2SrcProtocolFeesTest is FastBridgeV2SrcTest { + function configureFastBridge() public virtual override { + super.configureFastBridge(); + fastBridge.grantRole(fastBridge.GOVERNOR_ROLE(), address(this)); + fastBridge.setProtocolFeeRate(1e4); // 1% + } + + function createFixtures() public virtual override { + super.createFixtures(); + tokenTx.originFeeAmount = 0.01e6; + tokenTx.originAmount = 0.99e6; + tokenTx.destAmount = 0.98e6; + tokenParams.destAmount = 0.98e6; + ethTx.originFeeAmount = 0.01 ether; + ethTx.originAmount = 0.99 ether; + ethTx.destAmount = 0.98 ether; + ethParams.destAmount = 0.98 ether; + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol new file mode 100644 index 0000000000..8a0106d0c0 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol @@ -0,0 +1,833 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { + AmountIncorrect, + ChainIncorrect, + DisputePeriodNotPassed, + DisputePeriodPassed, + DeadlineNotExceeded, + DeadlineTooShort, + MsgValueIncorrect, + SenderIncorrect, + StatusIncorrect, + ZeroAddress +} from "../contracts/libs/Errors.sol"; + +import {FastBridgeV2, FastBridgeV2Test, IFastBridge} from "./FastBridgeV2.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2SrcTest is FastBridgeV2Test { + event BridgeRequested( + bytes32 indexed transactionId, + address indexed sender, + bytes request, + uint32 destChainId, + address originToken, + address destToken, + uint256 originAmount, + uint256 destAmount, + bool sendChainGas + ); + + event BridgeProofProvided(bytes32 indexed transactionId, address indexed relayer, bytes32 transactionHash); + + event BridgeDepositClaimed( + bytes32 indexed transactionId, address indexed relayer, address indexed to, address token, uint256 amount + ); + + event BridgeProofDisputed(bytes32 indexed transactionId, address indexed relayer); + + event BridgeDepositRefunded(bytes32 indexed transactionId, address indexed to, address token, uint256 amount); + + uint256 public constant MIN_DEADLINE = 30 minutes; + uint256 public constant CLAIM_DELAY = 30 minutes; + uint256 public constant PERMISSIONLESS_REFUND_DELAY = 7 days; + + uint256 public constant LEFTOVER_BALANCE = 1 ether; + uint256 public constant INITIAL_PROTOCOL_FEES_TOKEN = 456_789; + uint256 public constant INITIAL_PROTOCOL_FEES_ETH = 0.123 ether; + + address public claimTo = makeAddr("Claim To"); + + function setUp() public override { + vm.chainId(SRC_CHAIN_ID); + super.setUp(); + } + + function deployFastBridge() public override returns (FastBridgeV2) { + return new FastBridgeV2(address(this)); + } + + function configureFastBridge() public virtual override { + fastBridge.grantRole(fastBridge.RELAYER_ROLE(), relayerA); + fastBridge.grantRole(fastBridge.RELAYER_ROLE(), relayerB); + fastBridge.grantRole(fastBridge.GUARD_ROLE(), guard); + fastBridge.grantRole(fastBridge.REFUNDER_ROLE(), refunder); + } + + function mintTokens() public override { + // Prior Protocol fees + srcToken.mint(address(fastBridge), INITIAL_PROTOCOL_FEES_TOKEN); + deal(address(fastBridge), INITIAL_PROTOCOL_FEES_ETH); + cheatCollectedProtocolFees(address(srcToken), INITIAL_PROTOCOL_FEES_TOKEN); + cheatCollectedProtocolFees(ETH_ADDRESS, INITIAL_PROTOCOL_FEES_ETH); + // Users + srcToken.mint(userA, LEFTOVER_BALANCE + tokenParams.originAmount); + srcToken.mint(userB, LEFTOVER_BALANCE + tokenParams.originAmount); + deal(userA, LEFTOVER_BALANCE + ethParams.originAmount); + deal(userB, LEFTOVER_BALANCE + ethParams.originAmount); + vm.prank(userA); + srcToken.approve(address(fastBridge), type(uint256).max); + vm.prank(userB); + srcToken.approve(address(fastBridge), type(uint256).max); + } + + function bridge(address caller, uint256 msgValue, IFastBridge.BridgeParams memory params) public { + vm.prank(caller); + fastBridge.bridge{value: msgValue}(params); + } + + function prove(address caller, IFastBridge.BridgeTransaction memory bridgeTx, bytes32 destTxHash) public { + vm.prank(caller); + fastBridge.prove(abi.encode(bridgeTx), destTxHash); + } + + function claim(address caller, IFastBridge.BridgeTransaction memory bridgeTx) public { + vm.prank(caller); + fastBridge.claim(abi.encode(bridgeTx)); + } + + function claim(address caller, IFastBridge.BridgeTransaction memory bridgeTx, address to) public { + vm.prank(caller); + fastBridge.claim(abi.encode(bridgeTx), to); + } + + function dispute(address caller, bytes32 txId) public { + vm.prank(caller); + fastBridge.dispute(txId); + } + + function refund(address caller, IFastBridge.BridgeTransaction memory bridgeTx) public { + vm.prank(caller); + fastBridge.refund(abi.encode(bridgeTx)); + } + + function expectBridgeRequested(IFastBridge.BridgeTransaction memory bridgeTx, bytes32 txId) public { + vm.expectEmit(address(fastBridge)); + emit BridgeRequested({ + transactionId: txId, + sender: bridgeTx.originSender, + request: abi.encode(bridgeTx), + destChainId: bridgeTx.destChainId, + originToken: bridgeTx.originToken, + destToken: bridgeTx.destToken, + originAmount: bridgeTx.originAmount, + destAmount: bridgeTx.destAmount, + sendChainGas: bridgeTx.sendChainGas + }); + } + + function expectBridgeProofProvided(bytes32 txId, address relayer, bytes32 destTxHash) public { + vm.expectEmit(address(fastBridge)); + emit BridgeProofProvided({transactionId: txId, relayer: relayer, transactionHash: destTxHash}); + } + + function expectBridgeDepositClaimed( + IFastBridge.BridgeTransaction memory bridgeTx, + bytes32 txId, + address relayer, + address to + ) + public + { + vm.expectEmit(address(fastBridge)); + emit BridgeDepositClaimed({ + transactionId: txId, + relayer: relayer, + to: to, + token: bridgeTx.originToken, + amount: bridgeTx.originAmount + }); + } + + function expectBridgeProofDisputed(bytes32 txId, address guard) public { + vm.expectEmit(address(fastBridge)); + // Note: BridgeProofDisputed event has a mislabeled address parameter, this is actually the guard + emit BridgeProofDisputed({transactionId: txId, relayer: guard}); + } + + function expectBridgeDepositRefunded(IFastBridge.BridgeParams memory bridgeParams, bytes32 txId) public { + vm.expectEmit(address(fastBridge)); + emit BridgeDepositRefunded({ + transactionId: txId, + to: bridgeParams.sender, + token: bridgeParams.originToken, + amount: bridgeParams.originAmount + }); + } + + function assertEq(FastBridgeV2.BridgeStatus a, FastBridgeV2.BridgeStatus b) public pure { + assertEq(uint8(a), uint8(b)); + } + + // ══════════════════════════════════════════════════ BRIDGE ═══════════════════════════════════════════════════════ + + function checkTokenBalancesAfterBridge(address caller) public view { + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(caller), LEFTOVER_BALANCE); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + } + + function test_bridge_token() public { + bytes32 txId = getTxId(tokenTx); + expectBridgeRequested(tokenTx, txId); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + checkTokenBalancesAfterBridge(userA); + } + + function test_bridge_token_diffSender() public { + bytes32 txId = getTxId(tokenTx); + expectBridgeRequested(tokenTx, txId); + bridge({caller: userB, msgValue: 0, params: tokenParams}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(srcToken.balanceOf(userA), LEFTOVER_BALANCE + tokenParams.originAmount); + checkTokenBalancesAfterBridge(userB); + } + + function checkEthBalancesAfterBridge(address caller) public view { + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(caller).balance, LEFTOVER_BALANCE); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + } + + function test_bridge_eth() public { + // bridge token first to match the nonce + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + expectBridgeRequested(ethTx, txId); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + checkEthBalancesAfterBridge(userA); + } + + function test_bridge_eth_diffSender() public { + // bridge token first to match the nonce + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + expectBridgeRequested(ethTx, txId); + bridge({caller: userB, msgValue: ethParams.originAmount, params: ethParams}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(userA.balance, LEFTOVER_BALANCE + ethParams.originAmount); + checkEthBalancesAfterBridge(userB); + } + + function test_bridge_userSpecificNonce() public { + vm.skip(true); // TODO: unskip when implemented + bridge({caller: userA, msgValue: 0, params: tokenParams}); + // UserB nonce is 0 + ethTx.nonce = 0; + ethParams.sender = userB; + ethTx.originSender = userB; + bytes32 txId = getTxId(ethTx); + expectBridgeRequested(ethTx, txId); + bridge({caller: userB, msgValue: ethParams.originAmount, params: ethParams}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + checkEthBalancesAfterBridge(userB); + } + + function test_bridge_eth_revert_lowerMsgValue() public { + vm.expectRevert(MsgValueIncorrect.selector); + bridge({caller: userA, msgValue: ethParams.originAmount - 1, params: ethParams}); + } + + function test_bridge_eth_revert_higherMsgValue() public { + vm.expectRevert(MsgValueIncorrect.selector); + bridge({caller: userA, msgValue: ethParams.originAmount + 1, params: ethParams}); + } + + function test_bridge_eth_revert_zeroMsgValue() public { + vm.expectRevert(MsgValueIncorrect.selector); + bridge({caller: userA, msgValue: 0, params: ethParams}); + } + + function test_bridge_revert_sameDestinationChain() public { + tokenParams.dstChainId = SRC_CHAIN_ID; + vm.expectRevert(ChainIncorrect.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroOriginAmount() public { + tokenParams.originAmount = 0; + vm.expectRevert(AmountIncorrect.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroDestAmount() public { + tokenParams.destAmount = 0; + vm.expectRevert(AmountIncorrect.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroOriginToken() public { + tokenParams.originToken = address(0); + vm.expectRevert(ZeroAddress.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroDestToken() public { + tokenParams.destToken = address(0); + vm.expectRevert(ZeroAddress.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroSender() public { + vm.skip(true); // TODO: unskip when fixed + tokenParams.sender = address(0); + vm.expectRevert(ZeroAddress.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_zeroRecipient() public { + vm.skip(true); // TODO: unskip when fixed + tokenParams.to = address(0); + vm.expectRevert(ZeroAddress.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + function test_bridge_revert_deadlineTooClose() public { + tokenParams.deadline = block.timestamp + MIN_DEADLINE - 1; + vm.expectRevert(DeadlineTooShort.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + } + + // ═══════════════════════════════════════════════════ PROVE ═══════════════════════════════════════════════════════ + + function test_prove_token() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + (uint96 timestamp, address relayer) = fastBridge.bridgeProofs(txId); + assertEq(timestamp, block.timestamp); + assertEq(relayer, relayerA); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + function test_prove_eth() public { + // bridge token first to match the nonce + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + (uint96 timestamp, address relayer) = fastBridge.bridgeProofs(txId); + assertEq(timestamp, block.timestamp); + assertEq(relayer, relayerA); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + } + + function test_prove_revert_statusNull() public { + vm.expectRevert(StatusIncorrect.selector); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + } + + function test_prove_revert_statusProved() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + vm.expectRevert(StatusIncorrect.selector); + prove({caller: relayerB, bridgeTx: tokenTx, destTxHash: hex"02"}); + } + + function test_prove_revert_statusClaimed() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + vm.expectRevert(StatusIncorrect.selector); + prove({caller: relayerB, bridgeTx: tokenTx, destTxHash: hex"02"}); + } + + function test_prove_revert_statusRefunded() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + refund({caller: refunder, bridgeTx: tokenTx}); + vm.expectRevert(StatusIncorrect.selector); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + } + + function test_prove_revert_callerNotRelayer(address caller) public { + vm.assume(caller != relayerA && caller != relayerB); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + expectUnauthorized(caller, fastBridge.RELAYER_ROLE()); + prove({caller: caller, bridgeTx: tokenTx, destTxHash: hex"01"}); + } + + // ═══════════════════════════════════════════════════ CLAIM ═══════════════════════════════════════════════════════ + + function checkTokenBalancesAfterClaim(address relayer) public view { + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN + tokenTx.originFeeAmount); + assertEq(srcToken.balanceOf(relayer), tokenTx.originAmount); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenTx.originFeeAmount); + } + + function test_claim_token() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + assertTrue(fastBridge.canClaim(txId, relayerA)); + expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkTokenBalancesAfterClaim(relayerA); + } + + function test_claim_token_permissionless(address caller) public { + vm.assume(caller != relayerA); + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: caller, bridgeTx: tokenTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkTokenBalancesAfterClaim(relayerA); + } + + function test_claim_token_permissionless_toZeroAddress(address caller) public { + vm.assume(caller != relayerA); + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: caller, bridgeTx: tokenTx, to: address(0)}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkTokenBalancesAfterClaim(relayerA); + } + + function test_claim_token_toDiffAddress() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: claimTo}); + claim({caller: relayerA, bridgeTx: tokenTx, to: claimTo}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + assertEq(srcToken.balanceOf(relayerA), 0); + checkTokenBalancesAfterClaim(claimTo); + } + + function test_claim_token_longDelay() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 30 days); + expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkTokenBalancesAfterClaim(relayerA); + } + + function checkEthBalancesAfterClaim(address relayer) public view { + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH + ethTx.originFeeAmount); + assertEq(address(relayer).balance, ethTx.originAmount); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethTx.originFeeAmount); + } + + function test_claim_eth() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + assertTrue(fastBridge.canClaim(txId, relayerA)); + expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: relayerA, bridgeTx: ethTx, to: relayerA}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkEthBalancesAfterClaim(relayerA); + } + + function test_claim_eth_permissionless(address caller) public { + vm.assume(caller != relayerA); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: caller, bridgeTx: ethTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkEthBalancesAfterClaim(relayerA); + } + + function test_claim_eth_permissionless_toZeroAddress(address caller) public { + vm.assume(caller != relayerA); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: caller, bridgeTx: ethTx, to: address(0)}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkEthBalancesAfterClaim(relayerA); + } + + function test_claim_eth_toDiffAddress() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: claimTo}); + claim({caller: relayerA, bridgeTx: ethTx, to: claimTo}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkEthBalancesAfterClaim(claimTo); + } + + function test_claim_eth_longDelay() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 30 days); + expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); + claim({caller: relayerA, bridgeTx: ethTx, to: relayerA}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.RELAYER_CLAIMED); + checkEthBalancesAfterClaim(relayerA); + } + + function test_claim_revert_zeroDelay() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + assertFalse(fastBridge.canClaim(getTxId(tokenTx), relayerA)); + vm.expectRevert(DisputePeriodNotPassed.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + function test_claim_revert_smallerDelay() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY); + assertFalse(fastBridge.canClaim(getTxId(tokenTx), relayerA)); + vm.expectRevert(DisputePeriodNotPassed.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + function test_claim_revert_callerNotProven(address caller, address to) public { + vm.assume(caller != relayerA && to != address(0)); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + vm.expectRevert(SenderIncorrect.selector); + fastBridge.canClaim(getTxId(tokenTx), caller); + vm.expectRevert(SenderIncorrect.selector); + claim({caller: caller, bridgeTx: tokenTx, to: to}); + } + + function test_claim_revert_statusNull() public { + bytes32 txId = getTxId(tokenTx); + vm.expectRevert(StatusIncorrect.selector); + fastBridge.canClaim(txId, relayerA); + vm.expectRevert(StatusIncorrect.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + function test_claim_revert_statusRequested() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + vm.expectRevert(StatusIncorrect.selector); + fastBridge.canClaim(txId, relayerA); + vm.expectRevert(StatusIncorrect.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + function test_claim_revert_statusClaimed() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + vm.expectRevert(StatusIncorrect.selector); + fastBridge.canClaim(txId, relayerA); + vm.expectRevert(StatusIncorrect.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + function test_claim_revert_statusRefunded() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + refund({caller: refunder, bridgeTx: tokenTx}); + vm.expectRevert(StatusIncorrect.selector); + fastBridge.canClaim(txId, relayerA); + vm.expectRevert(StatusIncorrect.selector); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + } + + // ══════════════════════════════════════════════════ DISPUTE ══════════════════════════════════════════════════════ + + function test_dispute_token() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + expectBridgeProofDisputed({txId: txId, guard: guard}); + dispute({caller: guard, txId: txId}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + } + + function test_dispute_token_justBeforeDeadline() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY); + expectBridgeProofDisputed({txId: txId, guard: guard}); + dispute({caller: guard, txId: txId}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + } + + function test_dispute_eth() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + expectBridgeProofDisputed({txId: txId, guard: guard}); + dispute({caller: guard, txId: txId}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + } + + function test_dispute_eth_justBeforeDeadline() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY); + expectBridgeProofDisputed({txId: txId, guard: guard}); + dispute({caller: guard, txId: txId}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REQUESTED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + } + + function test_dispute_revert_afterDeadline() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + vm.expectRevert(DisputePeriodPassed.selector); + dispute({caller: guard, txId: txId}); + } + + function test_dispute_revert_callerNotGuard(address caller) public { + vm.assume(caller != guard); + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + expectUnauthorized(caller, fastBridge.GUARD_ROLE()); + dispute({caller: caller, txId: txId}); + } + + function test_dispute_revert_statusNull() public { + bytes32 txId = getTxId(tokenTx); + vm.expectRevert(StatusIncorrect.selector); + dispute({caller: guard, txId: txId}); + } + + function test_dispute_revert_statusRequested() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + vm.expectRevert(StatusIncorrect.selector); + dispute({caller: guard, txId: txId}); + } + + function test_dispute_revert_statusClaimed() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + vm.expectRevert(StatusIncorrect.selector); + dispute({caller: guard, txId: txId}); + } + + function test_dispute_revert_statusRefunded() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + refund({caller: refunder, bridgeTx: tokenTx}); + vm.expectRevert(StatusIncorrect.selector); + dispute({caller: guard, txId: txId}); + } + + // ══════════════════════════════════════════════════ REFUND ═══════════════════════════════════════════════════════ + + function test_refund_token() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + expectBridgeDepositRefunded({bridgeParams: tokenParams, txId: txId}); + refund({caller: refunder, bridgeTx: tokenTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(userA), LEFTOVER_BALANCE + tokenParams.originAmount); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + /// @notice Deposit should be refunded to the BridgeParams.sender, regardless of the actual caller + function test_refund_token_diffSender() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userB, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + expectBridgeDepositRefunded({bridgeParams: tokenParams, txId: txId}); + refund({caller: refunder, bridgeTx: tokenTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(userA), LEFTOVER_BALANCE + 2 * tokenParams.originAmount); + assertEq(srcToken.balanceOf(userB), LEFTOVER_BALANCE); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + function test_refund_token_longDelay() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 30 days); + expectBridgeDepositRefunded({bridgeParams: tokenParams, txId: txId}); + refund({caller: refunder, bridgeTx: tokenTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(userA), LEFTOVER_BALANCE + tokenParams.originAmount); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + function test_refund_token_permisionless(address caller) public { + vm.assume(caller != refunder); + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + PERMISSIONLESS_REFUND_DELAY + 1); + expectBridgeDepositRefunded({bridgeParams: tokenParams, txId: txId}); + refund({caller: caller, bridgeTx: tokenTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(userA), LEFTOVER_BALANCE + tokenParams.originAmount); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + function test_refund_eth() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + skip(DEADLINE + 1); + expectBridgeDepositRefunded({bridgeParams: ethParams, txId: txId}); + refund({caller: refunder, bridgeTx: ethTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(userA).balance, LEFTOVER_BALANCE + ethParams.originAmount); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH); + } + + /// @notice Deposit should be refunded to the BridgeParams.sender, regardless of the actual caller + function test_refund_eth_diffSender() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userB, msgValue: ethParams.originAmount, params: ethParams}); + skip(DEADLINE + 1); + expectBridgeDepositRefunded({bridgeParams: ethParams, txId: txId}); + refund({caller: refunder, bridgeTx: ethTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(userA).balance, LEFTOVER_BALANCE + 2 * ethParams.originAmount); + assertEq(address(userB).balance, LEFTOVER_BALANCE); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH); + } + + function test_refund_eth_longDelay() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + skip(DEADLINE + 30 days); + expectBridgeDepositRefunded({bridgeParams: ethParams, txId: txId}); + refund({caller: refunder, bridgeTx: ethTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(userA).balance, LEFTOVER_BALANCE + ethParams.originAmount); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH); + } + + function test_refund_eth_permisionless(address caller) public { + vm.assume(caller != refunder); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + skip(DEADLINE + PERMISSIONLESS_REFUND_DELAY + 1); + expectBridgeDepositRefunded({bridgeParams: ethParams, txId: txId}); + refund({caller: caller, bridgeTx: ethTx}); + assertEq(fastBridge.bridgeStatuses(txId), FastBridgeV2.BridgeStatus.REFUNDED); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(userA).balance, LEFTOVER_BALANCE + ethParams.originAmount); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH); + } + + function test_refund_revert_zeroDelay() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + vm.expectRevert(DeadlineNotExceeded.selector); + refund({caller: refunder, bridgeTx: ethTx}); + } + + function test_refund_revert_justBeforeDeadline() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE); + vm.expectRevert(DeadlineNotExceeded.selector); + refund({caller: refunder, bridgeTx: ethTx}); + } + + function test_refund_revert_justBeforeDeadline_permisionless(address caller) public { + vm.assume(caller != refunder); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + PERMISSIONLESS_REFUND_DELAY); + vm.expectRevert(DeadlineNotExceeded.selector); + refund({caller: caller, bridgeTx: ethTx}); + } + + function test_refund_revert_statusNull() public { + vm.skip(true); // TODO: unskip when fixed + vm.expectRevert(StatusIncorrect.selector); + refund({caller: refunder, bridgeTx: ethTx}); + } + + function test_refund_revert_statusProven() public { + vm.skip(true); // TODO: unskip when fixed + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + vm.expectRevert(StatusIncorrect.selector); + refund({caller: refunder, bridgeTx: tokenTx}); + } + + function test_refund_revert_statusClaimed() public { + vm.skip(true); // TODO: unskip when fixed + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + skip(CLAIM_DELAY + 1); + claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); + vm.expectRevert(StatusIncorrect.selector); + refund({caller: refunder, bridgeTx: tokenTx}); + } + + function test_refund_revert_statusRefunded() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + skip(DEADLINE + 1); + refund({caller: refunder, bridgeTx: tokenTx}); + vm.expectRevert(StatusIncorrect.selector); + refund({caller: refunder, bridgeTx: tokenTx}); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.t.sol b/packages/contracts-rfq/test/FastBridgeV2.t.sol new file mode 100644 index 0000000000..4fe357e3f2 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IFastBridge} from "../contracts/interfaces/IFastBridge.sol"; +import {FastBridgeV2} from "../contracts/FastBridgeV2.sol"; + +import {MockERC20} from "./MockERC20.sol"; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {stdStorage, StdStorage} from "forge-std/Test.sol"; + +// solhint-disable no-empty-blocks +abstract contract FastBridgeV2Test is Test { + using stdStorage for StdStorage; + + uint32 public constant SRC_CHAIN_ID = 1337; + uint32 public constant DST_CHAIN_ID = 7331; + uint256 public constant DEADLINE = 1 days; + address public constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + FastBridgeV2 public fastBridge; + MockERC20 public srcToken; + MockERC20 public dstToken; + + address public relayerA = makeAddr("Relayer A"); + address public relayerB = makeAddr("Relayer B"); + address public guard = makeAddr("Guard"); + address public userA = makeAddr("User A"); + address public userB = makeAddr("User B"); + address public governor = makeAddr("Governor"); + address public refunder = makeAddr("Refunder"); + + IFastBridge.BridgeTransaction public tokenTx; + IFastBridge.BridgeTransaction public ethTx; + IFastBridge.BridgeParams public tokenParams; + IFastBridge.BridgeParams public ethParams; + + function setUp() public virtual { + srcToken = new MockERC20("SrcToken", 6); + dstToken = new MockERC20("DstToken", 6); + createFixtures(); + fastBridge = deployFastBridge(); + configureFastBridge(); + mintTokens(); + } + + function deployFastBridge() public virtual returns (FastBridgeV2); + + function configureFastBridge() public virtual {} + + function mintTokens() public virtual {} + + function createFixtures() public virtual { + tokenParams = IFastBridge.BridgeParams({ + dstChainId: DST_CHAIN_ID, + sender: userA, + to: userB, + originToken: address(srcToken), + destToken: address(dstToken), + originAmount: 1e6, + destAmount: 0.99e6, + sendChainGas: false, + deadline: block.timestamp + DEADLINE + }); + ethParams = IFastBridge.BridgeParams({ + dstChainId: DST_CHAIN_ID, + sender: userA, + to: userB, + originToken: ETH_ADDRESS, + destToken: ETH_ADDRESS, + originAmount: 1 ether, + destAmount: 0.99 ether, + sendChainGas: false, + deadline: block.timestamp + DEADLINE + }); + + tokenTx = IFastBridge.BridgeTransaction({ + originChainId: SRC_CHAIN_ID, + destChainId: DST_CHAIN_ID, + originSender: userA, + destRecipient: userB, + originToken: address(srcToken), + destToken: address(dstToken), + originAmount: 1e6, + destAmount: 0.99e6, + // override this in tests with protocol fees + originFeeAmount: 0, + sendChainGas: false, + deadline: block.timestamp + DEADLINE, + nonce: 0 + }); + ethTx = IFastBridge.BridgeTransaction({ + originChainId: SRC_CHAIN_ID, + destChainId: DST_CHAIN_ID, + originSender: userA, + destRecipient: userB, + originToken: ETH_ADDRESS, + destToken: ETH_ADDRESS, + originAmount: 1 ether, + destAmount: 0.99 ether, + // override this in tests with protocol fees + originFeeAmount: 0, + sendChainGas: false, + deadline: block.timestamp + DEADLINE, + nonce: 1 + }); + } + + function getTxId(IFastBridge.BridgeTransaction memory bridgeTx) public pure returns (bytes32) { + return keccak256(abi.encode(bridgeTx)); + } + + function expectUnauthorized(address caller, bytes32 role) public { + vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, role)); + } + + function cheatCollectedProtocolFees(address token, uint256 amount) public { + stdstore.target(address(fastBridge)).sig("protocolFees(address)").with_key(token).checked_write(amount); + } +}