diff --git a/contracts/SpokePool.sol b/contracts/SpokePool.sol index 6c009dcbf..c8cc1c05a 100644 --- a/contracts/SpokePool.sol +++ b/contracts/SpokePool.sol @@ -11,6 +11,7 @@ import "./interfaces/V3SpokePoolInterface.sol"; import "./upgradeable/MultiCallerUpgradeable.sol"; import "./upgradeable/EIP712CrossChainUpgradeable.sol"; import "./upgradeable/AddressLibUpgradeable.sol"; +import "./libraries/AddressConverters.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; @@ -40,6 +41,8 @@ abstract contract SpokePool is { using SafeERC20Upgradeable for IERC20Upgradeable; using AddressLibUpgradeable for address; + using Bytes32ToAddress for bytes32; + using AddressToBytes32 for address; // Address of the L1 contract that acts as the owner of this SpokePool. This should normally be set to the HubPool // address. The crossDomainAdmin address is unused when the SpokePool is deployed to the same chain as the HubPool. @@ -57,7 +60,11 @@ abstract contract SpokePool is WETH9Interface private DEPRECATED_wrappedNativeToken; uint32 private DEPRECATED_depositQuoteTimeBuffer; - // Count of deposits is used to construct a unique deposit identifier for this spoke pool. + // `numberOfDeposits` acts as a counter to generate unique deposit identifiers for this spoke pool. + // It is a uint32 that increments with each `depositV3` call. In the `V3FundsDeposited` event, it is + // implicitly cast to uint256 by setting its most significant bits to 0, reducing the risk of ID collisions + // with unsafe deposits. However, this variable's name could be improved (e.g., `depositNonceCounter`) + // since it does not accurately reflect the total number of deposits, as `unsafeDepositV3` can bypass this increment. uint32 public numberOfDeposits; // Whether deposits and fills are disabled. @@ -100,6 +107,10 @@ abstract contract SpokePool is // to eliminate any chance of collision between pre and post V3 relay hashes. mapping(bytes32 => uint256) public fillStatuses; + // Mapping of L2TokenAddress to relayer to outstanding refund amount. Used when a relayer repayment fails for some + // reason (eg blacklist) to track their outstanding liability, thereby letting them claim it later. + mapping(address => mapping(address => uint256)) public relayerRefund; + /************************************************************** * CONSTANT/IMMUTABLE VARIABLES * **************************************************************/ @@ -128,21 +139,26 @@ abstract contract SpokePool is bytes32 public constant UPDATE_V3_DEPOSIT_DETAILS_HASH = keccak256( - "UpdateDepositDetails(uint32 depositId,uint256 originChainId,uint256 updatedOutputAmount,address updatedRecipient,bytes updatedMessage)" + "UpdateDepositDetails(uint256 depositId,uint256 originChainId,uint256 updatedOutputAmount,bytes32 updatedRecipient,bytes updatedMessage)" + ); + + bytes32 public constant UPDATE_V3_DEPOSIT_ADDRESS_OVERLOAD_DETAILS_HASH = + keccak256( + "UpdateDepositDetails(uint256 depositId,uint256 originChainId,uint256 updatedOutputAmount,address updatedRecipient,bytes updatedMessage)" ); // Default chain Id used to signify that no repayment is requested, for example when executing a slow fill. uint256 public constant EMPTY_REPAYMENT_CHAIN_ID = 0; // Default address used to signify that no relayer should be credited with a refund, for example // when executing a slow fill. - address public constant EMPTY_RELAYER = address(0); + bytes32 public constant EMPTY_RELAYER = bytes32(0); // This is the magic value that signals to the off-chain validator // that this deposit can never expire. A deposit with this fill deadline should always be eligible for a // slow fill, meaning that its output token and input token must be "equivalent". Therefore, this value is only // used as a fillDeadline in deposit(), a soon to be deprecated function that also hardcodes outputToken to // the zero address, which forces the off-chain validator to replace the output token with the equivalent // token for the input token. By using this magic value, off-chain validators do not have to keep - // this event in their lookback window when querying for expired deposts. + // this event in their lookback window when querying for expired deposits. uint32 public constant INFINITE_FILL_DEADLINE = type(uint32).max; // One year in seconds. If `exclusivityParameter` is set to a value less than this, then the emitted @@ -167,16 +183,17 @@ abstract contract SpokePool is uint32 indexed leafId, address l2TokenAddress, address[] refundAddresses, + bool deferredRefunds, address caller ); event TokensBridged( uint256 amountToReturn, uint256 indexed chainId, uint32 indexed leafId, - address indexed l2TokenAddress, + bytes32 indexed l2TokenAddress, address caller ); - event EmergencyDeleteRootBundle(uint256 indexed rootBundleId); + event EmergencyDeletedRootBundle(uint256 indexed rootBundleId); event PausedDeposits(bool isPaused); event PausedFills(bool isPaused); @@ -343,7 +360,7 @@ abstract contract SpokePool is // would require a new list in storage to keep track of keys. //slither-disable-next-line mapping-deletion delete rootBundles[rootBundleId]; - emit EmergencyDeleteRootBundle(rootBundleId); + emit EmergencyDeletedRootBundle(rootBundleId); } /************************************** @@ -474,7 +491,7 @@ abstract contract SpokePool is * the fill will revert on the destination chain. Must be set between [currentTime, currentTime + fillDeadlineBuffer] * where currentTime is block.timestamp on this chain or this transaction will revert. * @param exclusivityParameter This value is used to set the exclusivity deadline timestamp in the emitted deposit - * event. Before this destinationchain timestamp, only the exclusiveRelayer (if set to a non-zero address), + * event. Before this destination chain timestamp, only the exclusiveRelayer (if set to a non-zero address), * can fill this deposit. There are three ways to use this parameter: * 1. NO EXCLUSIVITY: If this value is set to 0, then a timestamp of 0 will be emitted, * meaning that there is no exclusivity period. @@ -487,6 +504,84 @@ abstract contract SpokePool is * @param message The message to send to the recipient on the destination chain if the recipient is a contract. * If the message is not empty, the recipient contract must implement handleV3AcrossMessage() or the fill will revert. */ + function depositV3( + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + bytes32 exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes calldata message + ) public payable override nonReentrant unpausedDeposits { + // Increment the `numberOfDeposits` counter to ensure a unique deposit ID for this spoke pool. + DepositV3Params memory params = DepositV3Params({ + depositor: depositor, + recipient: recipient, + inputToken: inputToken, + outputToken: outputToken, + inputAmount: inputAmount, + outputAmount: outputAmount, + destinationChainId: destinationChainId, + exclusiveRelayer: exclusiveRelayer, + depositId: numberOfDeposits++, + quoteTimestamp: quoteTimestamp, + fillDeadline: fillDeadline, + exclusivityParameter: exclusivityParameter, + message: message + }); + _depositV3(params); + } + + /** + * @notice An overloaded version of `depositV3` that accepts `address` types for backward compatibility. + * This function allows bridging of input tokens cross-chain to a destination chain, receiving a specified amount of output tokens. + * The relayer is refunded in input tokens on a repayment chain of their choice, minus system fees, after an optimistic challenge + * window. The exclusivity period is specified as an offset from the current block timestamp. + * + * @dev This version mirrors the original `depositV3` function, but uses `address` types for `depositor`, `recipient`, + * `inputToken`, `outputToken`, and `exclusiveRelayer` for compatibility with contracts using the `address` type. + * + * The key functionality and logic remain identical, ensuring interoperability across both versions. + * + * @param depositor The account credited with the deposit who can request to "speed up" this deposit by modifying + * the output amount, recipient, and message. + * @param recipient The account receiving funds on the destination chain. Can be an EOA or a contract. If + * the output token is the wrapped native token for the chain, then the recipient will receive native token if + * an EOA or wrapped native token if a contract. + * @param inputToken The token pulled from the caller's account and locked into this contract to initiate the deposit. + * The equivalent of this token on the relayer's repayment chain of choice will be sent as a refund. If this is equal + * to the wrapped native token, the caller can optionally pass in native token as msg.value, provided msg.value = inputTokenAmount. + * @param outputToken The token that the relayer will send to the recipient on the destination chain. Must be an ERC20. + * @param inputAmount The amount of input tokens pulled from the caller's account and locked into this contract. This + * amount will be sent to the relayer as a refund following an optimistic challenge window in the HubPool, less a system fee. + * @param outputAmount The amount of output tokens that the relayer will send to the recipient on the destination. + * @param destinationChainId The destination chain identifier. Must be enabled along with the input token as a valid + * deposit route from this spoke pool or this transaction will revert. + * @param exclusiveRelayer The relayer exclusively allowed to fill this deposit before the exclusivity deadline. + * @param quoteTimestamp The HubPool timestamp that determines the system fee paid by the depositor. This must be set + * between [currentTime - depositQuoteTimeBuffer, currentTime] where currentTime is block.timestamp on this chain. + * @param fillDeadline The deadline for the relayer to fill the deposit. After this destination chain timestamp, the fill will + * revert on the destination chain. Must be set between [currentTime, currentTime + fillDeadlineBuffer] where currentTime + * is block.timestamp on this chain. + * @param exclusivityParameter This value is used to set the exclusivity deadline timestamp in the emitted deposit + * event. Before this destination chain timestamp, only the exclusiveRelayer (if set to a non-zero address), + * can fill this deposit. There are three ways to use this parameter: + * 1. NO EXCLUSIVITY: If this value is set to 0, then a timestamp of 0 will be emitted, + * meaning that there is no exclusivity period. + * 2. OFFSET: If this value is less than MAX_EXCLUSIVITY_PERIOD_SECONDS, then add this value to + * the block.timestamp to derive the exclusive relayer deadline. Note that using the parameter in this way + * will expose the filler of the deposit to the risk that the block.timestamp of this event gets changed + * due to a chain-reorg, which would also change the exclusivity timestamp. + * 3. TIMESTAMP: Otherwise, set this value as the exclusivity deadline timestamp. + * which is the deadline for the exclusiveRelayer to fill the deposit. + * @param message The message to send to the recipient on the destination chain if the recipient is a contract. If the + * message is not empty, the recipient contract must implement `handleV3AcrossMessage()` or the fill will revert. + */ function depositV3( address depositor, address recipient, @@ -500,18 +595,16 @@ abstract contract SpokePool is uint32 fillDeadline, uint32 exclusivityParameter, bytes calldata message - ) public payable override nonReentrant unpausedDeposits { - _depositV3( - depositor, - recipient, - inputToken, - outputToken, + ) public payable override { + depositV3( + depositor.toBytes32(), + recipient.toBytes32(), + inputToken.toBytes32(), + outputToken.toBytes32(), inputAmount, outputAmount, destinationChainId, - exclusiveRelayer, - // Increment count of deposits so that deposit ID for this spoke pool is unique. - numberOfDeposits++, + exclusiveRelayer.toBytes32(), quoteTimestamp, fillDeadline, exclusivityParameter, @@ -519,6 +612,116 @@ abstract contract SpokePool is ); } + /** + * @notice An overloaded version of `unsafeDepositV3` that accepts `address` types for backward compatibility. * + * @dev This version mirrors the original `unsafeDepositV3` function, but uses `address` types for `depositor`, `recipient`, + * `inputToken`, `outputToken`, and `exclusiveRelayer` for compatibility with contracts using the `address` type. + * + * The key functionality and logic remain identical, ensuring interoperability across both versions. + */ + function unsafeDepositV3( + address depositor, + address recipient, + address inputToken, + address outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + address exclusiveRelayer, + uint256 depositNonce, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes calldata message + ) public payable { + unsafeDepositV3( + depositor.toBytes32(), + recipient.toBytes32(), + inputToken.toBytes32(), + outputToken.toBytes32(), + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer.toBytes32(), + depositNonce, + quoteTimestamp, + fillDeadline, + exclusivityParameter, + message + ); + } + + /** + * @notice See depositV3 for details. This function is identical to depositV3 except that it does not use the + * global deposit ID counter as a deposit nonce, instead allowing the caller to pass in a deposit nonce. This + * function is designed to be used by anyone who wants to pre-compute their resultant relay data hash, which + * could be useful for filling a deposit faster and avoiding any risk of a relay hash unexpectedly changing + * due to another deposit front-running this one and incrementing the global deposit ID counter. + * @dev This is labeled "unsafe" because there is no guarantee that the depositId emitted in the resultant + * V3FundsDeposited event is unique which means that the + * corresponding fill might collide with an existing relay hash on the destination chain SpokePool, + * which would make this deposit unfillable. In this case, the depositor would subsequently receive a refund + * of `inputAmount` of `inputToken` on the origin chain after the fill deadline. + * @dev On the destination chain, the hash of the deposit data will be used to uniquely identify this deposit, so + * modifying any params in it will result in a different hash and a different deposit. The hash will comprise + * all parameters to this function along with this chain's chainId(). Relayers are only refunded for filling + * deposits with deposit hashes that map exactly to the one emitted by this contract. + * @param depositNonce The nonce that uniquely identifies this deposit. This function will combine this parameter + * with the msg.sender address to create a unique uint256 depositNonce and ensure that the msg.sender cannot + * use this function to front-run another depositor's unsafe deposit. This function guarantees that the resultant + * deposit nonce will not collide with a safe uint256 deposit nonce whose 24 most significant bytes are always 0. + * @param depositor See identically named parameter in depositV3() comments. + * @param recipient See identically named parameter in depositV3() comments. + * @param inputToken See identically named parameter in depositV3() comments. + * @param outputToken See identically named parameter in depositV3() comments. + * @param inputAmount See identically named parameter in depositV3() comments. + * @param outputAmount See identically named parameter in depositV3() comments. + * @param destinationChainId See identically named parameter in depositV3() comments. + * @param exclusiveRelayer See identically named parameter in depositV3() comments. + * @param quoteTimestamp See identically named parameter in depositV3() comments. + * @param fillDeadline See identically named parameter in depositV3() comments. + * @param exclusivityParameter See identically named parameter in depositV3() comments. + * @param message See identically named parameter in depositV3() comments. + */ + function unsafeDepositV3( + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + bytes32 exclusiveRelayer, + uint256 depositNonce, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes calldata message + ) public payable nonReentrant unpausedDeposits { + // @dev Create the uint256 deposit ID by concatenating the msg.sender and depositor address with the inputted + // depositNonce parameter. The resultant 32 byte string will be hashed and then casted to an "unsafe" + // uint256 deposit ID. The probability that the resultant ID collides with a "safe" deposit ID is + // equal to the chance that the first 28 bytes of the hash are 0, which is too small for us to consider. + + uint256 depositId = getUnsafeDepositId(msg.sender, depositor, depositNonce); + DepositV3Params memory params = DepositV3Params({ + depositor: depositor, + recipient: recipient, + inputToken: inputToken, + outputToken: outputToken, + inputAmount: inputAmount, + outputAmount: outputAmount, + destinationChainId: destinationChainId, + exclusiveRelayer: exclusiveRelayer, + depositId: depositId, + quoteTimestamp: quoteTimestamp, + fillDeadline: fillDeadline, + exclusivityParameter: exclusivityParameter, + message: message + }); + _depositV3(params); + } + /** * @notice Submits deposit and sets quoteTimestamp to current Time. Sets fill and exclusivity * deadlines as offsets added to the current time. This function is designed to be called by users @@ -552,14 +755,14 @@ abstract contract SpokePool is * If the message is not empty, the recipient contract must implement handleV3AcrossMessage() or the fill will revert. */ function depositV3Now( - address depositor, - address recipient, - address inputToken, - address outputToken, + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 destinationChainId, - address exclusiveRelayer, + bytes32 exclusiveRelayer, uint32 fillDeadlineOffset, uint32 exclusivityPeriod, bytes calldata message @@ -581,44 +784,38 @@ abstract contract SpokePool is } /** - * @notice DEPRECATED. Use depositV3() instead. - * @notice Submits deposit and sets exclusivityDeadline to current time plus some offset. This function is - * designed to be called by users who want to set an exclusive relayer for some amount of time after their deposit - * transaction is mined. - * @notice If exclusivtyDeadlineOffset > 0, then exclusiveRelayer must be set to a valid address, which is a - * requirement imposed by depositV3(). - * @param depositor The account credited with the deposit who can request to "speed up" this deposit by modifying + * @notice An overloaded version of `depositV3Now` that supports addresses as input types for backward compatibility. + * This function submits a deposit and sets `quoteTimestamp` to the current time. The `fill` and `exclusivity` deadlines + * are set as offsets added to the current time. It is designed to be called by users, including Multisig contracts, who may + * not have certainty when their transaction will be mined. + * + * @dev This version is identical to the original `depositV3Now` but uses `address` types for `depositor`, `recipient`, + * `inputToken`, `outputToken`, and `exclusiveRelayer` to support compatibility with older systems. + * It maintains the same logic and purpose, ensuring interoperability with both versions. + * + * @param depositor The account credited with the deposit, who can request to "speed up" this deposit by modifying * the output amount, recipient, and message. * @param recipient The account receiving funds on the destination chain. Can be an EOA or a contract. If - * the output token is the wrapped native token for the chain, then the recipient will receive native token if + * the output token is the wrapped native token for the chain, then the recipient will receive the native token if * an EOA or wrapped native token if a contract. - * @param inputToken The token pulled from the caller's account and locked into this contract to - * initiate the deposit. The equivalent of this token on the relayer's repayment chain of choice will be sent - * as a refund. If this is equal to the wrapped native token then the caller can optionally pass in native token as - * msg.value, as long as msg.value = inputTokenAmount. - * @param outputToken The token that the relayer will send to the recipient on the destination chain. Must be an - * ERC20. - * @param inputAmount The amount of input tokens to pull from the caller's account and lock into this contract. - * This amount will be sent to the relayer on their repayment chain of choice as a refund following an optimistic - * challenge window in the HubPool, plus a system fee. - * @param outputAmount The amount of output tokens that the relayer will send to the recipient on the destination. - * @param destinationChainId The destination chain identifier. Must be enabled along with the input token - * as a valid deposit route from this spoke pool or this transaction will revert. - * @param exclusiveRelayer The relayer that will be exclusively allowed to fill this deposit before the - * exclusivity deadline timestamp. - * @param quoteTimestamp The HubPool timestamp that is used to determine the system fee paid by the depositor. - * This must be set to some time between [currentTime - depositQuoteTimeBuffer, currentTime] - * where currentTime is block.timestamp on this chain or this transaction will revert. - * @param fillDeadline The deadline for the relayer to fill the deposit. After this destination chain timestamp, - * the fill will revert on the destination chain. Must be set between [currentTime, currentTime + fillDeadlineBuffer] - * where currentTime is block.timestamp on this chain or this transaction will revert. - * @param exclusivityPeriod Added to the current time to set the exclusive reayer deadline, - * which is the deadline for the exclusiveRelayer to fill the deposit. After this destination chain timestamp, - * anyone can fill the deposit. - * @param message The message to send to the recipient on the destination chain if the recipient is a contract. - * If the message is not empty, the recipient contract must implement handleV3AcrossMessage() or the fill will revert. + * @param inputToken The token pulled from the caller's account and locked into this contract to initiate the deposit. + * Equivalent tokens on the relayer's repayment chain will be sent as a refund. If this is the wrapped native token, + * msg.value must equal inputTokenAmount when passed. + * @param outputToken The token the relayer will send to the recipient on the destination chain. Must be an ERC20. + * @param inputAmount The amount of input tokens pulled from the caller's account and locked into this contract. + * This amount will be sent to the relayer as a refund following an optimistic challenge window in the HubPool, plus a system fee. + * @param outputAmount The amount of output tokens the relayer will send to the recipient on the destination. + * @param destinationChainId The destination chain identifier. Must be enabled with the input token as a valid deposit route + * from this spoke pool, or the transaction will revert. + * @param exclusiveRelayer The relayer exclusively allowed to fill the deposit before the exclusivity deadline. + * @param fillDeadlineOffset Added to the current time to set the fill deadline. After this timestamp, fills on the + * destination chain will revert. + * @param exclusivityPeriod Added to the current time to set the exclusive relayer deadline. After this timestamp, + * anyone can fill the deposit until the fill deadline. + * @param message The message to send to the recipient on the destination chain. If the recipient is a contract, it must + * implement `handleV3AcrossMessage()` if the message is not empty, or the fill will revert. */ - function depositExclusive( + function depositV3Now( address depositor, address recipient, address inputToken, @@ -627,11 +824,10 @@ abstract contract SpokePool is uint256 outputAmount, uint256 destinationChainId, address exclusiveRelayer, - uint32 quoteTimestamp, - uint32 fillDeadline, + uint32 fillDeadlineOffset, uint32 exclusivityPeriod, bytes calldata message - ) public payable { + ) external payable { depositV3( depositor, recipient, @@ -641,8 +837,8 @@ abstract contract SpokePool is outputAmount, destinationChainId, exclusiveRelayer, - quoteTimestamp, - fillDeadline, + uint32(getCurrentTime()), + uint32(getCurrentTime()) + fillDeadlineOffset, exclusivityPeriod, message ); @@ -667,21 +863,22 @@ abstract contract SpokePool is * _verifyUpdateV3DepositMessage() for more details about how this signature should be constructed. */ function speedUpV3Deposit( - address depositor, - uint32 depositId, + bytes32 depositor, + uint256 depositId, uint256 updatedOutputAmount, - address updatedRecipient, + bytes32 updatedRecipient, bytes calldata updatedMessage, bytes calldata depositorSignature ) public override nonReentrant { _verifyUpdateV3DepositMessage( - depositor, + depositor.toAddress(), depositId, chainId(), updatedOutputAmount, updatedRecipient, updatedMessage, - depositorSignature + depositorSignature, + UPDATE_V3_DEPOSIT_DETAILS_HASH ); // Assuming the above checks passed, a relayer can take the signature and the updated deposit information @@ -696,6 +893,60 @@ abstract contract SpokePool is ); } + /** + * @notice An overloaded version of `speedUpV3Deposit` using `address` types for backward compatibility. + * This function allows the depositor to signal to the relayer to use updated output amount, recipient, and/or message + * when filling a deposit. This can be useful when the deposit needs to be modified after the original transaction has + * been mined. + * + * @dev The `depositor` and `depositId` must match the parameters in a `V3FundsDeposited` event that the depositor wants to speed up. + * The relayer is not obligated but has the option to use this updated information when filling the deposit using + * `fillV3RelayWithUpdatedDeposit()`. This version uses `address` types for compatibility with systems relying on + * `address`-based implementations. + * + * @param depositor The depositor that must sign the `depositorSignature` and was the original depositor. + * @param depositId The deposit ID to speed up. + * @param updatedOutputAmount The new output amount to use for this deposit. It should be lower than the previous value, + * otherwise the relayer has no incentive to use this updated value. + * @param updatedRecipient The new recipient for this deposit. Can be modified if the original recipient is a contract that + * expects to receive a message from the relay and needs to be changed. + * @param updatedMessage The new message for this deposit. Can be modified if the recipient is a contract that expects + * to receive a message from the relay and needs to be updated. + * @param depositorSignature The signed EIP712 hashstruct containing the deposit ID. Should be signed by the depositor account. + * If the depositor is a contract, it should implement EIP1271 to sign as a contract. See `_verifyUpdateV3DepositMessage()` + * for more details on how the signature should be constructed. + */ + function speedUpV3Deposit( + address depositor, + uint256 depositId, + uint256 updatedOutputAmount, + address updatedRecipient, + bytes calldata updatedMessage, + bytes calldata depositorSignature + ) public { + _verifyUpdateV3DepositMessage( + depositor, + depositId, + chainId(), + updatedOutputAmount, + updatedRecipient.toBytes32(), + updatedMessage, + depositorSignature, + UPDATE_V3_DEPOSIT_ADDRESS_OVERLOAD_DETAILS_HASH + ); + + // Assuming the above checks passed, a relayer can take the signature and the updated deposit information + // from the following event to submit a fill with updated relay data. + emit RequestedSpeedUpV3Deposit( + updatedOutputAmount, + depositId, + depositor.toBytes32(), + updatedRecipient.toBytes32(), + updatedMessage, + depositorSignature + ); + } + /************************************** * RELAYER FUNCTIONS * **************************************/ @@ -743,17 +994,16 @@ abstract contract SpokePool is * @param repaymentChainId Chain of SpokePool where relayer wants to be refunded after the challenge window has * passed. Will receive inputAmount of the equivalent token to inputToken on the repayment chain. */ - function fillV3Relay(V3RelayData calldata relayData, uint256 repaymentChainId) - public - override - nonReentrant - unpausedFills - { + function fillV3Relay( + V3RelayData calldata relayData, + uint256 repaymentChainId, + bytes32 repaymentAddress + ) public override nonReentrant unpausedFills { // Exclusivity deadline is inclusive and is the latest timestamp that the exclusive relayer has sole right // to fill the relay. if ( _fillIsExclusive(relayData.exclusivityDeadline, uint32(getCurrentTime())) && - relayData.exclusiveRelayer != msg.sender + relayData.exclusiveRelayer.toAddress() != msg.sender ) { revert NotExclusiveRelayer(); } @@ -767,7 +1017,7 @@ abstract contract SpokePool is repaymentChainId: repaymentChainId }); - _fillRelayV3(relayExecution, msg.sender, false); + _fillRelayV3(relayExecution, repaymentAddress, false); } /** @@ -775,7 +1025,7 @@ abstract contract SpokePool is * recipient, and/or message. The relayer should only use this function if they can supply a message signed * by the depositor that contains the fill's matching deposit ID along with updated relay parameters. * If the signature can be verified, then this function will emit a FilledV3Event that will be used by - * the system for refund verification purposes. In otherwords, this function is an alternative way to fill a + * the system for refund verification purposes. In other words, this function is an alternative way to fill a * a deposit than fillV3Relay. * @dev Subject to same exclusivity deadline rules as fillV3Relay(). * @param relayData struct containing all the data needed to identify the deposit to be filled. See fillV3Relay(). @@ -790,8 +1040,9 @@ abstract contract SpokePool is function fillV3RelayWithUpdatedDeposit( V3RelayData calldata relayData, uint256 repaymentChainId, + bytes32 repaymentAddress, uint256 updatedOutputAmount, - address updatedRecipient, + bytes32 updatedRecipient, bytes calldata updatedMessage, bytes calldata depositorSignature ) public override nonReentrant unpausedFills { @@ -799,7 +1050,7 @@ abstract contract SpokePool is // to fill the relay. if ( _fillIsExclusive(relayData.exclusivityDeadline, uint32(getCurrentTime())) && - relayData.exclusiveRelayer != msg.sender + relayData.exclusiveRelayer.toAddress() != msg.sender ) { revert NotExclusiveRelayer(); } @@ -814,16 +1065,17 @@ abstract contract SpokePool is }); _verifyUpdateV3DepositMessage( - relayData.depositor, + relayData.depositor.toAddress(), relayData.depositId, relayData.originChainId, updatedOutputAmount, updatedRecipient, updatedMessage, - depositorSignature + depositorSignature, + UPDATE_V3_DEPOSIT_DETAILS_HASH ); - _fillRelayV3(relayExecution, msg.sender, false); + _fillRelayV3(relayExecution, repaymentAddress, false); } /** @@ -868,7 +1120,7 @@ abstract contract SpokePool is relayData.exclusiveRelayer, relayData.depositor, relayData.recipient, - relayData.message + _hashNonEmptyMessage(relayData.message) ); } @@ -897,7 +1149,10 @@ abstract contract SpokePool is // Must do a delegatecall because the function requires the inputs to be calldata. (bool success, bytes memory data) = address(this).delegatecall( - abi.encodeCall(V3SpokePoolInterface.fillV3Relay, (relayData, destinationFillerData.repaymentChainId)) + abi.encodeCall( + V3SpokePoolInterface.fillV3Relay, + (relayData, destinationFillerData.repaymentChainId, msg.sender.toBytes32()) + ) ); if (!success) { revert LowLevelCallFailed(data); @@ -932,7 +1187,7 @@ abstract contract SpokePool is ) public override nonReentrant { V3RelayData memory relayData = slowFillLeaf.relayData; - _preExecuteLeafHook(relayData.outputToken); + _preExecuteLeafHook(relayData.outputToken.toAddress()); // @TODO In the future consider allowing way for slow fill leaf to be created with updated // deposit params like outputAmount, message and recipient. @@ -972,12 +1227,13 @@ abstract contract SpokePool is // Check that proof proves that relayerRefundLeaf is contained within the relayer refund root. // Note: This should revert if the relayerRefundRoot is uninitialized. - if (!MerkleLib.verifyRelayerRefund(rootBundle.relayerRefundRoot, relayerRefundLeaf, proof)) + if (!MerkleLib.verifyRelayerRefund(rootBundle.relayerRefundRoot, relayerRefundLeaf, proof)) { revert InvalidMerkleProof(); + } _setClaimedLeaf(rootBundleId, relayerRefundLeaf.leafId); - _distributeRelayerRefunds( + bool deferredRefunds = _distributeRelayerRefunds( relayerRefundLeaf.chainId, relayerRefundLeaf.amountToReturn, relayerRefundLeaf.refundAmounts, @@ -994,10 +1250,27 @@ abstract contract SpokePool is relayerRefundLeaf.leafId, relayerRefundLeaf.l2TokenAddress, relayerRefundLeaf.refundAddresses, + deferredRefunds, msg.sender ); } + /** + * @notice Enables a relayer to claim outstanding repayments. Should virtually never be used, unless for some reason + * relayer repayment transfer fails for reasons such as token transfer reverts due to blacklisting. In this case, + * the relayer can still call this method and claim the tokens to a new address. + * @param l2TokenAddress Address of the L2 token to claim refunds for. + * @param refundAddress Address to send the refund to. + */ + function claimRelayerRefund(bytes32 l2TokenAddress, bytes32 refundAddress) public { + uint256 refund = relayerRefund[l2TokenAddress.toAddress()][msg.sender]; + if (refund == 0) revert NoRelayerRefundToClaim(); + relayerRefund[l2TokenAddress.toAddress()][refundAddress.toAddress()] = 0; + IERC20Upgradeable(l2TokenAddress.toAddress()).safeTransfer(refundAddress.toAddress(), refund); + + emit ClaimedRelayerRefund(l2TokenAddress, refundAddress, refund, msg.sender); + } + /************************************** * VIEW FUNCTIONS * **************************************/ @@ -1018,28 +1291,36 @@ abstract contract SpokePool is return block.timestamp; // solhint-disable-line not-rely-on-time } + /** + * @notice Returns the deposit ID for an unsafe deposit. This function is used to compute the deposit ID + * in unsafeDepositV3 and is provided as a convenience. + * @dev msgSenderand depositor are both used as inputs to allow passthrough depositors to create unique + * deposit hash spaces for unique depositors. + * @param msgSender The caller of the transaction used as input to produce the deposit ID. + * @param depositor The depositor address used as input to produce the deposit ID. + * @param depositNonce The nonce used as input to produce the deposit ID. + * @return The deposit ID for the unsafe deposit. + */ + function getUnsafeDepositId( + address msgSender, + bytes32 depositor, + uint256 depositNonce + ) public pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(msgSender, depositor, depositNonce))); + } + + function getRelayerRefund(address l2TokenAddress, address refundAddress) public view returns (uint256) { + return relayerRefund[l2TokenAddress][refundAddress]; + } + /************************************** * INTERNAL FUNCTIONS * **************************************/ - function _depositV3( - address depositor, - address recipient, - address inputToken, - address outputToken, - uint256 inputAmount, - uint256 outputAmount, - uint256 destinationChainId, - address exclusiveRelayer, - uint32 depositId, - uint32 quoteTimestamp, - uint32 fillDeadline, - uint32 exclusivityParameter, - bytes calldata message - ) internal { + function _depositV3(DepositV3Params memory params) internal { // Check that deposit route is enabled for the input token. There are no checks required for the output token // which is pulled from the relayer at fill time and passed through this contract atomically to the recipient. - if (!enabledDepositRoutes[inputToken][destinationChainId]) revert DisabledRoute(); + if (!enabledDepositRoutes[params.inputToken.toAddress()][params.destinationChainId]) revert DisabledRoute(); // Require that quoteTimestamp has a maximum age so that depositors pay an LP fee based on recent HubPool usage. // It is assumed that cross-chain timestamps are normally loosely in-sync, but clock drift can occur. If the @@ -1050,7 +1331,8 @@ abstract contract SpokePool is // slither-disable-next-line timestamp uint256 currentTime = getCurrentTime(); - if (currentTime - quoteTimestamp > depositQuoteTimeBuffer) revert InvalidQuoteTimestamp(); + if (currentTime < params.quoteTimestamp || currentTime - params.quoteTimestamp > depositQuoteTimeBuffer) + revert InvalidQuoteTimestamp(); // fillDeadline is relative to the destination chain. // Don’t allow fillDeadline to be more than several bundles into the future. @@ -1059,7 +1341,8 @@ abstract contract SpokePool is // chain time keeping and this chain's time keeping are out of sync but is not really a practical hurdle // unless they are significantly out of sync or the depositor is setting very short fill deadlines. This latter // situation won't be a problem for honest users. - if (fillDeadline < currentTime || fillDeadline > currentTime + fillDeadlineBuffer) revert InvalidFillDeadline(); + if (params.fillDeadline < currentTime || params.fillDeadline > currentTime + fillDeadlineBuffer) + revert InvalidFillDeadline(); // There are three cases for setting the exclusivity deadline using the exclusivity parameter: // 1. If this parameter is 0, then there is no exclusivity period and emit 0 for the deadline. This @@ -1072,7 +1355,7 @@ abstract contract SpokePool is // 3. Otherwise, interpret this parameter as a timestamp and emit it as the exclusivity deadline. This means // that the filler of this deposit will not assume re-org risk related to the block.timestamp of this // event changing. - uint32 exclusivityDeadline = exclusivityParameter; + uint32 exclusivityDeadline = params.exclusivityParameter; if (exclusivityDeadline > 0) { if (exclusivityDeadline <= MAX_EXCLUSIVITY_PERIOD_SECONDS) { exclusivityDeadline += uint32(currentTime); @@ -1080,14 +1363,14 @@ abstract contract SpokePool is // As a safety measure, prevent caller from inadvertently locking funds during exclusivity period // by forcing them to specify an exclusive relayer. - if (exclusiveRelayer == address(0)) revert InvalidExclusiveRelayer(); + if (params.exclusiveRelayer == bytes32(0)) revert InvalidExclusiveRelayer(); } // If the address of the origin token is a wrappedNativeToken contract and there is a msg.value with the // transaction then the user is sending the native token. In this case, the native token should be // wrapped. - if (inputToken == address(wrappedNativeToken) && msg.value > 0) { - if (msg.value != inputAmount) revert MsgValueDoesNotMatchInputAmount(); + if (params.inputToken == address(wrappedNativeToken).toBytes32() && msg.value > 0) { + if (msg.value != params.inputAmount) revert MsgValueDoesNotMatchInputAmount(); wrappedNativeToken.deposit{ value: msg.value }(); // Else, it is a normal ERC20. In this case pull the token from the caller as per normal. // Note: this includes the case where the L2 caller has WETH (already wrapped ETH) and wants to bridge them. @@ -1095,23 +1378,27 @@ abstract contract SpokePool is } else { // msg.value should be 0 if input token isn't the wrapped native token. if (msg.value != 0) revert MsgValueDoesNotMatchInputAmount(); - IERC20Upgradeable(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); + IERC20Upgradeable(params.inputToken.toAddress()).safeTransferFrom( + msg.sender, + address(this), + params.inputAmount + ); } emit V3FundsDeposited( - inputToken, - outputToken, - inputAmount, - outputAmount, - destinationChainId, - depositId, - quoteTimestamp, - fillDeadline, + params.inputToken, + params.outputToken, + params.inputAmount, + params.outputAmount, + params.destinationChainId, + params.depositId, + params.quoteTimestamp, + params.fillDeadline, exclusivityDeadline, - depositor, - recipient, - exclusiveRelayer, - message + params.depositor, + params.recipient, + params.exclusiveRelayer, + params.message ); } @@ -1152,11 +1439,13 @@ abstract contract SpokePool is // Else, it is a normal ERC20. In this case pull the token from the user's wallet as per normal. // Note: this includes the case where the L2 user has WETH (already wrapped ETH) and wants to bridge them. // In this case the msg.value will be set to 0, indicating a "normal" ERC20 bridging action. - } else IERC20Upgradeable(originToken).safeTransferFrom(msg.sender, address(this), amount); + } else { + IERC20Upgradeable(originToken).safeTransferFrom(msg.sender, address(this), amount); + } emit V3FundsDeposited( - originToken, // inputToken - address(0), // outputToken. Setting this to 0x0 means that the outputToken should be assumed to be the + originToken.toBytes32(), // inputToken + bytes32(0), // outputToken. Setting this to 0x0 means that the outputToken should be assumed to be the // canonical token for the destination chain matching the inputToken. Therefore, this deposit // can always be slow filled. // - setting token to 0x0 will signal to off-chain validator that the "equivalent" @@ -1173,9 +1462,9 @@ abstract contract SpokePool is // expired deposits refunds could be a breaking change for existing users of this function. 0, // exclusivityDeadline. Setting this to 0 along with the exclusiveRelayer to 0x0 means that there // is no exclusive deadline - depositor, - recipient, - address(0), // exclusiveRelayer. Setting this to 0x0 will signal to off-chain validator that there + depositor.toBytes32(), + recipient.toBytes32(), + bytes32(0), // exclusiveRelayer. Setting this to 0x0 will signal to off-chain validator that there // is no exclusive relayer. message ); @@ -1188,24 +1477,65 @@ abstract contract SpokePool is uint32 leafId, address l2TokenAddress, address[] memory refundAddresses - ) internal { - if (refundAddresses.length != refundAmounts.length) revert InvalidMerkleLeaf(); - - // Send each relayer refund address the associated refundAmount for the L2 token address. - // Note: Even if the L2 token is not enabled on this spoke pool, we should still refund relayers. - uint256 length = refundAmounts.length; - for (uint256 i = 0; i < length; ++i) { - uint256 amount = refundAmounts[i]; - if (amount > 0) IERC20Upgradeable(l2TokenAddress).safeTransfer(refundAddresses[i], amount); + ) internal returns (bool deferredRefunds) { + uint256 numRefunds = refundAmounts.length; + if (refundAddresses.length != numRefunds) revert InvalidMerkleLeaf(); + + if (numRefunds > 0) { + uint256 spokeStartBalance = IERC20Upgradeable(l2TokenAddress).balanceOf(address(this)); + uint256 totalRefundedAmount = 0; // Track the total amount refunded. + + // Send each relayer refund address the associated refundAmount for the L2 token address. + // Note: Even if the L2 token is not enabled on this spoke pool, we should still refund relayers. + for (uint256 i = 0; i < numRefunds; ++i) { + if (refundAmounts[i] > 0) { + totalRefundedAmount += refundAmounts[i]; + + // Only if the total refunded amount exceeds the spoke starting balance, should we revert. This + // ensures that bundles are atomic, if we have sufficient balance to refund all relayers and + // prevents can only re-pay some of the relayers. + if (totalRefundedAmount > spokeStartBalance) revert InsufficientSpokePoolBalanceToExecuteLeaf(); + + bool success = _noRevertTransfer(l2TokenAddress, refundAddresses[i], refundAmounts[i]); + + // If the transfer failed then track a deferred transfer for the relayer. Given this function would + // have revered if there was insufficient balance, this will only happen if the transfer call + // reverts. This will only occur if the underlying transfer method on the l2Token reverts due to + // recipient blacklisting or other related modifications to the l2Token.transfer method. + if (!success) { + relayerRefund[l2TokenAddress][refundAddresses[i]] += refundAmounts[i]; + deferredRefunds = true; + } + } + } } - // If leaf's amountToReturn is positive, then send L2 --> L1 message to bridge tokens back via // chain-specific bridging method. if (amountToReturn > 0) { _bridgeTokensToHubPool(amountToReturn, l2TokenAddress); - emit TokensBridged(amountToReturn, _chainId, leafId, l2TokenAddress, msg.sender); + emit TokensBridged(amountToReturn, _chainId, leafId, l2TokenAddress.toBytes32(), msg.sender); + } + } + + // Re-implementation of OZ _callOptionalReturnBool to use private logic. Function executes a transfer and returns a + // bool indicating if the external call was successful, rather than reverting. Original method: + // https://github.com/OpenZeppelin/openzeppelin-contracts/blob/28aed34dc5e025e61ea0390c18cac875bfde1a78/contracts/token/ERC20/utils/SafeERC20.sol#L188 + function _noRevertTransfer( + address token, + address to, + uint256 amount + ) internal returns (bool) { + bool success; + uint256 returnSize; + uint256 returnValue; + bytes memory data = abi.encodeCall(IERC20Upgradeable.transfer, (to, amount)); + assembly { + success := call(gas(), token, 0, add(data, 0x20), mload(data), 0, 0x20) + returnSize := returndatasize() + returnValue := mload(0) } + return success && (returnSize == 0 ? address(token).code.length > 0 : returnValue == 1); } function _setCrossDomainAdmin(address newCrossDomainAdmin) internal { @@ -1241,24 +1571,21 @@ abstract contract SpokePool is function _verifyUpdateV3DepositMessage( address depositor, - uint32 depositId, + uint256 depositId, uint256 originChainId, uint256 updatedOutputAmount, - address updatedRecipient, + bytes32 updatedRecipient, bytes memory updatedMessage, - bytes memory depositorSignature + bytes memory depositorSignature, + bytes32 hashType ) internal view { // A depositor can request to modify an un-relayed deposit by signing a hash containing the updated // details and information uniquely identifying the deposit to relay. This information ensures // that this signature cannot be re-used for other deposits. - // Note: We use the EIP-712 (https://eips.ethereum.org/EIPS/eip-712) standard for hashing and signing typed data. - // Specifically, we use the version of the encoding known as "v4", as implemented by the JSON RPC method - // `eth_signedTypedDataV4` in MetaMask (https://docs.metamask.io/guide/signing-data.html). bytes32 expectedTypedDataV4Hash = _hashTypedDataV4( - // EIP-712 compliant hash struct: https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct keccak256( abi.encode( - UPDATE_V3_DEPOSIT_DETAILS_HASH, + hashType, depositId, originChainId, updatedOutputAmount, @@ -1266,7 +1593,6 @@ abstract contract SpokePool is keccak256(updatedMessage) ) ), - // By passing in the origin chain id, we enable the verification of the signature on a different chain originChainId ); _verifyDepositorSignature(depositor, expectedTypedDataV4Hash, depositorSignature); @@ -1282,7 +1608,7 @@ abstract contract SpokePool is bytes memory depositorSignature ) internal view virtual { // Note: - // - We don't need to worry about reentrancy from a contract deployed at the depositor address since the method + // - We don't need to worry about re-entrancy from a contract deployed at the depositor address since the method // `SignatureChecker.isValidSignatureNow` is a view method. Re-entrancy can happen, but it cannot affect state. // - EIP-1271 signatures are supported. This means that a signature valid now, may not be valid later and vice-versa. // - For an EIP-1271 signature to work, the depositor contract address must map to a deployed contract on the destination @@ -1303,8 +1629,9 @@ abstract contract SpokePool is updatedOutputAmount: relayExecution.updatedOutputAmount }); - if (!MerkleLib.verifyV3SlowRelayFulfillment(rootBundles[rootBundleId].slowRelayRoot, slowFill, proof)) + if (!MerkleLib.verifyV3SlowRelayFulfillment(rootBundles[rootBundleId].slowRelayRoot, slowFill, proof)) { revert InvalidMerkleProof(); + } } function _computeAmountPostFees(uint256 amount, int256 feesPct) private pure returns (uint256) { @@ -1312,7 +1639,24 @@ abstract contract SpokePool is } function _getV3RelayHash(V3RelayData memory relayData) private view returns (bytes32) { - return keccak256(abi.encode(relayData, chainId())); + return + keccak256( + abi.encode( + relayData.depositor, + relayData.recipient, + relayData.exclusiveRelayer, + relayData.inputToken, + relayData.outputToken, + relayData.inputAmount, + relayData.outputAmount, + relayData.originChainId, + relayData.depositId, + relayData.fillDeadline, + relayData.exclusivityDeadline, + _hashNonEmptyMessage(relayData.message), + chainId() + ) + ); } // Unwraps ETH and does a transfer to a recipient address. If the recipient is a smart contract then sends wrappedNativeToken. @@ -1329,7 +1673,7 @@ abstract contract SpokePool is // exclusiveRelayer if passed exclusivityDeadline or if slow fill. function _fillRelayV3( V3RelayExecutionParams memory relayExecution, - address relayer, + bytes32 relayer, bool isSlowFill ) internal { V3RelayData memory relayData = relayExecution.relay; @@ -1345,9 +1689,8 @@ abstract contract SpokePool is // event to assist the Dataworker in knowing when to return funds back to the HubPool that can no longer // be used for a slow fill execution. FillType fillType = isSlowFill - ? FillType.SlowFill + ? FillType.SlowFill // The following is true if this is a fast fill that was sent after a slow fill request. : ( - // The following is true if this is a fast fill that was sent after a slow fill request. fillStatuses[relayExecution.relayHash] == uint256(FillStatus.RequestedSlowFill) ? FillType.ReplacedSlowFill : FillType.FastFill @@ -1378,10 +1721,10 @@ abstract contract SpokePool is relayer, relayData.depositor, relayData.recipient, - relayData.message, + _hashNonEmptyMessage(relayData.message), V3RelayExecutionEventInfo({ updatedRecipient: relayExecution.updatedRecipient, - updatedMessage: relayExecution.updatedMessage, + updatedMessageHash: _hashNonEmptyMessage(relayExecution.updatedMessage), updatedOutputAmount: relayExecution.updatedOutputAmount, fillType: fillType }) @@ -1393,12 +1736,12 @@ abstract contract SpokePool is // way (no need to have funds on the destination). // If this is a slow fill, we can't exit early since we still need to send funds out of this contract // since there is no "relayer". - address recipientToSend = relayExecution.updatedRecipient; + address recipientToSend = relayExecution.updatedRecipient.toAddress(); if (msg.sender == recipientToSend && !isSlowFill) return; // If relay token is wrappedNativeToken then unwrap and send native token. - address outputToken = relayData.outputToken; + address outputToken = relayData.outputToken.toAddress(); uint256 amountToSend = relayExecution.updatedOutputAmount; if (outputToken == address(wrappedNativeToken)) { // Note: useContractFunds is True if we want to send funds to the recipient directly out of this contract, @@ -1431,6 +1774,12 @@ abstract contract SpokePool is return exclusivityDeadline >= currentTime; } + // Helper for emitting message hash. For easier easier human readability we return bytes32(0) for empty message. + function _hashNonEmptyMessage(bytes memory message) internal pure returns (bytes32) { + if (message.length == 0) return bytes32(0); + else return keccak256(message); + } + // Implementing contract needs to override this to ensure that only the appropriate cross chain admin can execute // certain admin functions. For L2 contracts, the cross chain admin refers to some L1 address or contract, and for // L1, this would just be the same admin of the HubPool. @@ -1442,5 +1791,5 @@ abstract contract SpokePool is // Reserve storage slots for future versions of this base contract to add state variables without // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables // are added. This is at bottom of contract to make sure it's always at the end of storage. - uint256[999] private __gap; + uint256[998] private __gap; } diff --git a/contracts/SpokePoolVerifier.sol b/contracts/SpokePoolVerifier.sol index 5dc333684..2aa038498 100644 --- a/contracts/SpokePoolVerifier.sol +++ b/contracts/SpokePoolVerifier.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/utils/Address.sol"; import "./interfaces/V3SpokePoolInterface.sol"; +import { AddressToBytes32 } from "./libraries/AddressConverters.sol"; /** * @notice SpokePoolVerifier is a contract that verifies that the SpokePool exists on this chain before sending ETH to it. @@ -14,6 +15,7 @@ import "./interfaces/V3SpokePoolInterface.sol"; */ contract SpokePoolVerifier { using Address for address; + using AddressToBytes32 for address; error InvalidMsgValue(); error InvalidSpokePool(); @@ -42,12 +44,12 @@ contract SpokePoolVerifier { */ function deposit( V3SpokePoolInterface spokePool, - address recipient, - address inputToken, + bytes32 recipient, + bytes32 inputToken, uint256 inputAmount, uint256 outputAmount, uint256 destinationChainId, - address exclusiveRelayer, + bytes32 exclusiveRelayer, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityDeadline, @@ -57,12 +59,12 @@ contract SpokePoolVerifier { if (!address(spokePool).isContract()) revert InvalidSpokePool(); // Set msg.sender as the depositor so that msg.sender can speed up the deposit. spokePool.depositV3{ value: msg.value }( - msg.sender, + msg.sender.toBytes32(), recipient, inputToken, // @dev Setting outputToken to 0x0 to instruct fillers to use the equivalent token // as the originToken on the destination chain. - address(0), + bytes32(0), inputAmount, outputAmount, destinationChainId, diff --git a/contracts/SwapAndBridge.sol b/contracts/SwapAndBridge.sol index 4c6cf84d7..284d2f28f 100644 --- a/contracts/SwapAndBridge.sol +++ b/contracts/SwapAndBridge.sol @@ -9,6 +9,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import "./Lockable.sol"; import "@uma/core/contracts/common/implementation/MultiCaller.sol"; +import "./libraries/AddressConverters.sol"; /** * @title SwapAndBridgeBase @@ -18,6 +19,7 @@ import "@uma/core/contracts/common/implementation/MultiCaller.sol"; */ abstract contract SwapAndBridgeBase is Lockable, MultiCaller { using SafeERC20 for IERC20; + using AddressToBytes32 for address; // This contract performs a low level call with arbirary data to an external contract. This is a large attack // surface and we should whitelist which function selectors are allowed to be called on the exchange. @@ -182,14 +184,14 @@ abstract contract SwapAndBridgeBase is Lockable, MultiCaller { ) internal { _acrossInputToken.safeIncreaseAllowance(address(spokePool), _acrossInputAmount); spokePool.depositV3( - depositData.depositor, - depositData.recipient, - address(_acrossInputToken), // input token - depositData.outputToken, // output token + depositData.depositor.toBytes32(), + depositData.recipient.toBytes32(), + address(_acrossInputToken).toBytes32(), // input token + depositData.outputToken.toBytes32(), // output token _acrossInputAmount, // input amount. depositData.outputAmount, // output amount depositData.destinationChainid, - depositData.exclusiveRelayer, + depositData.exclusiveRelayer.toBytes32(), depositData.quoteTimestamp, depositData.fillDeadline, depositData.exclusivityDeadline, diff --git a/contracts/chain-adapters/Solana_Adapter.sol b/contracts/chain-adapters/Solana_Adapter.sol new file mode 100644 index 000000000..429500e83 --- /dev/null +++ b/contracts/chain-adapters/Solana_Adapter.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IMessageTransmitter, ITokenMessenger } from "../external/interfaces/CCTPInterfaces.sol"; +import { SpokePoolInterface } from "../interfaces/SpokePoolInterface.sol"; +import { AdapterInterface } from "./interfaces/AdapterInterface.sol"; +import { CircleCCTPAdapter, CircleDomainIds } from "../libraries/CircleCCTPAdapter.sol"; +import { Bytes32ToAddress } from "../libraries/AddressConverters.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @notice Contract containing logic to send messages from L1 to Solana via CCTP. + * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be + * called via delegatecall, which will execute this contract's logic within the context of the originating contract. + * For example, the HubPool will delegatecall these functions, therefore it's only necessary that the HubPool's methods + * that call this contract's logic guard against reentrancy. + * @custom:security-contact bugs@across.to + */ + +// solhint-disable-next-line contract-name-camelcase +contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter { + /** + * @notice We use Bytes32ToAddress library to map a Solana address to an Ethereum address representation. + * @dev The Ethereum address is derived from the Solana address by truncating it to its lowest 20 bytes. This same + * conversion must be done by the HubPool owner when adding Solana spoke pool and setting the corresponding pool + * rebalance and deposit routes. + */ + using Bytes32ToAddress for bytes32; + + /** + * @notice The official Circle CCTP MessageTransmitter contract endpoint. + * @dev Posted officially here: https://developers.circle.com/stablecoins/docs/evm-smart-contracts + */ + // solhint-disable-next-line immutable-vars-naming + IMessageTransmitter public immutable cctpMessageTransmitter; + + // Solana spoke pool address, decoded from Base58 to bytes32. + bytes32 public immutable SOLANA_SPOKE_POOL_BYTES32; + + // Solana spoke pool address, mapped to its EVM address representation. + address public immutable SOLANA_SPOKE_POOL_ADDRESS; + + // USDC mint address on Solana, decoded from Base58 to bytes32. + bytes32 public immutable SOLANA_USDC_BYTES32; + + // USDC mint address on Solana, mapped to its EVM address representation. + address public immutable SOLANA_USDC_ADDRESS; + + // USDC token address on Solana for the spoke pool (vault ATA), decoded from Base58 to bytes32. + bytes32 public immutable SOLANA_SPOKE_POOL_USDC_VAULT; + + // Custom errors for constructor argument validation. + error InvalidCctpTokenMessenger(address tokenMessenger); + error InvalidCctpMessageTransmitter(address messageTransmitter); + + // Custom errors for relayMessage validation. + error InvalidRelayMessageTarget(address target); + error InvalidOriginToken(address originToken); + error InvalidDestinationChainId(uint256 destinationChainId); + + // Custom errors for relayTokens validation. + error InvalidL1Token(address l1Token); + error InvalidL2Token(address l2Token); + error InvalidAmount(uint256 amount); + error InvalidTokenRecipient(address to); + + /** + * @notice Constructs new Adapter. + * @param _l1Usdc USDC address on L1. + * @param _cctpTokenMessenger TokenMessenger contract to bridge tokens via CCTP. + * @param _cctpMessageTransmitter MessageTransmitter contract to bridge messages via CCTP. + * @param solanaSpokePool Solana spoke pool address, decoded from Base58 to bytes32. + * @param solanaUsdc USDC mint address on Solana, decoded from Base58 to bytes32. + * @param solanaSpokePoolUsdcVault USDC token address on Solana for the spoke pool, decoded from Base58 to bytes32. + */ + constructor( + IERC20 _l1Usdc, + ITokenMessenger _cctpTokenMessenger, + IMessageTransmitter _cctpMessageTransmitter, + bytes32 solanaSpokePool, + bytes32 solanaUsdc, + bytes32 solanaSpokePoolUsdcVault + ) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Solana) { + // Solana adapter requires CCTP TokenMessenger and MessageTransmitter contracts to be set. + if (address(_cctpTokenMessenger) == address(0)) { + revert InvalidCctpTokenMessenger(address(_cctpTokenMessenger)); + } + if (address(_cctpMessageTransmitter) == address(0)) { + revert InvalidCctpMessageTransmitter(address(_cctpMessageTransmitter)); + } + + cctpMessageTransmitter = _cctpMessageTransmitter; + + SOLANA_SPOKE_POOL_BYTES32 = solanaSpokePool; + SOLANA_SPOKE_POOL_ADDRESS = solanaSpokePool.toAddressUnchecked(); + + SOLANA_USDC_BYTES32 = solanaUsdc; + SOLANA_USDC_ADDRESS = solanaUsdc.toAddressUnchecked(); + + SOLANA_SPOKE_POOL_USDC_VAULT = solanaSpokePoolUsdcVault; + } + + /** + * @notice Send cross-chain message to target on Solana. + * @dev Only allows sending messages to the Solana spoke pool. + * @param target Program on Solana (translated as EVM address) that will receive message. + * @param message Data to send to target. + */ + function relayMessage(address target, bytes calldata message) external payable override { + if (target != SOLANA_SPOKE_POOL_ADDRESS) { + revert InvalidRelayMessageTarget(target); + } + + bytes4 selector = bytes4(message[:4]); + if (selector == SpokePoolInterface.setEnableRoute.selector) { + cctpMessageTransmitter.sendMessage( + CircleDomainIds.Solana, + SOLANA_SPOKE_POOL_BYTES32, + _translateSetEnableRoute(message) + ); + } else { + cctpMessageTransmitter.sendMessage(CircleDomainIds.Solana, SOLANA_SPOKE_POOL_BYTES32, message); + } + + // TODO: consider if we need also to emit the translated message. + emit MessageRelayed(target, message); + } + + /** + * @notice Bridge tokens to Solana. + * @dev Only allows bridging USDC to Solana spoke pool. + * @param l1Token L1 token to deposit. + * @param l2Token L2 token to receive. + * @param amount Amount of L1 tokens to deposit and L2 tokens to receive. + * @param to Bridge recipient. + */ + function relayTokens( + address l1Token, + address l2Token, + uint256 amount, + address to + ) external payable override { + if (l1Token != address(usdcToken)) { + revert InvalidL1Token(l1Token); + } + if (l2Token != SOLANA_USDC_ADDRESS) { + revert InvalidL2Token(l2Token); + } + if (amount > type(uint64).max) { + revert InvalidAmount(amount); + } + if (to != SOLANA_SPOKE_POOL_ADDRESS) { + revert InvalidTokenRecipient(to); + } + + _transferUsdc(SOLANA_SPOKE_POOL_USDC_VAULT, amount); + + // TODO: consider if we need also to emit the translated addresses. + emit TokensRelayed(l1Token, l2Token, amount, to); + } + + /** + * @notice Translates a message to enable/disable a route on Solana spoke pool. + * @param message Message to translate, expecting setEnableRoute(address,uint256,bool). + * @return Translated message, using setEnableRoute(bytes32,uint64,bool). + */ + function _translateSetEnableRoute(bytes calldata message) internal view returns (bytes memory) { + (address originToken, uint256 destinationChainId, bool enable) = abi.decode( + message[4:], + (address, uint256, bool) + ); + + if (originToken != SOLANA_USDC_ADDRESS) { + revert InvalidOriginToken(originToken); + } + + if (destinationChainId > type(uint64).max) { + revert InvalidDestinationChainId(destinationChainId); + } + + return + abi.encodeWithSignature( + "setEnableRoute(bytes32,uint64,bool)", + SOLANA_USDC_BYTES32, + uint64(destinationChainId), + enable + ); + } +} diff --git a/contracts/erc7683/ERC7683OrderDepositor.sol b/contracts/erc7683/ERC7683OrderDepositor.sol index 1381b4a47..a6bcd4dcd 100644 --- a/contracts/erc7683/ERC7683OrderDepositor.sol +++ b/contracts/erc7683/ERC7683OrderDepositor.sol @@ -9,6 +9,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { Output, GaslessCrossChainOrder, OnchainCrossChainOrder, ResolvedCrossChainOrder, IOriginSettler, FillInstruction } from "./ERC7683.sol"; import { AcrossOrderData, AcrossOriginFillerData, ERC7683Permit2Lib, ACROSS_ORDER_DATA_TYPE_HASH } from "./ERC7683Permit2Lib.sol"; +import { AddressToBytes32 } from "../libraries/AddressConverters.sol"; /** * @notice ERC7683OrderDepositor processes an external order type and translates it into an AcrossV3 deposit. @@ -18,6 +19,7 @@ import { AcrossOrderData, AcrossOriginFillerData, ERC7683Permit2Lib, ACROSS_ORDE */ abstract contract ERC7683OrderDepositor is IOriginSettler { using SafeERC20 for IERC20; + using AddressToBytes32 for address; error WrongSettlementContract(); error WrongChainId(); @@ -71,6 +73,7 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { acrossOrderData.outputAmount, acrossOrderData.destinationChainId, acrossOriginFillerData.exclusiveRelayer, + acrossOrderData.depositNonce, // Note: simplifying assumption to avoid quote timestamps that cause orders to expire before the deadline. SafeCast.toUint32(order.openDeadline - QUOTE_BEFORE_DEADLINE), order.fillDeadline, @@ -101,6 +104,7 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { acrossOrderData.outputAmount, acrossOrderData.destinationChainId, acrossOrderData.exclusiveRelayer, + acrossOrderData.depositNonce, // Note: simplifying assumption to avoid the order type having to bake in the quote timestamp. SafeCast.toUint32(block.timestamp), order.fillDeadline, @@ -159,6 +163,17 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { return SafeCast.toUint32(block.timestamp); // solhint-disable-line not-rely-on-time } + /** + * @notice Convenience method to compute the Across depositId for orders sent through 7683. + * @dev if a 0 depositNonce is used, the depositId will not be deterministic (meaning it can change depending on + * when the open txn is mined), but you will be safe from collisions. See the unsafeDepositV3 method on SpokePool + * for more details on how to choose between deterministic and non-deterministic. + * @param depositNonce the depositNonce field in the order. + * @param depositor the sender or signer of the order. + * @return the resulting Across depositId. + */ + function computeDepositId(uint256 depositNonce, address depositor) public view virtual returns (uint256); + function _resolveFor(GaslessCrossChainOrder calldata order, bytes calldata fillerData) internal view @@ -213,15 +228,15 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { FillInstruction[] memory fillInstructions = new FillInstruction[](1); V3SpokePoolInterface.V3RelayData memory relayData; - relayData.depositor = order.user; - relayData.recipient = _toAddress(acrossOrderData.recipient); - relayData.exclusiveRelayer = acrossOriginFillerData.exclusiveRelayer; - relayData.inputToken = acrossOrderData.inputToken; - relayData.outputToken = acrossOrderData.outputToken; + relayData.depositor = order.user.toBytes32(); + relayData.recipient = acrossOrderData.recipient; + relayData.exclusiveRelayer = acrossOriginFillerData.exclusiveRelayer.toBytes32(); + relayData.inputToken = acrossOrderData.inputToken.toBytes32(); + relayData.outputToken = acrossOrderData.outputToken.toBytes32(); relayData.inputAmount = acrossOrderData.inputAmount; relayData.outputAmount = acrossOrderData.outputAmount; relayData.originChainId = block.chainid; - relayData.depositId = _currentDepositId(); + relayData.depositId = computeDepositId(acrossOrderData.depositNonce, order.user); relayData.fillDeadline = order.fillDeadline; relayData.exclusivityDeadline = acrossOrderData.exclusivityPeriod; relayData.message = acrossOrderData.message; @@ -277,15 +292,15 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { FillInstruction[] memory fillInstructions = new FillInstruction[](1); V3SpokePoolInterface.V3RelayData memory relayData; - relayData.depositor = msg.sender; - relayData.recipient = _toAddress(acrossOrderData.recipient); - relayData.exclusiveRelayer = acrossOrderData.exclusiveRelayer; - relayData.inputToken = acrossOrderData.inputToken; - relayData.outputToken = acrossOrderData.outputToken; + relayData.depositor = msg.sender.toBytes32(); + relayData.recipient = acrossOrderData.recipient; + relayData.exclusiveRelayer = acrossOrderData.exclusiveRelayer.toBytes32(); + relayData.inputToken = acrossOrderData.inputToken.toBytes32(); + relayData.outputToken = acrossOrderData.outputToken.toBytes32(); relayData.inputAmount = acrossOrderData.inputAmount; relayData.outputAmount = acrossOrderData.outputAmount; relayData.originChainId = block.chainid; - relayData.depositId = _currentDepositId(); + relayData.depositId = computeDepositId(acrossOrderData.depositNonce, msg.sender); relayData.fillDeadline = order.fillDeadline; relayData.exclusivityDeadline = acrossOrderData.exclusivityPeriod; relayData.message = acrossOrderData.message; @@ -355,13 +370,12 @@ abstract contract ERC7683OrderDepositor is IOriginSettler { uint256 outputAmount, uint256 destinationChainId, address exclusiveRelayer, + uint256 depositNonce, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityPeriod, bytes memory message ) internal virtual; - function _currentDepositId() internal view virtual returns (uint32); - function _destinationSettler(uint256 chainId) internal view virtual returns (address); } diff --git a/contracts/erc7683/ERC7683OrderDepositorExternal.sol b/contracts/erc7683/ERC7683OrderDepositorExternal.sol index 9cefb19f1..82371f9e1 100644 --- a/contracts/erc7683/ERC7683OrderDepositorExternal.sol +++ b/contracts/erc7683/ERC7683OrderDepositorExternal.sol @@ -15,6 +15,7 @@ import "@uma/core/contracts/common/implementation/MultiCaller.sol"; */ contract ERC7683OrderDepositorExternal is ERC7683OrderDepositor, Ownable, MultiCaller { using SafeERC20 for IERC20; + using AddressToBytes32 for address; event SetDestinationSettler( uint256 indexed chainId, @@ -50,6 +51,7 @@ contract ERC7683OrderDepositorExternal is ERC7683OrderDepositor, Ownable, MultiC uint256 outputAmount, uint256 destinationChainId, address exclusiveRelayer, + uint256 depositNonce, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityDeadline, @@ -57,24 +59,45 @@ contract ERC7683OrderDepositorExternal is ERC7683OrderDepositor, Ownable, MultiC ) internal override { IERC20(inputToken).forceApprove(address(SPOKE_POOL), inputAmount); - SPOKE_POOL.depositV3( - depositor, - recipient, - inputToken, - outputToken, - inputAmount, - outputAmount, - destinationChainId, - exclusiveRelayer, - quoteTimestamp, - fillDeadline, - exclusivityDeadline, - message - ); + if (depositNonce == 0) { + SPOKE_POOL.depositV3( + depositor, + recipient, + inputToken, + outputToken, + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message + ); + } else { + SPOKE_POOL.unsafeDepositV3( + depositor, + recipient, + inputToken, + outputToken, + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + depositNonce, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message + ); + } } - function _currentDepositId() internal view override returns (uint32) { - return SPOKE_POOL.numberOfDeposits(); + function computeDepositId(uint256 depositNonce, address depositor) public view override returns (uint256) { + return + depositNonce == 0 + ? SPOKE_POOL.numberOfDeposits() + : SPOKE_POOL.getUnsafeDepositId(address(this), depositor.toBytes32(), depositNonce); } function _destinationSettler(uint256 chainId) internal view override returns (address) { diff --git a/contracts/erc7683/ERC7683Permit2Lib.sol b/contracts/erc7683/ERC7683Permit2Lib.sol index 6e1a47236..37c68946f 100644 --- a/contracts/erc7683/ERC7683Permit2Lib.sol +++ b/contracts/erc7683/ERC7683Permit2Lib.sol @@ -13,6 +13,7 @@ struct AcrossOrderData { uint256 destinationChainId; bytes32 recipient; address exclusiveRelayer; + uint256 depositNonce; uint32 exclusivityPeriod; bytes message; } @@ -34,6 +35,7 @@ bytes constant ACROSS_ORDER_DATA_TYPE = abi.encodePacked( "uint256 destinationChainId,", "bytes32 recipient,", "address exclusiveRelayer," + "uint256 depositNonce,", "uint32 exclusivityPeriod,", "bytes message)" ); diff --git a/contracts/external/interfaces/CCTPInterfaces.sol b/contracts/external/interfaces/CCTPInterfaces.sol index 8431bbfdc..e932d943a 100644 --- a/contracts/external/interfaces/CCTPInterfaces.sol +++ b/contracts/external/interfaces/CCTPInterfaces.sol @@ -74,3 +74,24 @@ interface ITokenMinter { */ function burnLimitsPerMessage(address token) external view returns (uint256); } + +/** + * IMessageTransmitter in CCTP inherits IRelayer and IReceiver, but here we only import sendMessage from IRelayer: + * https://github.com/circlefin/evm-cctp-contracts/blob/377c9bd813fb86a42d900ae4003599d82aef635a/src/interfaces/IMessageTransmitter.sol#L25 + * https://github.com/circlefin/evm-cctp-contracts/blob/377c9bd813fb86a42d900ae4003599d82aef635a/src/interfaces/IRelayer.sol#L23-L35 + */ +interface IMessageTransmitter { + /** + * @notice Sends an outgoing message from the source domain. + * @dev Increment nonce, format the message, and emit `MessageSent` event with message information. + * @param destinationDomain Domain of destination chain + * @param recipient Address of message recipient on destination domain as bytes32 + * @param messageBody Raw bytes content of message + * @return nonce reserved by message + */ + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes calldata messageBody + ) external returns (uint64); +} diff --git a/contracts/interfaces/V3SpokePoolInterface.sol b/contracts/interfaces/V3SpokePoolInterface.sol index dd490cd1b..34262013c 100644 --- a/contracts/interfaces/V3SpokePoolInterface.sol +++ b/contracts/interfaces/V3SpokePoolInterface.sol @@ -23,9 +23,9 @@ interface V3SpokePoolInterface { // to know when to send excess funds from the SpokePool to the HubPool because they can no longer be used // for a slow fill execution. SlowFill - // Slow fills are requested via requestSlowFill and executed by executeSlowRelayLeaf after a bundle containing - // the slow fill is validated. } + // Slow fills are requested via requestSlowFill and executed by executeSlowRelayLeaf after a bundle containing + // the slow fill is validated. /************************************** * STRUCTS * @@ -36,16 +36,16 @@ interface V3SpokePoolInterface { // replay attacks on other chains. If any portion of this data differs, the relay is considered to be // completely distinct. struct V3RelayData { - // The address that made the deposit on the origin chain. - address depositor; - // The recipient address on the destination chain. - address recipient; + // The bytes32 that made the deposit on the origin chain. + bytes32 depositor; + // The recipient bytes32 on the destination chain. + bytes32 recipient; // This is the exclusive relayer who can fill the deposit before the exclusivity deadline. - address exclusiveRelayer; + bytes32 exclusiveRelayer; // Token that is deposited on origin chain by depositor. - address inputToken; + bytes32 inputToken; // Token that is received on destination chain by recipient. - address outputToken; + bytes32 outputToken; // The amount of input token deposited by depositor. uint256 inputAmount; // The amount of output token to be received by recipient. @@ -53,7 +53,7 @@ interface V3SpokePoolInterface { // Origin chain id. uint256 originChainId; // The id uniquely identifying this deposit on the origin chain. - uint32 depositId; + uint256 depositId; // The timestamp on the destination chain after which this deposit can no longer be filled. uint32 fillDeadline; // The timestamp on the destination chain after which any relayer can fill the deposit. @@ -77,7 +77,7 @@ interface V3SpokePoolInterface { V3RelayData relay; bytes32 relayHash; uint256 updatedOutputAmount; - address updatedRecipient; + bytes32 updatedRecipient; bytes updatedMessage; uint256 repaymentChainId; } @@ -86,78 +86,117 @@ interface V3SpokePoolInterface { // Similar to V3RelayExecutionParams, these parameters are not used to uniquely identify the deposit being // filled so they don't have to be unpacked by all clients. struct V3RelayExecutionEventInfo { - address updatedRecipient; - bytes updatedMessage; + bytes32 updatedRecipient; + bytes32 updatedMessageHash; uint256 updatedOutputAmount; FillType fillType; } + // Represents the parameters required for a V3 deposit operation in the SpokePool. + struct DepositV3Params { + bytes32 depositor; + bytes32 recipient; + bytes32 inputToken; + bytes32 outputToken; + uint256 inputAmount; + uint256 outputAmount; + uint256 destinationChainId; + bytes32 exclusiveRelayer; + uint256 depositId; + uint32 quoteTimestamp; + uint32 fillDeadline; + uint32 exclusivityParameter; + bytes message; + } + /************************************** * EVENTS * **************************************/ event V3FundsDeposited( - address inputToken, - address outputToken, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 indexed destinationChainId, - uint32 indexed depositId, + uint256 indexed depositId, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityDeadline, - address indexed depositor, - address recipient, - address exclusiveRelayer, + bytes32 indexed depositor, + bytes32 recipient, + bytes32 exclusiveRelayer, bytes message ); event RequestedSpeedUpV3Deposit( uint256 updatedOutputAmount, - uint32 indexed depositId, - address indexed depositor, - address updatedRecipient, + uint256 indexed depositId, + bytes32 indexed depositor, + bytes32 updatedRecipient, bytes updatedMessage, bytes depositorSignature ); event FilledV3Relay( - address inputToken, - address outputToken, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 repaymentChainId, uint256 indexed originChainId, - uint32 indexed depositId, + uint256 indexed depositId, uint32 fillDeadline, uint32 exclusivityDeadline, - address exclusiveRelayer, - address indexed relayer, - address depositor, - address recipient, - bytes message, + bytes32 exclusiveRelayer, + bytes32 indexed relayer, + bytes32 depositor, + bytes32 recipient, + bytes32 messageHash, V3RelayExecutionEventInfo relayExecutionInfo ); event RequestedV3SlowFill( - address inputToken, - address outputToken, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 indexed originChainId, - uint32 indexed depositId, + uint256 indexed depositId, uint32 fillDeadline, uint32 exclusivityDeadline, - address exclusiveRelayer, - address depositor, - address recipient, - bytes message + bytes32 exclusiveRelayer, + bytes32 depositor, + bytes32 recipient, + bytes32 messageHash + ); + + event ClaimedRelayerRefund( + bytes32 indexed l2TokenAddress, + bytes32 indexed refundAddress, + uint256 amount, + address indexed caller ); /************************************** * FUNCTIONS * **************************************/ + function depositV3( + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + bytes32 exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityDeadline, + bytes calldata message + ) external payable; + function depositV3( address depositor, address recipient, @@ -174,35 +213,40 @@ interface V3SpokePoolInterface { ) external payable; function depositV3Now( - address depositor, - address recipient, - address inputToken, - address outputToken, + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 destinationChainId, - address exclusiveRelayer, + bytes32 exclusiveRelayer, uint32 fillDeadlineOffset, uint32 exclusivityDeadline, bytes calldata message ) external payable; function speedUpV3Deposit( - address depositor, - uint32 depositId, + bytes32 depositor, + uint256 depositId, uint256 updatedOutputAmount, - address updatedRecipient, + bytes32 updatedRecipient, bytes calldata updatedMessage, bytes calldata depositorSignature ) external; - function fillV3Relay(V3RelayData calldata relayData, uint256 repaymentChainId) external; + function fillV3Relay( + V3RelayData calldata relayData, + uint256 repaymentChainId, + bytes32 repaymentAddress + ) external; function fillV3RelayWithUpdatedDeposit( V3RelayData calldata relayData, uint256 repaymentChainId, + bytes32 repaymentAddress, uint256 updatedOutputAmount, - address updatedRecipient, + bytes32 updatedRecipient, bytes calldata updatedMessage, bytes calldata depositorSignature ) external; @@ -236,4 +280,6 @@ interface V3SpokePoolInterface { error InvalidPayoutAdjustmentPct(); error WrongERC7683OrderId(); error LowLevelCallFailed(bytes data); + error InsufficientSpokePoolBalanceToExecuteLeaf(); + error NoRelayerRefundToClaim(); } diff --git a/contracts/libraries/AddressConverters.sol b/contracts/libraries/AddressConverters.sol new file mode 100644 index 000000000..624fd9aa3 --- /dev/null +++ b/contracts/libraries/AddressConverters.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +library Bytes32ToAddress { + /************************************** + * ERRORS * + **************************************/ + error InvalidBytes32(); + + function toAddress(bytes32 _bytes32) internal pure returns (address) { + if (uint256(_bytes32) >> 192 != 0) { + revert InvalidBytes32(); + } + return address(uint160(uint256(_bytes32))); + } + + function toAddressUnchecked(bytes32 _bytes32) internal pure returns (address) { + return address(uint160(uint256(_bytes32))); + } +} + +library AddressToBytes32 { + function toBytes32(address _address) internal pure returns (bytes32) { + return bytes32(uint256(uint160(_address))); + } +} diff --git a/contracts/libraries/CircleCCTPAdapter.sol b/contracts/libraries/CircleCCTPAdapter.sol index 6403ed4c4..1df55ba11 100644 --- a/contracts/libraries/CircleCCTPAdapter.sol +++ b/contracts/libraries/CircleCCTPAdapter.sol @@ -4,11 +4,13 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../external/interfaces/CCTPInterfaces.sol"; +import { AddressToBytes32 } from "../libraries/AddressConverters.sol"; library CircleDomainIds { uint32 public constant Ethereum = 0; uint32 public constant Optimism = 2; uint32 public constant Arbitrum = 3; + uint32 public constant Solana = 5; uint32 public constant Base = 6; uint32 public constant Polygon = 7; // Use this value for placeholder purposes only for adapters that extend this adapter but haven't yet been @@ -23,13 +25,14 @@ library CircleDomainIds { */ abstract contract CircleCCTPAdapter { using SafeERC20 for IERC20; - + using AddressToBytes32 for address; /** * @notice The domain ID that CCTP will transfer funds to. * @dev This identifier is assigned by Circle and is not related to a chain ID. * @dev Official domain list can be found here: https://developers.circle.com/stablecoins/docs/supported-domains */ /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint32 public immutable recipientCircleDomainId; /** @@ -63,15 +66,6 @@ abstract contract CircleCCTPAdapter { recipientCircleDomainId = _recipientCircleDomainId; } - /** - * @notice converts address to bytes32 (alignment preserving cast.) - * @param addr the address to convert to bytes32 - * @dev Sourced from the official CCTP repo: https://github.com/walkerq/evm-cctp-contracts/blob/139d8d0ce3b5531d3c7ec284f89d946dfb720016/src/messages/Message.sol#L142C1-L148C6 - */ - function _addressToBytes32(address addr) internal pure returns (bytes32) { - return bytes32(uint256(uint160(addr))); - } - /** * @notice Returns whether or not the CCTP bridge is enabled. * @dev If the CCTPTokenMessenger is the zero address, CCTP bridging is disabled. @@ -87,6 +81,16 @@ abstract contract CircleCCTPAdapter { * @param amount Amount of USDC to transfer. */ function _transferUsdc(address to, uint256 amount) internal { + _transferUsdc(to.toBytes32(), amount); + } + + /** + * @notice Transfers USDC from the current domain to the given address on the new domain. + * @dev This function will revert if the CCTP bridge is disabled. I.e. if the zero address is passed to the constructor for the cctpTokenMessenger. + * @param to Address to receive USDC on the new domain represented as bytes32. + * @param amount Amount of USDC to transfer. + */ + function _transferUsdc(bytes32 to, uint256 amount) internal { // Only approve the exact amount to be transferred usdcToken.safeIncreaseAllowance(address(cctpTokenMessenger), amount); // Submit the amount to be transferred to bridged via the TokenMessenger. @@ -94,10 +98,9 @@ abstract contract CircleCCTPAdapter { ITokenMinter cctpMinter = cctpTokenMessenger.localMinter(); uint256 burnLimit = cctpMinter.burnLimitsPerMessage(address(usdcToken)); uint256 remainingAmount = amount; - bytes32 recipient = _addressToBytes32(to); while (remainingAmount > 0) { uint256 partAmount = remainingAmount > burnLimit ? burnLimit : remainingAmount; - cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, recipient, address(usdcToken)); + cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, to, address(usdcToken)); remainingAmount -= partAmount; } } diff --git a/contracts/permit2-order/Permit2Depositor.sol b/contracts/permit2-order/Permit2Depositor.sol index 8a72318c9..d265e24e1 100644 --- a/contracts/permit2-order/Permit2Depositor.sol +++ b/contracts/permit2-order/Permit2Depositor.sol @@ -8,12 +8,14 @@ import "../interfaces/V3SpokePoolInterface.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { AddressToBytes32 } from "../libraries/AddressConverters.sol"; /** * @notice Permit2Depositor processes an external order type and translates it into an AcrossV3 deposit. */ contract Permit2Depositor { using SafeERC20 for IERC20; + using AddressToBytes32 for address; // SpokePool that this contract can deposit to. V3SpokePoolInterface public immutable SPOKE_POOL; @@ -58,15 +60,15 @@ contract Permit2Depositor { IERC20(order.input.token).safeIncreaseAllowance(address(SPOKE_POOL), amountToDeposit); SPOKE_POOL.depositV3( - order.info.offerer, + order.info.offerer.toBytes32(), // Note: Permit2OrderLib checks that order only has a single output. - order.outputs[0].recipient, - order.input.token, - order.outputs[0].token, + order.outputs[0].recipient.toBytes32(), + order.input.token.toBytes32(), + order.outputs[0].token.toBytes32(), amountToDeposit, order.outputs[0].amount, order.outputs[0].chainId, - destinationChainFillerAddress, + destinationChainFillerAddress.toBytes32(), SafeCast.toUint32(order.info.initiateDeadline - QUOTE_BEFORE_DEADLINE), fillDeadline, // The entire fill period is exclusive. diff --git a/contracts/test/ExpandedERC20WithBlacklist.sol b/contracts/test/ExpandedERC20WithBlacklist.sol new file mode 100644 index 000000000..607609eb7 --- /dev/null +++ b/contracts/test/ExpandedERC20WithBlacklist.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@uma/core/contracts/common/implementation/ExpandedERC20.sol"; + +contract ExpandedERC20WithBlacklist is ExpandedERC20 { + mapping(address => bool) public isBlackListed; + + constructor( + string memory name, + string memory symbol, + uint8 decimals + ) ExpandedERC20(name, symbol, decimals) {} + + function setBlacklistStatus(address account, bool status) external { + isBlackListed[account] = status; + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) internal override { + require(!isBlackListed[to], "Recipient is blacklisted"); + super._beforeTokenTransfer(from, to, amount); + } +} diff --git a/contracts/test/MockSpokePool.sol b/contracts/test/MockSpokePool.sol index 81d68115c..42bae1e24 100644 --- a/contracts/test/MockSpokePool.sol +++ b/contracts/test/MockSpokePool.sol @@ -5,6 +5,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "../SpokePool.sol"; import "./interfaces/MockV2SpokePoolInterface.sol"; import "./V2MerkleLib.sol"; +import { AddressToBytes32, Bytes32ToAddress } from "../libraries/AddressConverters.sol"; /** * @title MockSpokePool @@ -12,6 +13,8 @@ import "./V2MerkleLib.sol"; */ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeable { using SafeERC20Upgradeable for IERC20Upgradeable; + using AddressToBytes32 for address; + using Bytes32ToAddress for bytes32; uint256 private chainId_; uint256 private currentTime; @@ -21,11 +24,11 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl bytes32 public constant UPDATE_DEPOSIT_DETAILS_HASH = keccak256( - "UpdateDepositDetails(uint32 depositId,uint256 originChainId,int64 updatedRelayerFeePct,address updatedRecipient,bytes updatedMessage)" + "UpdateDepositDetails(uint256 depositId,uint256 originChainId,int64 updatedRelayerFeePct,address updatedRecipient,bytes updatedMessage)" ); event BridgedToHubPool(uint256 amount, address token); - event PreLeafExecuteHook(address token); + event PreLeafExecuteHook(bytes32 token); /// @custom:oz-upgrades-unsafe-allow constructor constructor(address _wrappedNativeTokenAddress) SpokePool(_wrappedNativeTokenAddress, 1 hours, 9 hours) {} // solhint-disable-line no-empty-blocks @@ -57,10 +60,10 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl function _verifyUpdateDepositMessage( address depositor, - uint32 depositId, + uint256 depositId, uint256 originChainId, int64 updatedRelayerFeePct, - address updatedRecipient, + bytes32 updatedRecipient, bytes memory updatedMessage, bytes memory depositorSignature ) internal view { @@ -82,9 +85,31 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl _verifyDepositorSignature(depositor, expectedTypedDataV4Hash, depositorSignature); } + function verifyUpdateV3DepositMessage( + bytes32 depositor, + uint256 depositId, + uint256 originChainId, + uint256 updatedOutputAmount, + bytes32 updatedRecipient, + bytes memory updatedMessage, + bytes memory depositorSignature + ) public view { + return + _verifyUpdateV3DepositMessage( + depositor.toAddress(), + depositId, + originChainId, + updatedOutputAmount, + updatedRecipient, + updatedMessage, + depositorSignature, + UPDATE_V3_DEPOSIT_DETAILS_HASH + ); + } + function verifyUpdateV3DepositMessage( address depositor, - uint32 depositId, + uint256 depositId, uint256 originChainId, uint256 updatedOutputAmount, address updatedRecipient, @@ -97,15 +122,16 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl depositId, originChainId, updatedOutputAmount, - updatedRecipient, + updatedRecipient.toBytes32(), updatedMessage, - depositorSignature + depositorSignature, + UPDATE_V3_DEPOSIT_ADDRESS_OVERLOAD_DETAILS_HASH ); } function fillRelayV3Internal( V3RelayExecutionParams memory relayExecution, - address relayer, + bytes32 relayer, bool isSlowFill ) external { _fillRelayV3(relayExecution, relayer, isSlowFill); @@ -127,7 +153,7 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl } function _preExecuteLeafHook(address token) internal override { - emit PreLeafExecuteHook(token); + emit PreLeafExecuteHook(token.toBytes32()); } function _bridgeTokensToHubPool(uint256 amount, address token) internal override { @@ -194,24 +220,32 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl relayFills[relayExecution.relayHash] += fillAmountPreFees; - if (msg.sender == relayExecution.updatedRecipient && !relayExecution.slowFill) return fillAmountPreFees; + if (msg.sender.toBytes32() == relayExecution.updatedRecipient && !relayExecution.slowFill) { + return fillAmountPreFees; + } - if (relayData.destinationToken == address(wrappedNativeToken)) { - if (!relayExecution.slowFill) - IERC20Upgradeable(relayData.destinationToken).safeTransferFrom(msg.sender, address(this), amountToSend); - _unwrapwrappedNativeTokenTo(payable(relayExecution.updatedRecipient), amountToSend); + if (relayData.destinationToken == address(wrappedNativeToken).toBytes32()) { + if (!relayExecution.slowFill) { + IERC20Upgradeable(relayData.destinationToken.toAddress()).safeTransferFrom( + msg.sender, + address(this), + amountToSend + ); + } + _unwrapwrappedNativeTokenTo(payable(relayExecution.updatedRecipient.toAddress()), amountToSend); } else { - if (!relayExecution.slowFill) - IERC20Upgradeable(relayData.destinationToken).safeTransferFrom( + if (!relayExecution.slowFill) { + IERC20Upgradeable(relayData.destinationToken.toAddress()).safeTransferFrom( msg.sender, - relayExecution.updatedRecipient, + relayExecution.updatedRecipient.toAddress(), amountToSend ); - else - IERC20Upgradeable(relayData.destinationToken).safeTransfer( - relayExecution.updatedRecipient, + } else { + IERC20Upgradeable(relayData.destinationToken.toAddress()).safeTransfer( + relayExecution.updatedRecipient.toAddress(), amountToSend ); + } } } diff --git a/contracts/test/interfaces/MockV2SpokePoolInterface.sol b/contracts/test/interfaces/MockV2SpokePoolInterface.sol index 8f2f8acf2..214012865 100644 --- a/contracts/test/interfaces/MockV2SpokePoolInterface.sol +++ b/contracts/test/interfaces/MockV2SpokePoolInterface.sol @@ -6,9 +6,9 @@ pragma solidity ^0.8.0; */ interface MockV2SpokePoolInterface { struct RelayData { - address depositor; - address recipient; - address destinationToken; + bytes32 depositor; + bytes32 recipient; + bytes32 destinationToken; uint256 amount; uint256 originChainId; uint256 destinationChainId; @@ -22,7 +22,7 @@ interface MockV2SpokePoolInterface { RelayData relay; bytes32 relayHash; int64 updatedRelayerFeePct; - address updatedRecipient; + bytes32 updatedRecipient; bytes updatedMessage; uint256 repaymentChainId; uint256 maxTokensToSend; diff --git a/deployments/README.md b/deployments/README.md index c0601b886..9dc40252d 100644 --- a/deployments/README.md +++ b/deployments/README.md @@ -133,7 +133,7 @@ This is because this `deployments.json` file is used by bots in [`@across-protoc ## Ink mainnet (57073) -| Contract Name | Address | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Ink_SpokePool | [0xeF684C38F94F48775959ECf2012D7E864ffb9dd4](https://explorer.inkonchain.com/address/0xeF684C38F94F48775959ECf2012D7E864ffb9dd4) | -| MulticallHandler | [0x924a9f036260DdD5808007E1AA95f08eD08aA569](https://explorer.inkonchain.com/address/0x924a9f036260DdD5808007E1AA95f08eD08aA569) | +| Contract Name | Address | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Ink_SpokePool | [0xeF684C38F94F48775959ECf2012D7E864ffb9dd4](https://explorer.inkonchain.com/address/0xeF684C38F94F48775959ECf2012D7E864ffb9dd4) | +| MulticallHandler | [0x924a9f036260DdD5808007E1AA95f08eD08aA569](https://explorer.inkonchain.com/address/0x924a9f036260DdD5808007E1AA95f08eD08aA569) | diff --git a/deployments/ink/SpokePoolVerifier.json b/deployments/ink/SpokePoolVerifier.json index 7d25b4799..5344f10b1 100644 --- a/deployments/ink/SpokePoolVerifier.json +++ b/deployments/ink/SpokePoolVerifier.json @@ -112,4 +112,4 @@ "storage": [], "types": null } -} \ No newline at end of file +} diff --git a/deployments/ink/solcInputs/53ab13385da43753d9a657e7780a0560.json b/deployments/ink/solcInputs/53ab13385da43753d9a657e7780a0560.json index b640ddc74..b9b6c4373 100644 --- a/deployments/ink/solcInputs/53ab13385da43753d9a657e7780a0560.json +++ b/deployments/ink/solcInputs/53ab13385da43753d9a657e7780a0560.json @@ -33,13 +33,11 @@ "storageLayout", "evm.gasEstimates" ], - "": [ - "ast" - ] + "": ["ast"] } }, "metadata": { "useLiteralContent": true } } -} \ No newline at end of file +} diff --git a/hardhat.config.ts b/hardhat.config.ts index f49ae3207..594ae3c1b 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -49,7 +49,7 @@ const LARGE_CONTRACT_COMPILER_SETTINGS = { settings: { optimizer: { enabled: true, runs: 1000 }, viaIR: true, - debug: { revertStrings: isTest ? "default" : "strip" }, + debug: { revertStrings: isTest ? "debug" : "strip" }, }, }; const DEFAULT_CONTRACT_COMPILER_SETTINGS = { @@ -58,7 +58,7 @@ const DEFAULT_CONTRACT_COMPILER_SETTINGS = { optimizer: { enabled: true, runs: 1000000 }, viaIR: true, // Only strip revert strings if not testing or in ci. - debug: { revertStrings: isTest ? "default" : "strip" }, + debug: { revertStrings: isTest ? "debug" : "strip" }, }, }; diff --git a/programs/svm-spoke/src/event.rs b/programs/svm-spoke/src/event.rs index d4b797b34..b7bab22c7 100644 --- a/programs/svm-spoke/src/event.rs +++ b/programs/svm-spoke/src/event.rs @@ -84,7 +84,6 @@ pub struct FilledV3Relay { pub relayer: Pubkey, pub depositor: Pubkey, pub recipient: Pubkey, - // TODO: update EVM implementation to use message_hash in all fill related events. pub message_hash: [u8; 32], pub relay_execution_info: V3RelayExecutionEventInfo, } diff --git a/scripts/buildSampleTree.ts b/scripts/buildSampleTree.ts index 738c73941..d70f2be81 100644 --- a/scripts/buildSampleTree.ts +++ b/scripts/buildSampleTree.ts @@ -142,7 +142,7 @@ async function main() { originChainId: SPOKE_POOL_CHAIN_ID, fillDeadline: Math.floor(Date.now() / 1000) + 14400, // 4 hours from now exclusivityDeadline: 0, - depositId: i, + depositId: toBN(i), message: "0x", }, updatedOutputAmount: toBNWeiWithDecimals(SLOW_RELAY_AMOUNT, DECIMALS), diff --git a/storage-layouts/AlephZero_SpokePool.json b/storage-layouts/AlephZero_SpokePool.json index 24a66a64d..382132dd3 100644 --- a/storage-layouts/AlephZero_SpokePool.json +++ b/storage-layouts/AlephZero_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", "label": "l2GatewayRouter", diff --git a/storage-layouts/Arbitrum_SpokePool.json b/storage-layouts/Arbitrum_SpokePool.json index 132a22efe..14bd92871 100644 --- a/storage-layouts/Arbitrum_SpokePool.json +++ b/storage-layouts/Arbitrum_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", "label": "l2GatewayRouter", diff --git a/storage-layouts/Base_SpokePool.json b/storage-layouts/Base_SpokePool.json index 925a6ab42..80934a3f8 100644 --- a/storage-layouts/Base_SpokePool.json +++ b/storage-layouts/Base_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Blast_SpokePool.json b/storage-layouts/Blast_SpokePool.json index e287f83a7..2be5208f9 100644 --- a/storage-layouts/Blast_SpokePool.json +++ b/storage-layouts/Blast_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Ethereum_SpokePool.json b/storage-layouts/Ethereum_SpokePool.json index 73fc3f2e2..416c2be2c 100644 --- a/storage-layouts/Ethereum_SpokePool.json +++ b/storage-layouts/Ethereum_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", "label": "__gap", diff --git a/storage-layouts/Linea_SpokePool.json b/storage-layouts/Linea_SpokePool.json index 4a14b02c3..9dae90739 100644 --- a/storage-layouts/Linea_SpokePool.json +++ b/storage-layouts/Linea_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", "label": "l2MessageService", diff --git a/storage-layouts/Mode_SpokePool.json b/storage-layouts/Mode_SpokePool.json index 55de1b86b..b0daad98f 100644 --- a/storage-layouts/Mode_SpokePool.json +++ b/storage-layouts/Mode_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Optimism_SpokePool.json b/storage-layouts/Optimism_SpokePool.json index 8732f142e..ded4492f7 100644 --- a/storage-layouts/Optimism_SpokePool.json +++ b/storage-layouts/Optimism_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/PolygonZkEVM_SpokePool.json b/storage-layouts/PolygonZkEVM_SpokePool.json index 776ed36d0..7e1fc8127 100644 --- a/storage-layouts/PolygonZkEVM_SpokePool.json +++ b/storage-layouts/PolygonZkEVM_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", "label": "l2PolygonZkEVMBridge", diff --git a/storage-layouts/Polygon_SpokePool.json b/storage-layouts/Polygon_SpokePool.json index 50c271f81..924750f6e 100644 --- a/storage-layouts/Polygon_SpokePool.json +++ b/storage-layouts/Polygon_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", "label": "fxChild", diff --git a/storage-layouts/Redstone_SpokePool.json b/storage-layouts/Redstone_SpokePool.json index 61e0704c0..92b0654e8 100644 --- a/storage-layouts/Redstone_SpokePool.json +++ b/storage-layouts/Redstone_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Scroll_SpokePool.json b/storage-layouts/Scroll_SpokePool.json index adec6db47..81782717b 100644 --- a/storage-layouts/Scroll_SpokePool.json +++ b/storage-layouts/Scroll_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", "label": "l2GatewayRouter", diff --git a/storage-layouts/WorldChain_SpokePool.json b/storage-layouts/WorldChain_SpokePool.json index 2bc68b54c..e46bb47d2 100644 --- a/storage-layouts/WorldChain_SpokePool.json +++ b/storage-layouts/WorldChain_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/ZkSync_SpokePool.json b/storage-layouts/ZkSync_SpokePool.json index 7d1a0c5ce..d76f701b5 100644 --- a/storage-layouts/ZkSync_SpokePool.json +++ b/storage-layouts/ZkSync_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", "label": "l2Eth", diff --git a/storage-layouts/Zora_SpokePool.json b/storage-layouts/Zora_SpokePool.json index 6a6b9ae6d..ca513b7cc 100644 --- a/storage-layouts/Zora_SpokePool.json +++ b/storage-layouts/Zora_SpokePool.json @@ -146,10 +146,16 @@ }, { "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", - "label": "__gap", + "label": "relayerRefund", "offset": 0, "slot": "2163" }, + { + "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2164" + }, { "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", "label": "l1Gas", diff --git a/test/evm/foundry/fork/BlacklistedRelayerRecipient.t.sol b/test/evm/foundry/fork/BlacklistedRelayerRecipient.t.sol new file mode 100644 index 000000000..65bbdbdb5 --- /dev/null +++ b/test/evm/foundry/fork/BlacklistedRelayerRecipient.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { MockSpokePool } from "../../../../contracts/test/MockSpokePool.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +// Define a minimal interface for USDT. Note USDT does NOT return anything after a transfer. +interface IUSDT { + function balanceOf(address account) external view returns (uint256); + + function transfer(address recipient, uint256 amount) external; + + function transferFrom( + address from, + address to, + uint256 value + ) external; + + function addBlackList(address _evilUser) external; + + function getBlackListStatus(address _evilUser) external view returns (bool); +} + +// Define a minimal interface for USDC. Note USDC returns a boolean after a transfer. +interface IUSDC { + function balanceOf(address account) external view returns (uint256); + + function transfer(address recipient, uint256 amount) external returns (bool); + + function transferFrom( + address from, + address to, + uint256 value + ) external returns (bool); + + function blacklist(address _account) external; + + function isBlacklisted(address _account) external view returns (bool); +} + +contract MockSpokePoolTest is Test { + MockSpokePool spokePool; + IUSDT usdt; + IUSDC usdc; + using AddressToBytes32 for address; + + address largeUSDTAccount = 0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1; + address largeUSDCAccount = 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341; + uint256 seedAmount = 10_000 * 10**6; + + address recipient1 = address(0x6969691111111420); + address recipient2 = address(0x6969692222222420); + + function setUp() public { + spokePool = new MockSpokePool(address(0x123)); + // Create an instance of USDT & USDCusing its mainnet address + usdt = IUSDT(address(0xdAC17F958D2ee523a2206206994597C13D831ec7)); + usdc = IUSDC(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48)); + + // Impersonate a large USDT & USDC holders and send tokens to spokePool contract. + assertTrue(usdt.balanceOf(largeUSDTAccount) > seedAmount, "Large USDT holder has less USDT than expected"); + assertTrue(usdc.balanceOf(largeUSDCAccount) > seedAmount, "Large USDC holder has less USDC than expected"); + + vm.prank(largeUSDTAccount); + usdt.transfer(address(spokePool), seedAmount); + assertEq(usdt.balanceOf(address(spokePool)), seedAmount, "Seed transfer failed"); + + vm.prank(largeUSDCAccount); + usdc.transfer(address(spokePool), seedAmount); + assertEq(usdc.balanceOf(address(spokePool)), seedAmount, "USDC seed transfer failed"); + } + + function testStandardRefundsWorks() public { + // Test USDT + assertEq(usdt.balanceOf(recipient1), 0, "Recipient should start with 0 USDT balance"); + assertEq(usdt.balanceOf(address(spokePool)), seedAmount, "SpokePool should have seed USDT balance"); + + uint256[] memory refundAmounts = new uint256[](1); + refundAmounts[0] = 420 * 10**6; + + address[] memory refundAddresses = new address[](1); + refundAddresses[0] = recipient1; + spokePool.distributeRelayerRefunds(1, 0, refundAmounts, 0, address(usdt), refundAddresses); + + assertEq(usdt.balanceOf(recipient1), refundAmounts[0], "Recipient should have received refund"); + assertEq(usdt.balanceOf(address(spokePool)), seedAmount - refundAmounts[0], "SpokePool bal should drop"); + + // Test USDC + assertEq(usdc.balanceOf(recipient1), 0, "Recipient should start with 0 USDC balance"); + assertEq(usdc.balanceOf(address(spokePool)), seedAmount, "SpokePool should have seed USDC balance"); + + spokePool.distributeRelayerRefunds(1, 0, refundAmounts, 0, address(usdc), refundAddresses); + + assertEq(usdc.balanceOf(recipient1), refundAmounts[0], "Recipient should have received refund"); + assertEq(usdc.balanceOf(address(spokePool)), seedAmount - refundAmounts[0], "SpokePool bal should drop"); + } + + function testSomeRecipientsBlacklistedDoesNotBlockTheWholeRefundUsdt() public { + // Note that USDT does NOT block blacklisted recipients, only blacklisted senders. This means that even + // if a recipient is blacklisted the bundle payment should still work to them, even though they then cant + // send the tokens after the fact. + assertEq(usdt.getBlackListStatus(recipient1), false, "Recipient1 should not be blacklisted"); + vm.prank(0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828); // USDT owner. + usdt.addBlackList(recipient1); + assertEq(usdt.getBlackListStatus(recipient1), true, "Recipient1 should be blacklisted"); + + assertEq(usdt.balanceOf(recipient1), 0, "Recipient1 should start with 0 USDT balance"); + assertEq(usdt.balanceOf(recipient2), 0, "Recipient2 should start with 0 USDT balance"); + + uint256[] memory refundAmounts = new uint256[](2); + refundAmounts[0] = 420 * 10**6; + refundAmounts[1] = 69 * 10**6; + + address[] memory refundAddresses = new address[](2); + refundAddresses[0] = recipient1; + refundAddresses[1] = recipient2; + spokePool.distributeRelayerRefunds(1, 0, refundAmounts, 0, address(usdt), refundAddresses); + + assertEq(usdt.balanceOf(recipient1), refundAmounts[0], "Recipient1 should have received their refund"); + assertEq(usdt.balanceOf(recipient2), refundAmounts[1], "Recipient2 should have received their refund"); + + assertEq(spokePool.getRelayerRefund(address(usdt), recipient1), 0); + assertEq(spokePool.getRelayerRefund(address(usdt), recipient2), 0); + } + + function testSomeRecipientsBlacklistedDoesNotBlockTheWholeRefundUsdc() public { + // USDC blacklist blocks both the sender and recipient. Therefore if we a recipient within a bundle is + // blacklisted, they should be credited for the refund amount that can be claimed later to a new address. + assertEq(usdc.isBlacklisted(recipient1), false, "Recipient1 should not be blacklisted"); + vm.prank(0x10DF6B6fe66dd319B1f82BaB2d054cbb61cdAD2e); // USDC blacklister + usdc.blacklist(recipient1); + assertEq(usdc.isBlacklisted(recipient1), true, "Recipient1 should be blacklisted"); + + assertEq(usdc.balanceOf(recipient1), 0, "Recipient1 should start with 0 USDc balance"); + assertEq(usdc.balanceOf(recipient2), 0, "Recipient2 should start with 0 USDc balance"); + + uint256[] memory refundAmounts = new uint256[](2); + refundAmounts[0] = 420 * 10**6; + refundAmounts[1] = 69 * 10**6; + + address[] memory refundAddresses = new address[](2); + refundAddresses[0] = recipient1; + refundAddresses[1] = recipient2; + spokePool.distributeRelayerRefunds(1, 0, refundAmounts, 0, address(usdc), refundAddresses); + + assertEq(usdc.balanceOf(recipient1), 0, "Recipient1 should have 0 refund as blacklisted"); + assertEq(usdc.balanceOf(recipient2), refundAmounts[1], "Recipient2 should have received their refund"); + + assertEq(spokePool.getRelayerRefund(address(usdc), recipient1), refundAmounts[0]); + assertEq(spokePool.getRelayerRefund(address(usdc), recipient2), 0); + + // Now, blacklisted recipient should be able to claim refund to a new address. + address newRecipient = address(0x6969693333333420); + vm.prank(recipient1); + spokePool.claimRelayerRefund(address(usdc).toBytes32(), newRecipient.toBytes32()); + assertEq(usdc.balanceOf(newRecipient), refundAmounts[0], "New recipient should have received relayer2 refund"); + assertEq(spokePool.getRelayerRefund(address(usdt), recipient1), 0); + } + + function toBytes32(address _address) internal pure returns (bytes32) { + return bytes32(uint256(uint160(_address))); + } +} diff --git a/test/evm/foundry/local/MultiCallerUpgradeable.t.sol b/test/evm/foundry/local/MultiCallerUpgradeable.t.sol index 516e0d0a0..c50b87540 100644 --- a/test/evm/foundry/local/MultiCallerUpgradeable.t.sol +++ b/test/evm/foundry/local/MultiCallerUpgradeable.t.sol @@ -8,11 +8,14 @@ import { SpokePool } from "../../../../contracts/SpokePool.sol"; import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; // This test does not require a mainnet fork (since it is testing contracts before deployment). contract MultiCallerUpgradeableTest is Test { Ethereum_SpokePool ethereumSpokePool; + using AddressToBytes32 for address; + ERC20 mockWETH; ERC20 mockL2WETH; @@ -42,11 +45,11 @@ contract MultiCallerUpgradeableTest is Test { uint256 mockRepaymentChainId = 1; uint32 fillDeadline = uint32(ethereumSpokePool.getCurrentTime()) + 1000; - mockRelayData.depositor = rando1; - mockRelayData.recipient = rando2; - mockRelayData.exclusiveRelayer = relayer; - mockRelayData.inputToken = address(mockWETH); - mockRelayData.outputToken = address(mockL2WETH); + mockRelayData.depositor = rando1.toBytes32(); + mockRelayData.recipient = rando2.toBytes32(); + mockRelayData.exclusiveRelayer = relayer.toBytes32(); + mockRelayData.inputToken = address(mockWETH).toBytes32(); + mockRelayData.outputToken = address(mockL2WETH).toBytes32(); mockRelayData.inputAmount = depositAmount; mockRelayData.outputAmount = depositAmount; mockRelayData.originChainId = mockRepaymentChainId; diff --git a/test/evm/foundry/local/SpokePoolVerifier.t.sol b/test/evm/foundry/local/SpokePoolVerifier.t.sol index 391116244..3b36a006f 100644 --- a/test/evm/foundry/local/SpokePoolVerifier.t.sol +++ b/test/evm/foundry/local/SpokePoolVerifier.t.sol @@ -9,11 +9,31 @@ import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePo import { WETH9 } from "../../../../contracts/external/WETH9.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; + +interface EthereumSpokePoolOnlyAddressInterface { + function depositV3( + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + bytes32 exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityDeadline, + bytes calldata message + ) external payable; +} contract SpokePoolVerifierTest is Test { Ethereum_SpokePool ethereumSpokePool; SpokePoolVerifier spokePoolVerifier; + using AddressToBytes32 for address; + ERC20 mockWETH; ERC20 mockERC20; @@ -60,12 +80,12 @@ contract SpokePoolVerifierTest is Test { vm.expectRevert(SpokePoolVerifier.InvalidMsgValue.selector); spokePoolVerifier.deposit{ value: 0 }( ethereumSpokePool, // spokePool - depositor, // recipient - address(mockWETH), // inputToken + depositor.toBytes32(), // recipient + address(mockWETH).toBytes32(), // inputToken depositAmount, // inputAmount depositAmount, // outputAmount destinationChainId, // destinationChainId - address(0), // exclusiveRelayer + bytes32(0), // exclusiveRelayer uint32(block.timestamp), // quoteTimestamp uint32(block.timestamp) + fillDeadlineBuffer, // fillDeadline 0, // exclusivityDeadline @@ -76,12 +96,12 @@ contract SpokePoolVerifierTest is Test { vm.expectRevert(V3SpokePoolInterface.MsgValueDoesNotMatchInputAmount.selector); spokePoolVerifier.deposit{ value: depositAmount }( ethereumSpokePool, // spokePool - depositor, // recipient - address(mockERC20), // inputToken + depositor.toBytes32(), // recipient + address(mockERC20).toBytes32(), // inputToken depositAmount, // inputAmount depositAmount, // outputAmount destinationChainId, // destinationChainId - address(0), // exclusiveRelayer + bytes32(0), // exclusiveRelayer uint32(block.timestamp), // quoteTimestamp uint32(block.timestamp) + fillDeadlineBuffer, // fillDeadline 0, // exclusivityDeadline @@ -98,12 +118,12 @@ contract SpokePoolVerifierTest is Test { vm.expectRevert(SpokePoolVerifier.InvalidSpokePool.selector); spokePoolVerifier.deposit{ value: depositAmount }( V3SpokePoolInterface(address(0)), // spokePool - depositor, // recipient - address(mockWETH), // inputToken + depositor.toBytes32(), // recipient + address(mockWETH).toBytes32(), // inputToken depositAmount, // inputAmount depositAmount, // outputAmount destinationChainId, // destinationChainId - address(0), // exclusiveRelayer + bytes32(0), // exclusiveRelayer uint32(block.timestamp), // quoteTimestamp uint32(block.timestamp) + fillDeadlineBuffer, // fillDeadline 0, // exclusivityDeadline @@ -121,31 +141,31 @@ contract SpokePoolVerifierTest is Test { address(ethereumSpokePool), // callee depositAmount, // value abi.encodeCall( // data - ethereumSpokePool.depositV3, + EthereumSpokePoolOnlyAddressInterface.depositV3, ( - depositor, - depositor, - address(mockWETH), - address(0), + depositor.toBytes32(), + depositor.toBytes32(), + address(mockWETH).toBytes32(), + bytes32(0), depositAmount, depositAmount, destinationChainId, - address(0), + bytes32(0), uint32(block.timestamp), uint32(block.timestamp) + fillDeadlineBuffer, - 0, + uint32(0), bytes("") ) ) ); spokePoolVerifier.deposit{ value: depositAmount }( ethereumSpokePool, // spokePool - depositor, // recipient - address(mockWETH), // inputToken + depositor.toBytes32(), // recipient + address(mockWETH).toBytes32(), // inputToken depositAmount, // inputAmount depositAmount, // outputAmount destinationChainId, // destinationChainId - address(0), // exclusiveRelayer + bytes32(0), // exclusiveRelayer uint32(block.timestamp), // quoteTimestamp uint32(block.timestamp) + fillDeadlineBuffer, // fillDeadline 0, // exclusivityDeadline diff --git a/test/evm/hardhat/MerkleLib.Proofs.ts b/test/evm/hardhat/MerkleLib.Proofs.ts index 62ec669c8..a0a1be168 100644 --- a/test/evm/hardhat/MerkleLib.Proofs.ts +++ b/test/evm/hardhat/MerkleLib.Proofs.ts @@ -11,6 +11,7 @@ import { Contract, BigNumber, ethers, + randomBytes32, } from "../../../utils/utils"; import { V3RelayData, V3SlowFill } from "../../../test-utils"; @@ -113,15 +114,15 @@ describe("MerkleLib Proofs", async function () { const numDistributions = 101; // Create 101 and remove the last to use as the "invalid" one. for (let i = 0; i < numDistributions; i++) { const relayData: V3RelayData = { - depositor: randomAddress(), - recipient: randomAddress(), - exclusiveRelayer: randomAddress(), - inputToken: randomAddress(), - outputToken: randomAddress(), + depositor: randomBytes32(), + recipient: randomBytes32(), + exclusiveRelayer: randomBytes32(), + inputToken: randomBytes32(), + outputToken: randomBytes32(), inputAmount: randomBigNumber(), outputAmount: randomBigNumber(), originChainId: randomBigNumber(2).toNumber(), - depositId: BigNumber.from(i).toNumber(), + depositId: BigNumber.from(i), fillDeadline: randomBigNumber(2).toNumber(), exclusivityDeadline: randomBigNumber(2).toNumber(), message: ethers.utils.hexlify(ethers.utils.randomBytes(1024)), diff --git a/test/evm/hardhat/MerkleLib.utils.ts b/test/evm/hardhat/MerkleLib.utils.ts index 4886f1cb3..ff2b605f9 100644 --- a/test/evm/hardhat/MerkleLib.utils.ts +++ b/test/evm/hardhat/MerkleLib.utils.ts @@ -4,9 +4,10 @@ import { BigNumber, defaultAbiCoder, keccak256, - toBNWei, + toBNWeiWithDecimals, createRandomBytes32, Contract, + addressToBytes, } from "../../../utils/utils"; import { amountToReturn, repaymentChainId } from "./constants"; import { MerkleTree } from "../../../utils/MerkleTree"; @@ -119,9 +120,14 @@ export async function constructSingleRelayerRefundTree(l2Token: Contract | Strin return { leaves, tree }; } -export async function constructSingleChainTree(token: string, scalingSize = 1, repaymentChain = repaymentChainId) { - const tokensSendToL2 = toBNWei(100 * scalingSize); - const realizedLpFees = toBNWei(10 * scalingSize); +export async function constructSingleChainTree( + token: string, + scalingSize = 1, + repaymentChain = repaymentChainId, + decimals = 18 +) { + const tokensSendToL2 = toBNWeiWithDecimals(100 * scalingSize, decimals); + const realizedLpFees = toBNWeiWithDecimals(10 * scalingSize, decimals); const leaves = buildPoolRebalanceLeaves( [repaymentChain], // repayment chain. In this test we only want to send one token to one chain. [[token]], // l1Token. We will only be sending 1 token to one chain. diff --git a/test/evm/hardhat/SpokePool.Admin.ts b/test/evm/hardhat/SpokePool.Admin.ts index eea2b0667..1818e10bd 100644 --- a/test/evm/hardhat/SpokePool.Admin.ts +++ b/test/evm/hardhat/SpokePool.Admin.ts @@ -1,4 +1,4 @@ -import { expect, ethers, Contract, SignerWithAddress, getContractFactory } from "../../../utils/utils"; +import { expect, ethers, Contract, SignerWithAddress, getContractFactory, addressToBytes } from "../../../utils/utils"; import { hre } from "../../../utils/utils.hre"; import { spokePoolFixture } from "./fixtures/SpokePool.Fixture"; import { destinationChainId, mockRelayerRefundRoot, mockSlowRelayRoot } from "./constants"; @@ -49,7 +49,7 @@ describe("SpokePool Admin Functions", async function () { expect(await spokePool.rootBundles(0)).has.property("relayerRefundRoot", mockRelayerRefundRoot); await expect(spokePool.connect(owner).emergencyDeleteRootBundle(0)) - .to.emit(spokePool, "EmergencyDeleteRootBundle") + .to.emit(spokePool, "EmergencyDeletedRootBundle") .withArgs(0); expect(await spokePool.rootBundles(0)).has.property("slowRelayRoot", ethers.utils.hexZeroPad("0x0", 32)); diff --git a/test/evm/hardhat/SpokePool.ClaimRelayerRefund.ts b/test/evm/hardhat/SpokePool.ClaimRelayerRefund.ts new file mode 100644 index 000000000..2c9778fd8 --- /dev/null +++ b/test/evm/hardhat/SpokePool.ClaimRelayerRefund.ts @@ -0,0 +1,92 @@ +import { + SignerWithAddress, + seedContract, + seedWallet, + expect, + Contract, + ethers, + toBN, + addressToBytes, +} from "../../../utils/utils"; +import * as consts from "./constants"; +import { spokePoolFixture } from "./fixtures/SpokePool.Fixture"; + +let spokePool: Contract, destErc20: Contract, weth: Contract; +let deployerWallet: SignerWithAddress, relayer: SignerWithAddress, rando: SignerWithAddress; + +let destinationChainId: number; + +describe("SpokePool with Blacklisted destErc20", function () { + beforeEach(async function () { + [deployerWallet, relayer, rando] = await ethers.getSigners(); + ({ spokePool, destErc20, weth } = await spokePoolFixture()); + + destinationChainId = Number(await spokePool.chainId()); + await seedContract(spokePool, deployerWallet, [destErc20], weth, consts.amountHeldByPool); + }); + + it("Blacklist destErc20 operates as expected", async function () { + // Transfer tokens to relayer before blacklisting works as expected. + await seedWallet(deployerWallet, [destErc20], weth, consts.amountToRelay); + await destErc20.connect(deployerWallet).transfer(relayer.address, consts.amountToRelay); + expect(await destErc20.balanceOf(relayer.address)).to.equal(consts.amountToRelay); + + await destErc20.setBlacklistStatus(relayer.address, true); // Blacklist the relayer + + // Attempt to transfer tokens to the blacklisted relayer + await expect(destErc20.connect(deployerWallet).transfer(relayer.address, consts.amountToRelay)).to.be.revertedWith( + "Recipient is blacklisted" + ); + }); + + it("Executes repayments and handles blacklisted addresses", async function () { + // No starting relayer liability. + expect(await spokePool.getRelayerRefund(destErc20.address, relayer.address)).to.equal(toBN(0)); + expect(await destErc20.balanceOf(rando.address)).to.equal(toBN(0)); + expect(await destErc20.balanceOf(relayer.address)).to.equal(toBN(0)); + // Blacklist the relayer + await destErc20.setBlacklistStatus(relayer.address, true); + + // Distribute relayer refunds. some refunds go to blacklisted address and some go to non-blacklisted address. + + await spokePool + .connect(deployerWallet) + .distributeRelayerRefunds( + destinationChainId, + consts.amountToReturn, + [consts.amountToRelay, consts.amountToRelay], + 0, + destErc20.address, + [relayer.address, rando.address] + ); + + // Ensure relayerRepaymentLiability is incremented + expect(await spokePool.getRelayerRefund(destErc20.address, relayer.address)).to.equal(consts.amountToRelay); + expect(await destErc20.balanceOf(rando.address)).to.equal(consts.amountToRelay); + expect(await destErc20.balanceOf(relayer.address)).to.equal(toBN(0)); + }); + it("Relayer with failed repayment can claim their refund", async function () { + await destErc20.setBlacklistStatus(relayer.address, true); + + await spokePool + .connect(deployerWallet) + .distributeRelayerRefunds( + destinationChainId, + consts.amountToReturn, + [consts.amountToRelay], + 0, + destErc20.address, + [relayer.address] + ); + + await expect( + spokePool.connect(relayer).claimRelayerRefund(addressToBytes(destErc20.address), addressToBytes(relayer.address)) + ).to.be.revertedWith("Recipient is blacklisted"); + + expect(await destErc20.balanceOf(rando.address)).to.equal(toBN(0)); + await spokePool + .connect(relayer) + .claimRelayerRefund(addressToBytes(destErc20.address), addressToBytes(rando.address)); + expect(await destErc20.balanceOf(rando.address)).to.equal(consts.amountToRelay); + }); +}); diff --git a/test/evm/hardhat/SpokePool.Deposit.ts b/test/evm/hardhat/SpokePool.Deposit.ts index 0069bac77..1160f5d72 100644 --- a/test/evm/hardhat/SpokePool.Deposit.ts +++ b/test/evm/hardhat/SpokePool.Deposit.ts @@ -8,6 +8,8 @@ import { toWei, randomAddress, BigNumber, + addressToBytes, + bytes32ToAddress, } from "../../../utils/utils"; import { spokePoolFixture, @@ -27,6 +29,7 @@ import { originChainId, MAX_EXCLUSIVITY_OFFSET_SECONDS, zeroAddress, + SpokePoolFuncs, } from "./constants"; const { AddressZero: ZERO_ADDRESS } = ethers.constants; @@ -88,8 +91,8 @@ describe("SpokePool Depositor Logic", async function () { ) .to.emit(spokePool, "V3FundsDeposited") .withArgs( - erc20.address, - ZERO_ADDRESS, + addressToBytes(erc20.address), + addressToBytes(ZERO_ADDRESS), amountToDeposit, amountReceived, destinationChainId, @@ -97,9 +100,9 @@ describe("SpokePool Depositor Logic", async function () { quoteTimestamp, MAX_UINT32, 0, - depositor.address, - recipient.address, - ZERO_ADDRESS, + addressToBytes(depositor.address), + addressToBytes(recipient.address), + addressToBytes(ZERO_ADDRESS), "0x" ); @@ -128,8 +131,8 @@ describe("SpokePool Depositor Logic", async function () { ) .to.emit(spokePool, "V3FundsDeposited") .withArgs( - erc20.address, - ZERO_ADDRESS, + addressToBytes(erc20.address), + addressToBytes(ZERO_ADDRESS), amountToDeposit, amountReceived, destinationChainId, @@ -137,9 +140,9 @@ describe("SpokePool Depositor Logic", async function () { quoteTimestamp, BigNumber.from("0xFFFFFFFF"), 0, - newDepositor, // Depositor is overridden. - recipient.address, - ZERO_ADDRESS, + addressToBytes(newDepositor), // Depositor is overridden. + addressToBytes(recipient.address), + addressToBytes(ZERO_ADDRESS), "0x" ); }); @@ -349,17 +352,42 @@ describe("SpokePool Depositor Logic", async function () { function getDepositArgsFromRelayData( _relayData: V3RelayData, _destinationChainId = destinationChainId, + _quoteTimestamp = quoteTimestamp, + _isAddressOverload = false + ) { + return [ + _isAddressOverload ? bytes32ToAddress(_relayData.depositor) : addressToBytes(_relayData.depositor), + _isAddressOverload ? bytes32ToAddress(_relayData.recipient) : addressToBytes(_relayData.recipient), + _isAddressOverload ? bytes32ToAddress(_relayData.inputToken) : addressToBytes(_relayData.inputToken), + _isAddressOverload ? bytes32ToAddress(_relayData.outputToken) : addressToBytes(_relayData.outputToken), + _relayData.inputAmount, + _relayData.outputAmount, + _destinationChainId, + _isAddressOverload + ? bytes32ToAddress(_relayData.exclusiveRelayer) + : addressToBytes(_relayData.exclusiveRelayer), + _quoteTimestamp, + _relayData.fillDeadline, + _relayData.exclusivityDeadline, + _relayData.message, + ]; + } + function getUnsafeDepositArgsFromRelayData( + _relayData: V3RelayData, + _depositId: string, + _destinationChainId = destinationChainId, _quoteTimestamp = quoteTimestamp ) { return [ - _relayData.depositor, - _relayData.recipient, - _relayData.inputToken, - _relayData.outputToken, + addressToBytes(_relayData.depositor), + addressToBytes(_relayData.recipient), + addressToBytes(_relayData.inputToken), + addressToBytes(_relayData.outputToken), _relayData.inputAmount, _relayData.outputAmount, _destinationChainId, - _relayData.exclusiveRelayer, + addressToBytes(_relayData.exclusiveRelayer), + _depositId, _quoteTimestamp, _relayData.fillDeadline, _relayData.exclusivityDeadline, @@ -368,15 +396,15 @@ describe("SpokePool Depositor Logic", async function () { } beforeEach(async function () { relayData = { - depositor: depositor.address, - recipient: recipient.address, - exclusiveRelayer: ZERO_ADDRESS, - inputToken: erc20.address, - outputToken: randomAddress(), + depositor: addressToBytes(depositor.address), + recipient: addressToBytes(recipient.address), + exclusiveRelayer: addressToBytes(ZERO_ADDRESS), + inputToken: addressToBytes(erc20.address), + outputToken: addressToBytes(randomAddress()), inputAmount: amountToDeposit, outputAmount: amountToDeposit.sub(19), originChainId: originChainId, - depositId: 0, + depositId: toBN(0), fillDeadline: quoteTimestamp + 1000, exclusivityDeadline: 0, message: "0x", @@ -384,29 +412,44 @@ describe("SpokePool Depositor Logic", async function () { depositArgs = getDepositArgsFromRelayData(relayData); }); it("placeholder: gas test", async function () { - await spokePool.connect(depositor).depositV3(...depositArgs); + await spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](...depositArgs); + }); + it("should allow depositv3 with address overload", async function () { + await spokePool + .connect(depositor) + [SpokePoolFuncs.depositV3Address]( + ...getDepositArgsFromRelayData(relayData, destinationChainId, quoteTimestamp, true) + ); }); it("route disabled", async function () { // Verify that routes are disabled by default for a new route const _depositArgs = getDepositArgsFromRelayData(relayData, 999); - await expect(spokePool.connect(depositor).depositV3(..._depositArgs)).to.be.revertedWith("DisabledRoute"); + await expect(spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](..._depositArgs)).to.be.revertedWith( + "DisabledRoute" + ); // Enable the route: await spokePool.connect(depositor).setEnableRoute(erc20.address, 999, true); - await expect(spokePool.connect(depositor).depositV3(..._depositArgs)).to.not.be.reverted; + await expect(spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](..._depositArgs)).to.not.be.reverted; }); it("invalid quoteTimestamp", async function () { const quoteTimeBuffer = await spokePool.depositQuoteTimeBuffer(); const currentTime = await spokePool.getCurrentTime(); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( // quoteTimestamp too far into past (i.e. beyond the buffer) ...getDepositArgsFromRelayData(relayData, destinationChainId, currentTime.sub(quoteTimeBuffer).sub(1)) ) ).to.be.revertedWith("InvalidQuoteTimestamp"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( + // quoteTimestamp in the future should also revert with InvalidQuoteTimestamp + ...getDepositArgsFromRelayData(relayData, destinationChainId, currentTime.add(500)) + ) + ).to.be.revertedWith("InvalidQuoteTimestamp"); + await expect( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( // quoteTimestamp right at the buffer is OK ...getDepositArgsFromRelayData(relayData, destinationChainId, currentTime.sub(quoteTimeBuffer)) ) @@ -417,19 +460,19 @@ describe("SpokePool Depositor Logic", async function () { const currentTime = await spokePool.getCurrentTime(); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( // fillDeadline too far into future (i.e. beyond the buffer) ...getDepositArgsFromRelayData({ ...relayData, fillDeadline: currentTime.add(fillDeadlineBuffer).add(1) }) ) ).to.be.revertedWith("InvalidFillDeadline"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( // fillDeadline in past ...getDepositArgsFromRelayData({ ...relayData, fillDeadline: currentTime.sub(1) }) ) ).to.be.revertedWith("InvalidFillDeadline"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( // fillDeadline right at the buffer is OK ...getDepositArgsFromRelayData({ ...relayData, fillDeadline: currentTime.add(fillDeadlineBuffer) }) ) @@ -440,7 +483,7 @@ describe("SpokePool Depositor Logic", async function () { // If exclusive deadline is not zero, then exclusive relayer must be set. await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -449,7 +492,7 @@ describe("SpokePool Depositor Logic", async function () { ) ).to.be.revertedWith("InvalidExclusiveRelayer"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -458,7 +501,7 @@ describe("SpokePool Depositor Logic", async function () { ) ).to.be.revertedWith("InvalidExclusiveRelayer"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -467,7 +510,7 @@ describe("SpokePool Depositor Logic", async function () { ) ).to.be.revertedWith("InvalidExclusiveRelayer"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -476,7 +519,7 @@ describe("SpokePool Depositor Logic", async function () { ) ).to.be.revertedWith("InvalidExclusiveRelayer"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -485,7 +528,7 @@ describe("SpokePool Depositor Logic", async function () { ) ).to.be.revertedWith("InvalidExclusiveRelayer"); await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData({ ...relayData, exclusiveRelayer: zeroAddress, @@ -499,7 +542,7 @@ describe("SpokePool Depositor Logic", async function () { const fillDeadlineOffset = 1000; const exclusivityDeadlineOffset = MAX_EXCLUSIVITY_OFFSET_SECONDS; await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData( { ...relayData, @@ -525,7 +568,7 @@ describe("SpokePool Depositor Logic", async function () { currentTime + exclusivityDeadlineOffset, // exclusivityDeadline should be current time + offset relayData.depositor, relayData.recipient, - depositor.address, + addressToBytes(depositor.address), relayData.message ); }); @@ -534,7 +577,7 @@ describe("SpokePool Depositor Logic", async function () { const fillDeadlineOffset = 1000; const exclusivityDeadlineTimestamp = MAX_EXCLUSIVITY_OFFSET_SECONDS + 1; await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData( { ...relayData, @@ -560,7 +603,7 @@ describe("SpokePool Depositor Logic", async function () { exclusivityDeadlineTimestamp, // exclusivityDeadline should be passed in time relayData.depositor, relayData.recipient, - depositor.address, + addressToBytes(depositor.address), relayData.message ); }); @@ -569,7 +612,7 @@ describe("SpokePool Depositor Logic", async function () { const fillDeadlineOffset = 1000; const zeroExclusivity = 0; await expect( - spokePool.connect(depositor).depositV3( + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes]( ...getDepositArgsFromRelayData( { ...relayData, @@ -595,7 +638,7 @@ describe("SpokePool Depositor Logic", async function () { 0, // Exclusivity deadline should always be 0 relayData.depositor, relayData.recipient, - depositor.address, + addressToBytes(depositor.address), relayData.message ); }); @@ -603,14 +646,16 @@ describe("SpokePool Depositor Logic", async function () { await expect( spokePool .connect(depositor) - .depositV3(...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { value: 1 }) + [SpokePoolFuncs.depositV3Bytes](...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { + value: 1, + }) ).to.be.revertedWith("MsgValueDoesNotMatchInputAmount"); // Pulls ETH from depositor and deposits it into WETH via the wrapped contract. await expect(() => spokePool .connect(depositor) - .depositV3(...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { + [SpokePoolFuncs.depositV3Bytes](...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { value: amountToDeposit, }) ).to.changeEtherBalances([depositor, weth], [amountToDeposit.mul(toBN("-1")), amountToDeposit]); // ETH should transfer from depositor to WETH contract. @@ -620,39 +665,42 @@ describe("SpokePool Depositor Logic", async function () { }); it("if input token is not WETH then msg.value must be 0", async function () { await expect( - spokePool.connect(depositor).depositV3(...getDepositArgsFromRelayData(relayData), { value: 1 }) + spokePool + .connect(depositor) + [SpokePoolFuncs.depositV3Bytes](...getDepositArgsFromRelayData(relayData), { value: 1 }) ).to.be.revertedWith("MsgValueDoesNotMatchInputAmount"); }); it("if input token is WETH and msg.value = 0, pulls ERC20 from depositor", async function () { await expect(() => spokePool .connect(depositor) - .depositV3(...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { value: 0 }) + [SpokePoolFuncs.depositV3Bytes](...getDepositArgsFromRelayData({ ...relayData, inputToken: weth.address }), { + value: 0, + }) ).to.changeTokenBalances(weth, [depositor, spokePool], [amountToDeposit.mul(toBN("-1")), amountToDeposit]); }); it("pulls input token from caller", async function () { - await expect(() => spokePool.connect(depositor).depositV3(...depositArgs)).to.changeTokenBalances( - erc20, - [depositor, spokePool], - [amountToDeposit.mul(toBN("-1")), amountToDeposit] - ); + await expect(() => + spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](...depositArgs) + ).to.changeTokenBalances(erc20, [depositor, spokePool], [amountToDeposit.mul(toBN("-1")), amountToDeposit]); }); it("depositV3Now uses current time as quote time", async function () { const currentTime = (await spokePool.getCurrentTime()).toNumber(); const fillDeadlineOffset = 1000; const exclusivityDeadline = 0; + await expect( spokePool .connect(depositor) - .depositV3Now( - relayData.depositor, - relayData.recipient, - relayData.inputToken, - relayData.outputToken, + [SpokePoolFuncs.depositV3NowBytes]( + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, destinationChainId, - relayData.exclusiveRelayer, + addressToBytes(relayData.exclusiveRelayer), fillDeadlineOffset, exclusivityDeadline, relayData.message @@ -660,8 +708,8 @@ describe("SpokePool Depositor Logic", async function () { ) .to.emit(spokePool, "V3FundsDeposited") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, destinationChainId, @@ -669,19 +717,58 @@ describe("SpokePool Depositor Logic", async function () { 0, currentTime, // quoteTimestamp should be current time currentTime + fillDeadlineOffset, // fillDeadline should be current time + offset + exclusivityDeadline, + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + addressToBytes(relayData.exclusiveRelayer), + relayData.message + ); + }); + it("should allow depositV3Now with address overload", async function () { + const currentTime = (await spokePool.getCurrentTime()).toNumber(); + const fillDeadlineOffset = 1000; + const exclusivityDeadline = 0; + await expect( + spokePool + .connect(depositor) + [SpokePoolFuncs.depositV3NowAddress]( + bytes32ToAddress(relayData.depositor), + bytes32ToAddress(relayData.recipient), + bytes32ToAddress(relayData.inputToken), + bytes32ToAddress(relayData.outputToken), + relayData.inputAmount, + relayData.outputAmount, + destinationChainId, + bytes32ToAddress(relayData.exclusiveRelayer), + fillDeadlineOffset, + exclusivityDeadline, + relayData.message + ) + ) + .to.emit(spokePool, "V3FundsDeposited") + .withArgs( + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), + relayData.inputAmount, + relayData.outputAmount, + destinationChainId, + // deposit ID is 0 for first deposit 0, - relayData.depositor, - relayData.recipient, - relayData.exclusiveRelayer, + currentTime, // quoteTimestamp should be current time + currentTime + fillDeadlineOffset, // fillDeadline should be current time + offset + exclusivityDeadline, + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + addressToBytes(relayData.exclusiveRelayer), relayData.message ); }); it("emits V3FundsDeposited event with correct deposit ID", async function () { - await expect(spokePool.connect(depositor).depositV3(...depositArgs)) + await expect(spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](...depositArgs)) .to.emit(spokePool, "V3FundsDeposited") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, destinationChainId, @@ -689,15 +776,15 @@ describe("SpokePool Depositor Logic", async function () { 0, quoteTimestamp, relayData.fillDeadline, - 0, - relayData.depositor, - relayData.recipient, - relayData.exclusiveRelayer, + relayData.exclusivityDeadline, + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + addressToBytes(relayData.exclusiveRelayer), relayData.message ); }); it("deposit ID state variable incremented", async function () { - await spokePool.connect(depositor).depositV3(...depositArgs); + await spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](...depositArgs); expect(await spokePool.numberOfDeposits()).to.equal(1); }); it("tokens are always pulled from caller, even if different from specified depositor", async function () { @@ -706,12 +793,12 @@ describe("SpokePool Depositor Logic", async function () { await expect( spokePool .connect(depositor) - .depositV3(...getDepositArgsFromRelayData({ ...relayData, depositor: newDepositor })) + [SpokePoolFuncs.depositV3Bytes](...getDepositArgsFromRelayData({ ...relayData, depositor: newDepositor })) ) .to.emit(spokePool, "V3FundsDeposited") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, destinationChainId, @@ -720,56 +807,96 @@ describe("SpokePool Depositor Logic", async function () { relayData.fillDeadline, 0, // New depositor - newDepositor, - relayData.recipient, - relayData.exclusiveRelayer, + addressToBytes(newDepositor), + addressToBytes(relayData.recipient), + addressToBytes(relayData.exclusiveRelayer), relayData.message ); expect(await erc20.balanceOf(depositor.address)).to.equal(balanceBefore.sub(amountToDeposit)); }); it("deposits are not paused", async function () { await spokePool.pauseDeposits(true); - await expect(spokePool.connect(depositor).depositV3(...depositArgs)).to.be.revertedWith("DepositsArePaused"); + await expect(spokePool.connect(depositor)[SpokePoolFuncs.depositV3Bytes](...depositArgs)).to.be.revertedWith( + "DepositsArePaused" + ); }); it("reentrancy protected", async function () { - const functionCalldata = spokePool.interface.encodeFunctionData("depositV3", [...depositArgs]); + const functionCalldata = spokePool.interface.encodeFunctionData(SpokePoolFuncs.depositV3Bytes, [...depositArgs]); await expect(spokePool.connect(depositor).callback(functionCalldata)).to.be.revertedWith( "ReentrancyGuard: reentrant call" ); }); + it("unsafe deposit ID", async function () { + // new deposit ID should be the uint256 equivalent of the keccak256 hash of packed {msg.sender, depositor, forcedDepositId}. + const forcedDepositId = "99"; + const expectedDepositId = BigNumber.from( + ethers.utils.solidityKeccak256( + ["address", "bytes32", "uint256"], + [depositor.address, addressToBytes(recipient.address), forcedDepositId] + ) + ); + expect( + await spokePool.getUnsafeDepositId(depositor.address, addressToBytes(recipient.address), forcedDepositId) + ).to.equal(expectedDepositId); + // Note: we deliberately set the depositor != msg.sender to test that the hashing algorithm correctly includes + // both addresses in the hash. + await expect( + spokePool + .connect(depositor) + [SpokePoolFuncs.unsafeDepositV3Bytes]( + ...getUnsafeDepositArgsFromRelayData({ ...relayData, depositor: recipient.address }, forcedDepositId) + ) + ) + .to.emit(spokePool, "V3FundsDeposited") + .withArgs( + relayData.inputToken, + relayData.outputToken, + relayData.inputAmount, + relayData.outputAmount, + destinationChainId, + expectedDepositId, + quoteTimestamp, + relayData.fillDeadline, + 0, + addressToBytes(recipient.address), + relayData.recipient, + relayData.exclusiveRelayer, + relayData.message + ); + }); }); describe("speed up V3 deposit", function () { const updatedOutputAmount = amountToDeposit.add(1); const updatedRecipient = randomAddress(); const updatedMessage = "0x1234"; - const depositId = 100; + const depositId = toBN(100); it("_verifyUpdateV3DepositMessage", async function () { const signature = await getUpdatedV3DepositSignature( depositor, depositId, originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); - await spokePool.verifyUpdateV3DepositMessage( - depositor.address, + await spokePool[SpokePoolFuncs.verifyUpdateV3DepositMessageBytes]( + addressToBytes(depositor.address), depositId, originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ); // Reverts if passed in depositor is the signer or if signature is incorrect await expect( - spokePool.verifyUpdateV3DepositMessage( - updatedRecipient, + spokePool[SpokePoolFuncs.verifyUpdateV3DepositMessageBytes]( + addressToBytes(updatedRecipient), depositId, originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -778,19 +905,19 @@ describe("SpokePool Depositor Logic", async function () { // @dev Creates an invalid signature using different params const invalidSignature = await getUpdatedV3DepositSignature( depositor, - depositId + 1, + depositId.add(toBN(1)), originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); await expect( - spokePool.verifyUpdateV3DepositMessage( - depositor.address, + spokePool[SpokePoolFuncs.verifyUpdateV3DepositMessageBytes]( + addressToBytes(depositor.address), depositId, originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, invalidSignature ) @@ -804,25 +931,27 @@ describe("SpokePool Depositor Logic", async function () { depositId, spokePoolChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); await expect( - spokePool.speedUpV3Deposit( - depositor.address, - depositId, - updatedOutputAmount, - updatedRecipient, - updatedMessage, - expectedSignature - ) + spokePool + .connect(depositor) + [SpokePoolFuncs.speedUpV3DepositBytes]( + addressToBytes(depositor.address), + depositId, + updatedOutputAmount, + addressToBytes(updatedRecipient), + updatedMessage, + expectedSignature + ) ) .to.emit(spokePool, "RequestedSpeedUpV3Deposit") .withArgs( updatedOutputAmount, depositId, - depositor.address, - updatedRecipient, + addressToBytes(depositor.address), + addressToBytes(updatedRecipient), updatedMessage, expectedSignature ); @@ -834,30 +963,81 @@ describe("SpokePool Depositor Logic", async function () { depositId, otherChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); await expect( - spokePool.verifyUpdateV3DepositMessage( - depositor.address, + spokePool[SpokePoolFuncs.verifyUpdateV3DepositMessageBytes]( + addressToBytes(depositor.address), depositId, otherChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, invalidSignatureForChain ) ).to.not.be.reverted; await expect( - spokePool.speedUpV3Deposit( - depositor.address, - depositId, + spokePool + .connect(depositor) + [SpokePoolFuncs.speedUpV3DepositBytes]( + addressToBytes(depositor.address), + depositId, + updatedOutputAmount, + addressToBytes(updatedRecipient), + updatedMessage, + invalidSignatureForChain + ) + ).to.be.revertedWith("InvalidDepositorSignature"); + }); + it("should allow speeding up V3 deposit with address overload", async function () { + const updatedOutputAmount = amountToDeposit.add(1); + const updatedRecipient = randomAddress(); + const updatedMessage = "0x1234"; + const depositId = toBN(100); + const spokePoolChainId = await spokePool.chainId(); + + const signature = await getUpdatedV3DepositSignature( + depositor, + depositId, + spokePoolChainId, + updatedOutputAmount, + updatedRecipient, + updatedMessage, + true + ); + + await spokePool[SpokePoolFuncs.verifyUpdateV3DepositMessageAddress]( + depositor.address, + depositId, + spokePoolChainId, + updatedOutputAmount, + updatedRecipient, + updatedMessage, + signature + ); + + await expect( + spokePool + .connect(depositor) + [SpokePoolFuncs.speedUpV3DepositAddress]( + depositor.address, + depositId, + updatedOutputAmount, + updatedRecipient, + updatedMessage, + signature + ) + ) + .to.emit(spokePool, "RequestedSpeedUpV3Deposit") + .withArgs( updatedOutputAmount, - updatedRecipient, + depositId, + addressToBytes(depositor.address), + addressToBytes(updatedRecipient), updatedMessage, - invalidSignatureForChain - ) - ).to.be.revertedWith("InvalidDepositorSignature"); + signature + ); }); }); }); diff --git a/test/evm/hardhat/SpokePool.ExecuteRootBundle.ts b/test/evm/hardhat/SpokePool.ExecuteRootBundle.ts index 61dc527ec..fefc39678 100644 --- a/test/evm/hardhat/SpokePool.ExecuteRootBundle.ts +++ b/test/evm/hardhat/SpokePool.ExecuteRootBundle.ts @@ -1,4 +1,13 @@ -import { SignerWithAddress, seedContract, toBN, expect, Contract, ethers, BigNumber } from "../../../utils/utils"; +import { + SignerWithAddress, + seedContract, + toBN, + expect, + Contract, + ethers, + BigNumber, + addressToBytes, +} from "../../../utils/utils"; import { buildRelayerRefundMerkleTree } from "../../svm/utils"; import * as consts from "./constants"; import { spokePoolFixture } from "./fixtures/SpokePool.Fixture"; @@ -190,6 +199,28 @@ describe("SpokePool Root Bundle Execution", function () { await expect(spokePool.connect(dataWorker).executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]))).to .be.reverted; }); + it("Execution correctly logs deferred refunds", async function () { + const { leaves, tree } = await constructSimpleTree(destErc20, destinationChainId); + + // Store new tree. + await spokePool.connect(dataWorker).relayRootBundle( + tree.getHexRoot(), // relayer refund root. Generated from the merkle tree constructed before. + consts.mockSlowRelayRoot + ); + + // Blacklist the relayer EOA to prevent it from receiving refunds. The execution should still succeed, though. + await destErc20.setBlacklistStatus(relayer.address, true); + await spokePool.connect(dataWorker).executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0])); + + // Only the non-blacklisted recipient should receive their refund. + expect(await destErc20.balanceOf(spokePool.address)).to.equal(consts.amountHeldByPool.sub(consts.amountToRelay)); + expect(await destErc20.balanceOf(relayer.address)).to.equal(toBN(0)); + expect(await destErc20.balanceOf(rando.address)).to.equal(consts.amountToRelay); + + // Check event that tracks deferred refunds. + let relayTokensEvents = await spokePool.queryFilter(spokePool.filters.ExecutedRelayerRefundRoot()); + expect(relayTokensEvents[0].args?.deferredRefunds).to.equal(true); + }); describe("_distributeRelayerRefunds", function () { it("refund address length mismatch", async function () { @@ -223,7 +254,7 @@ describe("SpokePool Root Bundle Execution", function () { .distributeRelayerRefunds(destinationChainId, toBN(1), [], 0, destErc20.address, []) ) .to.emit(spokePool, "TokensBridged") - .withArgs(toBN(1), destinationChainId, 0, destErc20.address, dataWorker.address); + .withArgs(toBN(1), destinationChainId, 0, addressToBytes(destErc20.address), dataWorker.address); }); }); describe("amountToReturn = 0", function () { @@ -280,5 +311,21 @@ describe("SpokePool Root Bundle Execution", function () { .withArgs(toBN(1), destErc20.address); }); }); + describe("Total refundAmounts > spokePool's balance", function () { + it("Reverts and emits log", async function () { + await expect( + spokePool.connect(dataWorker).distributeRelayerRefunds( + destinationChainId, + toBN(1), + [consts.amountHeldByPool, consts.amountToRelay], // spoke has only amountHeldByPool. + 0, + destErc20.address, + [relayer.address, rando.address] + ) + ).to.be.revertedWith("InsufficientSpokePoolBalanceToExecuteLeaf"); + + expect((await destErc20.queryFilter(destErc20.filters.Transfer(spokePool.address))).length).to.equal(0); + }); + }); }); }); diff --git a/test/evm/hardhat/SpokePool.Relay.ts b/test/evm/hardhat/SpokePool.Relay.ts index 88ca7de91..51c946807 100644 --- a/test/evm/hardhat/SpokePool.Relay.ts +++ b/test/evm/hardhat/SpokePool.Relay.ts @@ -8,6 +8,10 @@ import { randomAddress, createRandomBytes32, BigNumber, + addressToBytes, + bytes32ToAddress, + hashNonEmptyMessage, + toBN, } from "../../../utils/utils"; import { spokePoolFixture, @@ -59,11 +63,11 @@ describe("SpokePool Relayer Logic", async function () { beforeEach(async function () { const fillDeadline = (await spokePool.getCurrentTime()).toNumber() + 1000; relayData = { - depositor: depositor.address, - recipient: recipient.address, - exclusiveRelayer: relayer.address, - inputToken: erc20.address, - outputToken: destErc20.address, + depositor: addressToBytes(depositor.address), + recipient: addressToBytes(recipient.address), + exclusiveRelayer: addressToBytes(relayer.address), + inputToken: addressToBytes(erc20.address), + outputToken: addressToBytes(destErc20.address), inputAmount: consts.amountToDeposit, outputAmount: consts.amountToDeposit, originChainId: consts.originChainId, @@ -87,7 +91,7 @@ describe("SpokePool Relayer Logic", async function () { await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ).to.be.revertedWith("ExpiredFillDeadline"); @@ -98,7 +102,7 @@ describe("SpokePool Relayer Logic", async function () { await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ).to.be.revertedWith("RelayFilled"); @@ -109,14 +113,14 @@ describe("SpokePool Relayer Logic", async function () { await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, relayExecution.repaymentChainId, @@ -124,14 +128,14 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - relayer.address, - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(relayer.address), + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ - relayExecution.updatedRecipient, - relayExecution.updatedMessage, + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayExecution.updatedMessage), relayExecution.updatedOutputAmount, // Testing that this FillType is not "FastFill" FillType.ReplacedSlowFill, @@ -145,14 +149,14 @@ describe("SpokePool Relayer Logic", async function () { await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), true // isSlowFill ) ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, relayExecution.repaymentChainId, @@ -160,14 +164,14 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - relayer.address, - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(relayer.address), + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ - relayExecution.updatedRecipient, - relayExecution.updatedMessage, + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayExecution.updatedMessage), relayExecution.updatedOutputAmount, // Testing that this FillType is "SlowFill" FillType.SlowFill, @@ -180,14 +184,14 @@ describe("SpokePool Relayer Logic", async function () { await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, relayExecution.repaymentChainId, @@ -195,14 +199,14 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - relayer.address, - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(relayer.address), + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ - relayExecution.updatedRecipient, - relayExecution.updatedMessage, + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayExecution.updatedMessage), relayExecution.updatedOutputAmount, FillType.FastFill, ] @@ -213,13 +217,13 @@ describe("SpokePool Relayer Logic", async function () { const _relayData = { ...relayData, // Set recipient == relayer - recipient: relayer.address, + recipient: addressToBytes(relayer.address), }; const relayExecution = await getRelayExecutionParams(_relayData, consts.destinationChainId); await expect( spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ).to.not.emit(destErc20, "Transfer"); @@ -231,15 +235,15 @@ describe("SpokePool Relayer Logic", async function () { // Overwrite amount to send to be double the original amount updatedOutputAmount: consts.amountToDeposit.mul(2), // Overwrite recipient to depositor which is not the same as the original recipient - updatedRecipient: depositor.address, + updatedRecipient: addressToBytes(depositor.address), }; - expect(_relayExecution.updatedRecipient).to.not.equal(relayExecution.updatedRecipient); + expect(_relayExecution.updatedRecipient).to.not.equal(addressToBytes(relayExecution.updatedRecipient)); expect(_relayExecution.updatedOutputAmount).to.not.equal(relayExecution.updatedOutputAmount); await destErc20.connect(relayer).approve(spokePool.address, _relayExecution.updatedOutputAmount); await expect(() => spokePool.connect(relayer).fillRelayV3Internal( _relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ).to.changeTokenBalance(destErc20, depositor, consts.amountToDeposit.mul(2)); @@ -247,13 +251,13 @@ describe("SpokePool Relayer Logic", async function () { it("unwraps native token if sending to EOA", async function () { const _relayData = { ...relayData, - outputToken: weth.address, + outputToken: addressToBytes(weth.address), }; const relayExecution = await getRelayExecutionParams(_relayData, consts.destinationChainId); await expect(() => spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ) ).to.changeEtherBalance(recipient, relayExecution.updatedOutputAmount); @@ -261,7 +265,7 @@ describe("SpokePool Relayer Logic", async function () { it("slow fills send native token out of spoke pool balance", async function () { const _relayData = { ...relayData, - outputToken: weth.address, + outputToken: addressToBytes(weth.address), }; const relayExecution = await getRelayExecutionParams(_relayData, consts.destinationChainId); await weth.connect(relayer).transfer(spokePool.address, relayExecution.updatedOutputAmount); @@ -269,7 +273,7 @@ describe("SpokePool Relayer Logic", async function () { await expect(() => spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), true // isSlowFill ) ).to.changeEtherBalance(recipient, relayExecution.updatedOutputAmount); @@ -282,7 +286,7 @@ describe("SpokePool Relayer Logic", async function () { await expect(() => spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), true // isSlowFill ) ).to.changeTokenBalance(destErc20, spokePool, relayExecution.updatedOutputAmount.mul(-1)); @@ -292,7 +296,7 @@ describe("SpokePool Relayer Logic", async function () { const acrossMessageHandler = await createFake("AcrossMessageHandlerMock"); const _relayData = { ...relayData, - recipient: acrossMessageHandler.address, + recipient: addressToBytes(acrossMessageHandler.address), message: "0x1234", }; const relayExecution = await getRelayExecutionParams(_relayData, consts.destinationChainId); @@ -300,11 +304,12 @@ describe("SpokePool Relayer Logic", async function () { // Handler is called with expected params. await spokePool.connect(relayer).fillRelayV3Internal( relayExecution, - relayer.address, + addressToBytes(relayer.address), false // isSlowFill ); + expect(acrossMessageHandler.handleV3AcrossMessage).to.have.been.calledOnceWith( - _relayData.outputToken, + bytes32ToAddress(_relayData.outputToken), relayExecution.updatedOutputAmount, relayer.address, // Custom relayer _relayData.message @@ -314,14 +319,15 @@ describe("SpokePool Relayer Logic", async function () { describe("fillV3Relay", function () { it("fills are not paused", async function () { await spokePool.pauseFills(true); - await expect(spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId)).to.be.revertedWith( - "FillsArePaused" - ); + await expect( + spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId, addressToBytes(relayer.address)) + ).to.be.revertedWith("FillsArePaused"); }); it("reentrancy protected", async function () { const functionCalldata = spokePool.interface.encodeFunctionData("fillV3Relay", [ relayData, consts.repaymentChainId, + addressToBytes(relayer.address), ]); await expect(spokePool.connect(relayer).callback(functionCalldata)).to.be.revertedWith( "ReentrancyGuard: reentrant call" @@ -331,30 +337,32 @@ describe("SpokePool Relayer Logic", async function () { const _relayData = { ...relayData, // Overwrite exclusive relayer and exclusivity deadline - exclusiveRelayer: recipient.address, + exclusiveRelayer: addressToBytes(recipient.address), exclusivityDeadline: relayData.fillDeadline, }; - await expect(spokePool.connect(relayer).fillV3Relay(_relayData, consts.repaymentChainId)).to.be.revertedWith( - "NotExclusiveRelayer" - ); + await expect( + spokePool.connect(relayer).fillV3Relay(_relayData, consts.repaymentChainId, addressToBytes(relayer.address)) + ).to.be.revertedWith("NotExclusiveRelayer"); // Can send it after exclusivity deadline await expect( - spokePool.connect(relayer).fillV3Relay( - { - ..._relayData, - exclusivityDeadline: 0, - }, - consts.repaymentChainId - ) + spokePool + .connect(relayer) + .fillV3Relay( + { ..._relayData, exclusivityDeadline: 0 }, + consts.repaymentChainId, + addressToBytes(relayer.address) + ) ).to.not.be.reverted; }); it("calls _fillRelayV3 with expected params", async function () { - await expect(spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId)) + await expect( + spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId, addressToBytes(relayer.address)) + ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, consts.repaymentChainId, // Should be passed-in repayment chain ID @@ -362,14 +370,14 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - relayer.address, // Should be equal to msg.sender of fillRelayV3 - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(relayer.address), // Should be equal to msg.sender of fillRelayV3 + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ - relayData.recipient, // updatedRecipient should be equal to recipient - relayData.message, // updatedMessage should be equal to message + addressToBytes(relayData.recipient), // updatedRecipient should be equal to recipient + hashNonEmptyMessage(relayData.message), // updatedMessageHash should be equal to message hash relayData.outputAmount, // updatedOutputAmount should be equal to outputAmount // Should be FastFill FillType.FastFill, @@ -389,7 +397,7 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); }); @@ -397,24 +405,24 @@ describe("SpokePool Relayer Logic", async function () { // Clock drift between spokes can mean exclusivityDeadline is in future even when no exclusivity was applied. await spokePool.setCurrentTime(relayData.exclusivityDeadline - 1); await expect( - spokePool.connect(relayer).fillV3RelayWithUpdatedDeposit( - { - ...relayData, - exclusivityDeadline: 0, - }, - consts.repaymentChainId, - updatedOutputAmount, - updatedRecipient, - updatedMessage, - signature - ) + spokePool + .connect(relayer) + .fillV3RelayWithUpdatedDeposit( + { ...relayData, exclusivityDeadline: 0 }, + consts.repaymentChainId, + addressToBytes(relayer.address), + updatedOutputAmount, + addressToBytes(updatedRecipient), + updatedMessage, + signature + ) ).to.emit(spokePool, "FilledV3Relay"); }); it("must be exclusive relayer before exclusivity deadline", async function () { const _relayData = { ...relayData, // Overwrite exclusive relayer and exclusivity deadline - exclusiveRelayer: recipient.address, + exclusiveRelayer: addressToBytes(recipient.address), exclusivityDeadline: relayData.fillDeadline, }; await expect( @@ -423,8 +431,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( _relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -438,8 +447,9 @@ describe("SpokePool Relayer Logic", async function () { exclusivityDeadline: 0, }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -457,16 +467,17 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, consts.repaymentChainId, // Should be passed-in repayment chain ID @@ -474,15 +485,15 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - relayer.address, // Should be equal to msg.sender - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(relayer.address), // Should be equal to msg.sender + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ // Should use passed-in updated params: - updatedRecipient, - updatedMessage, + addressToBytes(updatedRecipient), + hashNonEmptyMessage(updatedMessage), updatedOutputAmount, // Should be FastFill FillType.FastFill, @@ -499,10 +510,11 @@ describe("SpokePool Relayer Logic", async function () { spokePool .connect(relayer) .fillV3RelayWithUpdatedDeposit( - { ...relayData, depositor: relayer.address }, + { ...relayData, depositor: addressToBytes(relayer.address) }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -511,10 +523,10 @@ describe("SpokePool Relayer Logic", async function () { // Incorrect signature for new deposit ID const otherSignature = await getUpdatedV3DepositSignature( depositor, - relayData.depositId + 1, + relayData.depositId.add(toBN(1)), relayData.originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); await expect( @@ -523,8 +535,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, otherSignature ) @@ -537,8 +550,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( { ...relayData, originChainId: relayData.originChainId + 1 }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -549,10 +563,11 @@ describe("SpokePool Relayer Logic", async function () { spokePool .connect(relayer) .fillV3RelayWithUpdatedDeposit( - { ...relayData, depositId: relayData.depositId + 1 }, + { ...relayData, depositId: relayData.depositId.add(toBN(1)) }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -565,8 +580,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount.sub(1), - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -579,8 +595,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - randomAddress(), + addressToBytes(randomAddress()), updatedMessage, signature ) @@ -593,8 +610,9 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, createRandomBytes32() ) @@ -608,17 +626,18 @@ describe("SpokePool Relayer Logic", async function () { relayData.depositId, relayData.originChainId, updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage ); await expect( spokePool .connect(relayer) .fillV3RelayWithUpdatedDeposit( - { ...relayData, depositor: erc1271.address }, + { ...relayData, depositor: addressToBytes(erc1271.address) }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, incorrectSignature ) @@ -627,25 +646,29 @@ describe("SpokePool Relayer Logic", async function () { spokePool .connect(relayer) .fillV3RelayWithUpdatedDeposit( - { ...relayData, depositor: erc1271.address }, + { ...relayData, depositor: addressToBytes(erc1271.address) }, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) ).to.not.be.reverted; }); it("cannot send updated fill after original fill", async function () { - await spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId); + await spokePool + .connect(relayer) + .fillV3Relay(relayData, consts.repaymentChainId, addressToBytes(relayer.address)); await expect( spokePool .connect(relayer) .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ) @@ -657,14 +680,15 @@ describe("SpokePool Relayer Logic", async function () { .fillV3RelayWithUpdatedDeposit( relayData, consts.repaymentChainId, + addressToBytes(relayer.address), updatedOutputAmount, - updatedRecipient, + addressToBytes(updatedRecipient), updatedMessage, signature ); - await expect(spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId)).to.be.revertedWith( - "RelayFilled" - ); + await expect( + spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId, addressToBytes(relayer.address)) + ).to.be.revertedWith("RelayFilled"); }); }); }); diff --git a/test/evm/hardhat/SpokePool.SlowRelay.ts b/test/evm/hardhat/SpokePool.SlowRelay.ts index c72dcad09..1088b3c34 100644 --- a/test/evm/hardhat/SpokePool.SlowRelay.ts +++ b/test/evm/hardhat/SpokePool.SlowRelay.ts @@ -1,4 +1,14 @@ -import { expect, Contract, ethers, SignerWithAddress, toBN, seedContract, seedWallet } from "../../../utils/utils"; +import { + expect, + Contract, + ethers, + SignerWithAddress, + toBN, + seedContract, + seedWallet, + addressToBytes, + hashNonEmptyMessage, +} from "../../../utils/utils"; import { spokePoolFixture, V3RelayData, getV3RelayHash, V3SlowFill, FillType } from "./fixtures/SpokePool.Fixture"; import { buildV3SlowRelayTree } from "./MerkleLib.utils"; import * as consts from "./constants"; @@ -31,11 +41,11 @@ describe("SpokePool Slow Relay Logic", async function () { beforeEach(async function () { const fillDeadline = (await spokePool.getCurrentTime()).toNumber() + 1000; relayData = { - depositor: depositor.address, - recipient: recipient.address, - exclusiveRelayer: relayer.address, - inputToken: erc20.address, - outputToken: destErc20.address, + depositor: addressToBytes(depositor.address), + recipient: addressToBytes(recipient.address), + exclusiveRelayer: addressToBytes(relayer.address), + inputToken: addressToBytes(erc20.address), + outputToken: addressToBytes(destErc20.address), inputAmount: consts.amountToDeposit, outputAmount: fullRelayAmountPostFees, originChainId: consts.originChainId, @@ -81,7 +91,7 @@ describe("SpokePool Slow Relay Logic", async function () { ); // Can fast fill after: - await spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId); + await spokePool.connect(relayer).fillV3Relay(relayData, consts.repaymentChainId, addressToBytes(relayer.address)); }); it("cannot request if FillStatus is Filled", async function () { const relayHash = getV3RelayHash(relayData, consts.destinationChainId); @@ -109,11 +119,11 @@ describe("SpokePool Slow Relay Logic", async function () { beforeEach(async function () { const fillDeadline = (await spokePool.getCurrentTime()).toNumber() + 1000; relayData = { - depositor: depositor.address, - recipient: recipient.address, - exclusiveRelayer: relayer.address, - inputToken: erc20.address, - outputToken: destErc20.address, + depositor: addressToBytes(depositor.address), + recipient: addressToBytes(recipient.address), + exclusiveRelayer: addressToBytes(relayer.address), + inputToken: addressToBytes(erc20.address), + outputToken: addressToBytes(destErc20.address), inputAmount: consts.amountToDeposit, outputAmount: fullRelayAmountPostFees, originChainId: consts.originChainId, @@ -163,7 +173,9 @@ describe("SpokePool Slow Relay Logic", async function () { // Cannot fast fill after slow fill await expect( - spokePool.connect(relayer).fillV3Relay(slowRelayLeaf.relayData, consts.repaymentChainId) + spokePool + .connect(relayer) + .fillV3Relay(slowRelayLeaf.relayData, consts.repaymentChainId, addressToBytes(relayer.address)) ).to.be.revertedWith("RelayFilled"); }); it("cannot be used to double send a fill", async function () { @@ -171,7 +183,9 @@ describe("SpokePool Slow Relay Logic", async function () { await spokePool.connect(depositor).relayRootBundle(consts.mockTreeRoot, tree.getHexRoot()); // Fill before executing slow fill - await spokePool.connect(relayer).fillV3Relay(slowRelayLeaf.relayData, consts.repaymentChainId); + await spokePool + .connect(relayer) + .fillV3Relay(slowRelayLeaf.relayData, consts.repaymentChainId, addressToBytes(relayer.address)); await expect( spokePool.connect(relayer).executeV3SlowRelayLeaf( slowRelayLeaf, @@ -214,7 +228,7 @@ describe("SpokePool Slow Relay Logic", async function () { ) ) .to.emit(spokePool, "PreLeafExecuteHook") - .withArgs(slowRelayLeaf.relayData.outputToken); + .withArgs(slowRelayLeaf.relayData.outputToken.toLowerCase()); }); it("cannot execute leaves with chain IDs not matching spoke pool's chain ID", async function () { // In this test, the merkle proof is valid for the tree relayed to the spoke pool, but the merkle leaf @@ -283,27 +297,25 @@ describe("SpokePool Slow Relay Logic", async function () { ) .to.emit(spokePool, "FilledV3Relay") .withArgs( - relayData.inputToken, - relayData.outputToken, + addressToBytes(relayData.inputToken), + addressToBytes(relayData.outputToken), relayData.inputAmount, relayData.outputAmount, - // Sets repaymentChainId to 0: - 0, + 0, // Sets repaymentChainId to 0. relayData.originChainId, relayData.depositId, relayData.fillDeadline, relayData.exclusivityDeadline, - relayData.exclusiveRelayer, - // Sets relayer address to 0x0 - consts.zeroAddress, - relayData.depositor, - relayData.recipient, - relayData.message, + addressToBytes(relayData.exclusiveRelayer), + addressToBytes(consts.zeroAddress), // Sets relayer address to 0x0 + addressToBytes(relayData.depositor), + addressToBytes(relayData.recipient), + hashNonEmptyMessage(relayData.message), [ // Uses relayData.recipient - relayData.recipient, + addressToBytes(relayData.recipient), // Uses relayData.message - relayData.message, + hashNonEmptyMessage(relayData.message), // Uses slow fill leaf's updatedOutputAmount slowRelayLeaf.updatedOutputAmount, // Should be SlowFill diff --git a/test/evm/hardhat/chain-adapters/Solana_Adapter.ts b/test/evm/hardhat/chain-adapters/Solana_Adapter.ts new file mode 100644 index 000000000..86dcb576e --- /dev/null +++ b/test/evm/hardhat/chain-adapters/Solana_Adapter.ts @@ -0,0 +1,140 @@ +/* eslint-disable no-unused-expressions */ +import { + amountToLp, + refundProposalLiveness, + bondAmount, + mockRelayerRefundRoot, + mockSlowRelayRoot, +} from "./../constants"; +import { + ethers, + expect, + Contract, + createFakeFromABI, + FakeContract, + SignerWithAddress, + getContractFactory, + seedWallet, + randomAddress, + createRandomBytes32, + trimSolanaAddress, + toWeiWithDecimals, +} from "../../../../utils/utils"; +import { hubPoolFixture, enableTokensForLP } from "../fixtures/HubPool.Fixture"; +import { constructSingleChainTree } from "../MerkleLib.utils"; +import { + CCTPTokenMessengerInterface, + CCTPTokenMinterInterface, + CCTPMessageTransmitterInterface, +} from "../../../../utils/abis"; + +let hubPool: Contract, solanaAdapter: Contract, weth: Contract, usdc: Contract, timer: Contract, mockSpoke: Contract; +let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress; +let cctpTokenMessenger: FakeContract, cctpMessageTransmitter: FakeContract, cctpTokenMinter: FakeContract; +let solanaSpokePoolBytes32: string, + solanaUsdcBytes32: string, + solanaSpokePoolUsdcVaultBytes32: string, + solanaSpokePoolAddress: string, + solanaUsdcAddress: string; + +const solanaChainId = 1234567890; // TODO: Decide how to represent Solana in Across as it does not have a chainId. +const solanaDomainId = 5; + +describe("Solana Chain Adapter", function () { + beforeEach(async function () { + [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); + ({ weth, hubPool, mockSpoke, timer, usdc } = await hubPoolFixture()); + await seedWallet(dataWorker, [usdc], weth, amountToLp); + await seedWallet(liquidityProvider, [usdc], weth, amountToLp.mul(10)); + + await enableTokensForLP(owner, hubPool, weth, [weth, usdc]); + for (const token of [weth, usdc]) { + await token.connect(liquidityProvider).approve(hubPool.address, amountToLp); + await hubPool.connect(liquidityProvider).addLiquidity(token.address, amountToLp); + await token.connect(dataWorker).approve(hubPool.address, bondAmount.mul(10)); + } + + cctpTokenMessenger = await createFakeFromABI(CCTPTokenMessengerInterface); + cctpMessageTransmitter = await createFakeFromABI(CCTPMessageTransmitterInterface); + cctpTokenMinter = await createFakeFromABI(CCTPTokenMinterInterface); + cctpTokenMessenger.localMinter.returns(cctpTokenMinter.address); + cctpTokenMinter.burnLimitsPerMessage.returns(toWeiWithDecimals("1000000", 6)); + + solanaSpokePoolBytes32 = createRandomBytes32(); + solanaUsdcBytes32 = createRandomBytes32(); + solanaSpokePoolUsdcVaultBytes32 = createRandomBytes32(); + + solanaSpokePoolAddress = trimSolanaAddress(solanaSpokePoolBytes32); + solanaUsdcAddress = trimSolanaAddress(solanaUsdcBytes32); + + solanaAdapter = await ( + await getContractFactory("Solana_Adapter", owner) + ).deploy( + usdc.address, + cctpTokenMessenger.address, + cctpMessageTransmitter.address, + solanaSpokePoolBytes32, + solanaUsdcBytes32, + solanaSpokePoolUsdcVaultBytes32 + ); + + await hubPool.setCrossChainContracts(solanaChainId, solanaAdapter.address, solanaSpokePoolAddress); + await hubPool.setPoolRebalanceRoute(solanaChainId, usdc.address, solanaUsdcAddress); + }); + + it("relayMessage calls spoke pool functions", async function () { + const newAdmin = randomAddress(); + const functionCallData = mockSpoke.interface.encodeFunctionData("setCrossDomainAdmin", [newAdmin]); + expect(await hubPool.relaySpokePoolAdminFunction(solanaChainId, functionCallData)) + .to.emit(solanaAdapter.attach(hubPool.address), "MessageRelayed") + .withArgs(solanaSpokePoolAddress.toLowerCase(), functionCallData); + expect(cctpMessageTransmitter.sendMessage).to.have.been.calledWith( + solanaDomainId, + solanaSpokePoolBytes32, + functionCallData + ); + }); + + it("Correctly calls the CCTP bridge adapter when attempting to bridge USDC", async function () { + // Create an action that will send an L1->L2 tokens transfer and bundle. For this, create a relayer repayment bundle + // and check that at it's finalization the L2 bridge contracts are called as expected. + const { leaves, tree, tokensSendToL2 } = await constructSingleChainTree(usdc.address, 1, solanaChainId, 6); + await hubPool + .connect(dataWorker) + .proposeRootBundle([3117], 1, tree.getHexRoot(), mockRelayerRefundRoot, mockSlowRelayRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness + 1); + await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); + + // Adapter should have approved CCTP TokenMessenger to spend its ERC20, but the fake instance does not pull them. + expect(await usdc.allowance(hubPool.address, cctpTokenMessenger.address)).to.equal(tokensSendToL2); + + // The correct functions should have been called on the CCTP TokenMessenger contract + expect(cctpTokenMessenger.depositForBurn).to.have.been.calledOnce; + expect(cctpTokenMessenger.depositForBurn).to.have.been.calledWith( + ethers.BigNumber.from(tokensSendToL2), + solanaDomainId, + solanaSpokePoolUsdcVaultBytes32, + usdc.address + ); + }); + + it("Correctly translates setEnableRoute calls to the spoke pool", async function () { + // Enable deposits for USDC on Solana. + const destinationChainId = 1; + const depositsEnabled = true; + await hubPool.setDepositRoute(solanaChainId, destinationChainId, solanaUsdcAddress, depositsEnabled); + + // Solana spoke pool expects to receive full bytes32 token address and uint64 for chainId. + const solanaInterface = new ethers.utils.Interface(["function setEnableRoute(bytes32, uint64, bool)"]); + const solanaMessage = solanaInterface.encodeFunctionData("setEnableRoute", [ + solanaUsdcBytes32, + destinationChainId, + depositsEnabled, + ]); + expect(cctpMessageTransmitter.sendMessage).to.have.been.calledWith( + solanaDomainId, + solanaSpokePoolBytes32, + solanaMessage + ); + }); +}); diff --git a/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts index 5476a2b43..723e513e9 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts @@ -11,6 +11,7 @@ import { seedContract, avmL1ToL2Alias, createFakeFromABI, + addressToBytes, } from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; import { hubPoolFixture } from "../fixtures/HubPool.Fixture"; diff --git a/test/evm/hardhat/chain-specific-spokepools/Ethereum_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Ethereum_SpokePool.ts index 3080995ff..a7b2d0823 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Ethereum_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Ethereum_SpokePool.ts @@ -1,5 +1,13 @@ import { mockTreeRoot, amountToReturn, amountHeldByPool } from "../constants"; -import { ethers, expect, Contract, SignerWithAddress, getContractFactory, seedContract } from "../../../../utils/utils"; +import { + ethers, + expect, + Contract, + SignerWithAddress, + getContractFactory, + seedContract, + addressToBytes, +} from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; import { hubPoolFixture } from "../fixtures/HubPool.Fixture"; import { constructSingleRelayerRefundTree } from "../MerkleLib.utils"; diff --git a/test/evm/hardhat/chain-specific-spokepools/Optimism_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Optimism_SpokePool.ts index 628b1c6bc..f629007fb 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Optimism_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Optimism_SpokePool.ts @@ -10,6 +10,7 @@ import { getContractFactory, seedContract, createFakeFromABI, + addressToBytes, } from "../../../../utils/utils"; import { CCTPTokenMessengerInterface, CCTPTokenMinterInterface } from "../../../../utils/abis"; import { hre } from "../../../../utils/utils.hre"; diff --git a/test/evm/hardhat/chain-specific-spokepools/Polygon_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Polygon_SpokePool.ts index 4a847990e..186b3df07 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Polygon_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Polygon_SpokePool.ts @@ -20,6 +20,8 @@ import { seedWallet, FakeContract, createFakeFromABI, + addressToBytes, + toBN, } from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; import { hubPoolFixture } from "../fixtures/HubPool.Fixture"; @@ -320,22 +322,30 @@ describe("Polygon Spoke Pool", function () { ]; const currentTime = (await polygonSpokePool.getCurrentTime()).toNumber(); const relayData: V3RelayData = { - depositor: owner.address, - recipient: acrossMessageHandler.address, - exclusiveRelayer: zeroAddress, - inputToken: dai.address, - outputToken: dai.address, + depositor: addressToBytes(owner.address), + recipient: addressToBytes(acrossMessageHandler.address), + exclusiveRelayer: addressToBytes(zeroAddress), + inputToken: addressToBytes(dai.address), + outputToken: addressToBytes(dai.address), inputAmount: toWei("1"), outputAmount: toWei("1"), originChainId: originChainId, - depositId: 0, + depositId: toBN(0), fillDeadline: currentTime + 7200, exclusivityDeadline: 0, message: "0x1234", }; const fillData = [ - polygonSpokePool.interface.encodeFunctionData("fillV3Relay", [relayData, repaymentChainId]), - polygonSpokePool.interface.encodeFunctionData("fillV3Relay", [{ ...relayData, depositId: 1 }, repaymentChainId]), + polygonSpokePool.interface.encodeFunctionData("fillV3Relay", [ + relayData, + repaymentChainId, + addressToBytes(relayer.address), + ]), + polygonSpokePool.interface.encodeFunctionData("fillV3Relay", [ + { ...relayData, depositId: 1 }, + repaymentChainId, + addressToBytes(relayer.address), + ]), ]; const otherData = [polygonSpokePool.interface.encodeFunctionData("wrap", [])]; diff --git a/test/evm/hardhat/constants.ts b/test/evm/hardhat/constants.ts index 14c2f6075..a8ed598d6 100644 --- a/test/evm/hardhat/constants.ts +++ b/test/evm/hardhat/constants.ts @@ -42,7 +42,7 @@ export const originChainId = 666; export const repaymentChainId = 777; -export const firstDepositId = 0; +export const firstDepositId = toBN(0); export const bondAmount = toWei("5"); @@ -109,3 +109,22 @@ export const sampleRateModel = { R1: toWei(0.07).toString(), R2: toWei(0.75).toString(), }; + +export const SpokePoolFuncs = { + unsafeDepositV3Bytes: + "unsafeDepositV3(bytes32,bytes32,bytes32,bytes32,uint256,uint256,uint256,bytes32,uint256,uint32,uint32,uint32,bytes)", + depositV3Bytes: + "depositV3(bytes32,bytes32,bytes32,bytes32,uint256,uint256,uint256,bytes32,uint32,uint32,uint32,bytes)", + depositV3Address: + "depositV3(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes)", + depositV3NowBytes: + "depositV3Now(bytes32,bytes32,bytes32,bytes32,uint256,uint256,uint256,bytes32,uint32,uint32,bytes)", + depositV3NowAddress: + "depositV3Now(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,bytes)", + speedUpV3DepositBytes: "speedUpV3Deposit(bytes32,uint256,uint256,bytes32,bytes,bytes)", + speedUpV3DepositAddress: "speedUpV3Deposit(address,uint256,uint256,address,bytes,bytes)", + verifyUpdateV3DepositMessageBytes: + "verifyUpdateV3DepositMessage(bytes32,uint256,uint256,uint256,bytes32,bytes,bytes)", + verifyUpdateV3DepositMessageAddress: + "verifyUpdateV3DepositMessage(address,uint256,uint256,uint256,address,bytes,bytes)", +}; diff --git a/test/evm/hardhat/fixtures/SpokePool.Fixture.ts b/test/evm/hardhat/fixtures/SpokePool.Fixture.ts index 277575861..a54cb58dc 100644 --- a/test/evm/hardhat/fixtures/SpokePool.Fixture.ts +++ b/test/evm/hardhat/fixtures/SpokePool.Fixture.ts @@ -40,7 +40,7 @@ export async function deploySpokePool( ).deploy("Unwhitelisted", "UNWHITELISTED", 18); await unwhitelistedErc20.addMember(consts.TokenRolesEnum.MINTER, deployerWallet.address); const destErc20 = await ( - await getContractFactory("ExpandedERC20", deployerWallet) + await getContractFactory("ExpandedERC20WithBlacklist", deployerWallet) ).deploy("L2 USD Coin", "L2 USDC", 18); await destErc20.addMember(consts.TokenRolesEnum.MINTER, deployerWallet.address); @@ -102,7 +102,7 @@ export interface V3RelayData { inputAmount: BigNumber; outputAmount: BigNumber; originChainId: number; - depositId: number; + depositId: BigNumber; fillDeadline: number; exclusivityDeadline: number; message: string; @@ -177,13 +177,39 @@ export function getRelayHash( } export function getV3RelayHash(relayData: V3RelayData, destinationChainId: number): string { + const messageHash = relayData.message == "0x" ? ethers.constants.HashZero : ethers.utils.keccak256(relayData.message); return ethers.utils.keccak256( defaultAbiCoder.encode( [ - "tuple(address depositor, address recipient, address exclusiveRelayer, address inputToken, address outputToken, uint256 inputAmount, uint256 outputAmount, uint256 originChainId, uint32 depositId, uint32 fillDeadline, uint32 exclusivityDeadline, bytes message)", - "uint256 destinationChainId", + "bytes32", // depositor + "bytes32", // recipient + "bytes32", // exclusiveRelayer + "bytes32", // inputToken + "bytes32", // outputToken + "uint256", // inputAmount + "uint256", // outputAmount + "uint256", // originChainId + "uint256", // depositId + "uint32", // fillDeadline + "uint32", // exclusivityDeadline + "bytes32", // messageHash + "uint256", // destinationChainId ], - [relayData, destinationChainId] + [ + relayData.depositor, + relayData.recipient, + relayData.exclusiveRelayer, + relayData.inputToken, + relayData.outputToken, + relayData.inputAmount, + relayData.outputAmount, + relayData.originChainId, + relayData.depositId, + relayData.fillDeadline, + relayData.exclusivityDeadline, + messageHash, + destinationChainId, + ] ) ); } @@ -336,19 +362,20 @@ export async function modifyRelayHelper( export async function getUpdatedV3DepositSignature( depositor: SignerWithAddress, - depositId: number, + depositId: BigNumber, originChainId: number, updatedOutputAmount: BigNumber, updatedRecipient: string, - updatedMessage: string + updatedMessage: string, + isAddressOverload: boolean = false ): Promise { const typedData = { types: { UpdateDepositDetails: [ - { name: "depositId", type: "uint32" }, + { name: "depositId", type: "uint256" }, { name: "originChainId", type: "uint256" }, { name: "updatedOutputAmount", type: "uint256" }, - { name: "updatedRecipient", type: "address" }, + { name: "updatedRecipient", type: isAddressOverload ? "address" : "bytes32" }, { name: "updatedMessage", type: "bytes" }, ], }, diff --git a/test/svm/SvmSpoke.Bundle.ts b/test/svm/SvmSpoke.Bundle.ts index e84f9f36f..a78e8aea6 100644 --- a/test/svm/SvmSpoke.Bundle.ts +++ b/test/svm/SvmSpoke.Bundle.ts @@ -264,6 +264,7 @@ describe("svm_spoke.bundle", () => { assert.isFalse(event.deferredRefunds, "deferredRefunds should be false"); assertSE(event.caller, owner, "caller should match"); + // Verify the tokensBridged event event = events.find((event) => event.name === "tokensBridged")?.data; assertSE(event.amountToReturn, relayerRefundLeaves[0].amountToReturn, "amountToReturn should match"); assertSE(event.chainId, chainId, "chainId should match"); diff --git a/test/svm/SvmSpoke.HandleReceiveMessage.ts b/test/svm/SvmSpoke.HandleReceiveMessage.ts index ee783b487..09e6721d4 100644 --- a/test/svm/SvmSpoke.HandleReceiveMessage.ts +++ b/test/svm/SvmSpoke.HandleReceiveMessage.ts @@ -520,34 +520,34 @@ describe("svm_spoke.handle_receive_message", () => { messageBody, }); - // Remaining accounts specific to EmergencyDeleteRootBundle. + // Remaining accounts specific to EmergencyDeletedRootBundle. // Same 3 remaining accounts passed for HandleReceiveMessage context. const emergencyDeleteRootBundleRemainingAccounts = remainingAccounts.slice(0, 3); - // closer in self-invoked EmergencyDeleteRootBundle. + // closer in self-invoked EmergencyDeletedRootBundle. emergencyDeleteRootBundleRemainingAccounts.push({ isSigner: true, isWritable: true, pubkey: provider.wallet.publicKey, }); - // state in self-invoked EmergencyDeleteRootBundle. + // state in self-invoked EmergencyDeletedRootBundle. emergencyDeleteRootBundleRemainingAccounts.push({ isSigner: false, isWritable: false, pubkey: state, }); - // root_bundle in self-invoked EmergencyDeleteRootBundle. + // root_bundle in self-invoked EmergencyDeletedRootBundle. emergencyDeleteRootBundleRemainingAccounts.push({ isSigner: false, isWritable: true, pubkey: rootBundle, }); - // event_authority in self-invoked EmergencyDeleteRootBundle (appended by Anchor with event_cpi macro). + // event_authority in self-invoked EmergencyDeletedRootBundle (appended by Anchor with event_cpi macro). emergencyDeleteRootBundleRemainingAccounts.push({ isSigner: false, isWritable: false, pubkey: eventAuthority, }); - // program in self-invoked EmergencyDeleteRootBundle (appended by Anchor with event_cpi macro). + // program in self-invoked EmergencyDeletedRootBundle (appended by Anchor with event_cpi macro). emergencyDeleteRootBundleRemainingAccounts.push({ isSigner: false, isWritable: false, diff --git a/test/svm/utils.ts b/test/svm/utils.ts index ae90762cc..591b147bd 100644 --- a/test/svm/utils.ts +++ b/test/svm/utils.ts @@ -3,7 +3,6 @@ import { AccountMeta, Keypair, PublicKey } from "@solana/web3.js"; import * as crypto from "crypto"; import { BigNumber, ethers } from "ethers"; import { MulticallHandler } from "../../target/types/multicall_handler"; - import { AcrossPlusMessageCoder, calculateRelayHashUint8Array, @@ -13,7 +12,6 @@ import { readProgramEvents, relayerRefundHashFn, } from "../../src/svm"; - import { MerkleTree } from "@uma/common"; import { RelayerRefundLeaf, RelayerRefundLeafType } from "../../src/types/svm"; diff --git a/utils/abis.ts b/utils/abis.ts index 7a52b6f1c..f33c69027 100644 --- a/utils/abis.ts +++ b/utils/abis.ts @@ -51,3 +51,17 @@ export const CCTPTokenMinterInterface = [ type: "function", }, ]; + +export const CCTPMessageTransmitterInterface = [ + { + inputs: [ + { internalType: "uint32", name: "destinationDomain", type: "uint32" }, + { internalType: "bytes32", name: "recipient", type: "bytes32" }, + { internalType: "bytes", name: "messageBody", type: "bytes" }, + ], + name: "sendMessage", + outputs: [{ internalType: "uint64", name: "", type: "uint64" }], + stateMutability: "nonpayable", + type: "function", + }, +]; diff --git a/utils/utils.ts b/utils/utils.ts index 215580421..05bcc8d6a 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -101,6 +101,19 @@ export const hexToUtf8 = (input: string) => ethers.utils.toUtf8String(input); export const createRandomBytes32 = () => ethers.utils.hexlify(ethers.utils.randomBytes(32)); +export const hexZeroPad = (input: string, length: number) => ethers.utils.hexZeroPad(input, length); + +export const addressToBytes = (input: string) => hexZeroPad(input.toLowerCase(), 32); + +export const bytes32ToAddress = (input: string) => { + if (!/^0x[a-fA-F0-9]{64}$/.test(input)) { + throw new Error("Invalid bytes32 input"); + } + return ethers.utils.getAddress("0x" + input.slice(26)); +}; + +export const isBytes32 = (input: string) => /^0x[0-9a-fA-F]{64}$/.test(input); + export async function seedWallet( walletToFund: Signer, tokens: Contract[], @@ -134,6 +147,10 @@ export function randomAddress() { return ethers.utils.getAddress(ethers.utils.hexlify(ethers.utils.randomBytes(20))); } +export function randomBytes32() { + return ethers.utils.hexlify(ethers.utils.randomBytes(32)); +} + export async function getParamType(contractName: string, functionName: string, paramName: string) { const contractFactory = await getContractFactory(contractName, new ethers.VoidSigner(ethers.constants.AddressZero)); const fragment = contractFactory.interface.fragments.find((fragment) => fragment.name === functionName); @@ -166,6 +183,25 @@ function avmL1ToL2Alias(l1Address: string) { return ethers.utils.hexlify(l2AddressAsNumber.mod(mask)); } +export function trimSolanaAddress(bytes32Address: string): string { + if (!ethers.utils.isHexString(bytes32Address, 32)) { + throw new Error("Invalid bytes32 address"); + } + + const uint160Address = ethers.BigNumber.from(bytes32Address).mask(160); + return ethers.utils.hexZeroPad(ethers.utils.hexlify(uint160Address), 20); +} + +export function hashNonEmptyMessage(message: string) { + if (!ethers.utils.isHexString(message) || message.length % 2 !== 0) throw new Error("Invalid hex message bytes"); + + // account for 0x prefix when checking length + if (message.length > 2) { + return ethers.utils.keccak256(message); + } + return ethers.utils.hexlify(new Uint8Array(32)); +} + const { defaultAbiCoder, keccak256 } = ethers.utils; export { avmL1ToL2Alias, expect, Contract, ethers, BigNumber, defaultAbiCoder, keccak256, FakeContract, Signer };