diff --git a/cspell.json b/cspell.json index 059350f412..af1ce1eda0 100644 --- a/cspell.json +++ b/cspell.json @@ -37,11 +37,13 @@ "ethersproject", "ethtx", "extralight", + "fastbridge", "ftmscan", "getids", "gitbook", "gorm", "headlessui", + "hyperliquid", "incentivized", "interchain", "ipfs", diff --git a/docs/bridge/CHANGELOG.md b/docs/bridge/CHANGELOG.md index ccf73ae349..a9466fccf2 100644 --- a/docs/bridge/CHANGELOG.md +++ b/docs/bridge/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.5.14](https://github.com/synapsecns/sanguine/compare/@synapsecns/bridge-docs@0.5.13...@synapsecns/bridge-docs@0.5.14) (2024-12-13) + +**Note:** Version bump only for package @synapsecns/bridge-docs + + + + + ## [0.5.13](https://github.com/synapsecns/sanguine/compare/@synapsecns/bridge-docs@0.5.12...@synapsecns/bridge-docs@0.5.13) (2024-12-08) **Note:** Version bump only for package @synapsecns/bridge-docs diff --git a/docs/bridge/blog-posts/2024-12-12-fastbridgev2-post.md b/docs/bridge/blog-posts/2024-12-12-fastbridgev2-post.md new file mode 100644 index 0000000000..3e87bf8d69 --- /dev/null +++ b/docs/bridge/blog-posts/2024-12-12-fastbridgev2-post.md @@ -0,0 +1,135 @@ +--- +slug: synapse-intent-network-launch +title: The Synapse Intent Network +# authors: [synapse] +tags: [update, fastbridgev2, intent-network] +--- + +import { RFQFlow } from '@site/src/components/RFQFlow' + +We are excited to announce the **Synapse Intent Network**. + + + +# Summary + +The Synapse Intent Network is a cross-chain communication protocol that enables seamless asset transfers and message passing between different blockchain networks. At its core, it provides a robust infrastructure for executing complex cross-chain operations while maintaining security and efficiency. + +
+ +
RFQ flow: get Quote, get txData, sign transaction
+
+ +
+ +This major protocol upgrade represents a fundamental shift in the architecture, introducing intent-based routing, advanced bridging capabilities, and significant gas optimizations. The changes reflect a deeper focus on user experience while substantially improving the protocol's security, efficiency, and scalability. +## Key Improvements At-A-Glance + +* **Gas Optimization Revolution** + * Achieved 30-50% reduction in transaction costs through storage and execution improvements + * Built-In Multicall allows efficient batching to save 21,000+ gas per operation + +* **Powerful Intent-Based Routing** + * New "Zap" architecture enables complex actions to be atomically executed after the intent is fulfilled + * Reduces cross-contract calls + * Allows for sophisticated bridging scenarios + +* **Exclusive Relayer Functionality** + * Intents can be assigned for exclusive fulfillment only by the Relayer who provided the best quote. + * This eliminates wasteful on-chain competition while still incentivizing low fees and fast fills. + +* **Operational Flexibility** + * Relayers can now Relay, Prove, and Claim all from different addresses. + * This offers throughput & efficiency improvements for advanced Relayers + * Multiple Quoting options for relayers to choose from to maximize competitiveness + +## Synapse Intent Network: Technical Improvements + +### Quoting and API Improvements + +The transition to the Synapse Intent Network brings significant changes to our quoting infrastructure, focusing on real-time price discovery and efficient market making. The most notable addition is active quoting alongside our existing passive quoting system. + +#### Active Quoting + +Traditional passive quoting works like an order book - relayers post standing offers that users can take. While this model is simple and efficient for stable market conditions, it can lag during volatile periods. Active quoting addresses this limitation by enabling relayers to respond to quote requests in real-time: + +```typescript +// Example WebSocket quote request format +interface QuoteRequest { + data: { + origin_chain_id: number; + dest_chain_id: number; + origin_token_addr: string; + dest_token_addr: string; + origin_amount_exact: string; + expiration_window: number; + } +} +``` + +This hybrid approach gives relayers flexibility in their market-making strategies. Simple integrations can stick with passive quoting, while sophisticated relayers can implement dynamic pricing models that account for immediate market conditions, liquidity depth, and cross-chain gas costs. + +### WebSocket API Evolution + +Supporting this new quoting model required rethinking our API infrastructure. The new WebSocket layer eliminates the need for polling and complex state management: + +```typescript +const operations = { + subscribe: "Subscribe to specific chains", + send_quote: "Respond to quote request", + request_quote: "New quote request notification" +} +``` + +The real-time nature of WebSockets dramatically reduces quote latency. Rather than repeatedly querying for updates, relayers receive instant notifications about new opportunities. This improved efficiency translates directly to better pricing for end users as relayers can operate with tighter spreads. + +## Contract Improvements + +:::info + +The Synapse Intent Network is backwards compatible with the original Fastbridge Contracts. + +::: + +### Gas Optimizations + +A core focus of V2 was reducing transaction costs without sacrificing code clarity. Through careful struct packing and custom error implementations, we've achieved significant gas savings: + +```solidity +struct BridgeTxDetails { + BridgeStatus status; // 1 byte + uint32 destChainId; // 4 bytes + uint16 proverID; // 2 bytes + uint40 proofBlockTimestamp; // 5 bytes + address proofRelayer; // 20 bytes +} // Total: 32 bytes (1 slot) +``` + +These optimizations balance efficiency with maintainability. While more aggressive packing was possible, it would have made the code significantly harder to reason about. The current implementation provides meaningful gas savings while keeping the codebase approachable for new developers. + +### Atomic Operations with Multicall and Zaps + +Cross-chain operations often require multiple transactions. V2 introduces multicall support and a new Zap interface to address this friction: + +```solidity +// Multicall enables efficient batching +fastBridge.multicallNoResults([ + abi.encodeCall(IFastBridge.prove, (request1)), + abi.encodeCall(IFastBridge.claim, (request1)) +], false); + +// Zaps enable complex atomic operations +interface IZapRecipient { + function zap( + address token, + uint256 amount, + bytes memory zapData + ) external payable returns (bytes4); +} +``` + +The Zap interface is intentionally minimal, maximizing composability while maintaining security. It enables powerful workflows like "bridge and stake" or "bridge and provide liquidity" in a single atomic transaction. These compound operations significantly improve the user experience by reducing transaction overhead and eliminating partial execution risks. + +## Final Thoughts + +The Synapse Intent Network (FastBridgeV2) update represents a thoughtful evolution of our protocol. Each change was evaluated not just for its immediate benefits, but for its long-term impact on protocol composability and user experience. The result is a more efficient, developer-friendly, and user-centric cross-chain bridging system. Please reach out to us if you have any questions or feedback. diff --git a/docs/bridge/blog-posts/RFQFlow.tsx b/docs/bridge/blog-posts/RFQFlow.tsx new file mode 100644 index 0000000000..5a25396d5d --- /dev/null +++ b/docs/bridge/blog-posts/RFQFlow.tsx @@ -0,0 +1,185 @@ +export const RFQFlow = () => { + return ( + + + + + + + + + + + + + + + + + + + originChain + + + destChain + + App / SDK + User + Relayer + Bridge + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/docs/bridge/package.json b/docs/bridge/package.json index de0c5ebf15..4dd46d154f 100644 --- a/docs/bridge/package.json +++ b/docs/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/bridge-docs", - "version": "0.5.13", + "version": "0.5.14", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/packages/contracts-rfq/CHANGELOG.md b/packages/contracts-rfq/CHANGELOG.md index 3c258f337c..5077ed21bd 100644 --- a/packages/contracts-rfq/CHANGELOG.md +++ b/packages/contracts-rfq/CHANGELOG.md @@ -3,6 +3,44 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.15.1](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.15.0...@synapsecns/contracts-rfq@0.15.1) (2024-12-11) + + +### Bug Fixes + +* **contracts-rfq:** increase coverage ([#3453](https://github.com/synapsecns/sanguine/issues/3453)) ([c341b69](https://github.com/synapsecns/sanguine/commit/c341b696a5b1f99514735acfb993b04e4a6cd3a7)) + + + + + +# [0.15.0](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.14.8...@synapsecns/contracts-rfq@0.15.0) (2024-12-10) + + +### Bug Fixes + +* **contracts-rfq:** configurable `deployBlock` ([#3437](https://github.com/synapsecns/sanguine/issues/3437)) ([cb43466](https://github.com/synapsecns/sanguine/commit/cb43466ceb7106eeee11ce615b556f8012228f39)) + + +### Features + +* **contracts-rfq:** Synapse Intent Router ([#3433](https://github.com/synapsecns/sanguine/issues/3433)) ([9900167](https://github.com/synapsecns/sanguine/commit/9900167792b5d5a59013cab7a77f5b72459e17ce)), closes [#3434](https://github.com/synapsecns/sanguine/issues/3434) [#3451](https://github.com/synapsecns/sanguine/issues/3451) + + + + + +## [0.14.8](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.14.7...@synapsecns/contracts-rfq@0.14.8) (2024-12-10) + + +### Bug Fixes + +* **contracts-rfq:** FastBridge V2 prove race condition ([#3435](https://github.com/synapsecns/sanguine/issues/3435)) ([3d3530d](https://github.com/synapsecns/sanguine/commit/3d3530de4867d66458399ee146e29a6cffdc44e5)) + + + + + ## [0.14.7](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.14.6...@synapsecns/contracts-rfq@0.14.7) (2024-12-05) **Note:** Version bump only for package @synapsecns/contracts-rfq diff --git a/packages/contracts-rfq/contracts/AdminV2.sol b/packages/contracts-rfq/contracts/AdminV2.sol index 64806426c3..0ec8a6eed0 100644 --- a/packages/contracts-rfq/contracts/AdminV2.sol +++ b/packages/contracts-rfq/contracts/AdminV2.sol @@ -18,6 +18,16 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { using SafeERC20 for IERC20; + /// @notice Struct for storing information about a prover. + /// @param id The ID of the prover: its position in `_allProvers` plus one, + /// or zero if the prover has never been added. + /// @param activeFromTimestamp The timestamp at which the prover becomes active, + /// or zero if the prover has never been added or is no longer active. + struct ProverInfo { + uint16 id; + uint240 activeFromTimestamp; + } + /// @notice The address reserved for the native gas token (ETH on Ethereum and most L2s, AVAX on Avalanche, etc.). address public constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; @@ -25,10 +35,6 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { /// @dev Only addresses with this role can post FastBridge quotes to the API. bytes32 public constant QUOTER_ROLE = keccak256("QUOTER_ROLE"); - /// @notice The role identifier for the Prover's on-chain authentication in FastBridge. - /// @dev Only addresses with this role can provide proofs that a FastBridge request has been relayed. - bytes32 public constant PROVER_ROLE = keccak256("PROVER_ROLE"); - /// @notice The role identifier for the Guard's on-chain authentication in FastBridge. /// @dev Only addresses with this role can dispute submitted relay proofs during the dispute period. bytes32 public constant GUARD_ROLE = keccak256("GUARD_ROLE"); @@ -51,6 +57,11 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { /// @notice The default cancel delay set during contract deployment. uint256 public constant DEFAULT_CANCEL_DELAY = 1 days; + /// @notice The minimum dispute penalty time that can be set by the governor. + uint256 public constant MIN_DISPUTE_PENALTY_TIME = 1 minutes; + /// @notice The default dispute penalty time set during contract deployment. + uint256 public constant DEFAULT_DISPUTE_PENALTY_TIME = 30 minutes; + /// @notice The protocol fee rate taken on the origin amount deposited in the origin chain. uint256 public protocolFeeRate; @@ -60,6 +71,20 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { /// @notice The delay period after which a transaction can be permissionlessly cancelled. uint256 public cancelDelay; + /// @notice The block number at which the contract was deployed. + /// @dev This used to be immutable in V1, but was adjusted to be mutable in V2 for chains like Arbitrum that + /// implement the `block.number` as the underlying L1 block number rather than the chain's native block number. + /// This is exposed for conveniece for off-chain indexers that need to know the deployment block. + uint256 public deployBlock; + + /// @notice The timeout period that is used to temporarily disactivate a disputed prover. + uint256 public disputePenaltyTime; + + /// @notice A list of all provers ever added to the contract. Can hold up to 2^16-1 provers. + address[] private _allProvers; + /// @notice A mapping of provers to their information: id and activeFromTimestamp. + mapping(address => ProverInfo) private _proverInfos; + /// @notice This variable is deprecated and should not be used. /// @dev Use ZapNative V2 requests instead. uint256 public immutable chainGasAmount = 0; @@ -67,6 +92,36 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { constructor(address defaultAdmin) { _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); _setCancelDelay(DEFAULT_CANCEL_DELAY); + _setDeployBlock(block.number); + _setDisputePenaltyTime(DEFAULT_DISPUTE_PENALTY_TIME); + } + + /// @inheritdoc IAdminV2 + function addProver(address prover) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (getActiveProverID(prover) != 0) revert ProverAlreadyActive(); + ProverInfo storage $ = _proverInfos[prover]; + // Add the prover to the list of all provers and record its id (its position + 1), + // if this has not already been done. + if ($.id == 0) { + _allProvers.push(prover); + uint256 id = _allProvers.length; + if (id > type(uint16).max) revert ProverCapacityExceeded(); + // Note: this is a storage write. + $.id = uint16(id); + } + // Update the activeFrom timestamp. + // Note: this is a storage write. + $.activeFromTimestamp = uint240(block.timestamp); + emit ProverAdded(prover); + } + + /// @inheritdoc IAdminV2 + function removeProver(address prover) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (getActiveProverID(prover) == 0) revert ProverNotActive(); + // We never remove provers from the list of all provers to preserve their IDs, + // so we just need to reset the activeFrom timestamp. + _proverInfos[prover].activeFromTimestamp = 0; + emit ProverRemoved(prover); } /// @inheritdoc IAdminV2 @@ -74,6 +129,16 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { _setCancelDelay(newCancelDelay); } + /// @inheritdoc IAdminV2 + function setDeployBlock(uint256 blockNumber) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setDeployBlock(blockNumber); + } + + /// @inheritdoc IAdminV2 + function setDisputePenaltyTime(uint256 newDisputePenaltyTime) external onlyRole(GOVERNOR_ROLE) { + _setDisputePenaltyTime(newDisputePenaltyTime); + } + /// @inheritdoc IAdminV2 function setProtocolFeeRate(uint256 newFeeRate) external onlyRole(GOVERNOR_ROLE) { if (newFeeRate > FEE_RATE_MAX) revert FeeRateAboveMax(); @@ -98,6 +163,66 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { } } + /// @inheritdoc IAdminV2 + function getProvers() external view returns (address[] memory provers) { + uint256 length = _allProvers.length; + // Calculate the number of active provers. + uint256 activeProversCount = 0; + for (uint256 i = 0; i < length; i++) { + if (getActiveProverID(_allProvers[i]) != 0) { + activeProversCount++; + } + } + // Do the second pass to populate the provers array. + provers = new address[](activeProversCount); + uint256 activeProversIndex = 0; + for (uint256 i = 0; i < length; i++) { + address prover = _allProvers[i]; + if (getActiveProverID(prover) != 0) { + provers[activeProversIndex++] = prover; + } + } + } + + /// @inheritdoc IAdminV2 + function getProverInfo(address prover) external view returns (uint16 proverID, uint256 activeFromTimestamp) { + proverID = _proverInfos[prover].id; + activeFromTimestamp = _proverInfos[prover].activeFromTimestamp; + } + + /// @inheritdoc IAdminV2 + function getProverInfoByID(uint16 proverID) external view returns (address prover, uint256 activeFromTimestamp) { + if (proverID == 0 || proverID > _allProvers.length) return (address(0), 0); + prover = _allProvers[proverID - 1]; + activeFromTimestamp = _proverInfos[prover].activeFromTimestamp; + } + + /// @inheritdoc IAdminV2 + function getActiveProverID(address prover) public view returns (uint16) { + // Aggregate the read operations from the same storage slot. + uint16 id = _proverInfos[prover].id; + uint256 activeFromTimestamp = _proverInfos[prover].activeFromTimestamp; + // Return zero if the prover has never been added or is no longer active. + if (activeFromTimestamp == 0 || activeFromTimestamp > block.timestamp) return 0; + return id; + } + + /// @notice Internal logic to apply the dispute penalty time to a given prover. Will make the prover inactive + /// for `disputePenaltyTime` seconds. No-op if the prover ID does not exist or prover is already inactive. + function _applyDisputePenaltyTime(uint16 proverID) internal { + // Check that the prover exists. + if (proverID == 0 || proverID > _allProvers.length) return; + address prover = _allProvers[proverID - 1]; + ProverInfo storage $ = _proverInfos[prover]; + // No-op if the prover is already inactive. + if ($.activeFromTimestamp == 0) return; + uint256 newActiveFromTimestamp = block.timestamp + disputePenaltyTime; + // Update the activeFrom timestamp. + // Note: this is a storage write. + $.activeFromTimestamp = uint240(newActiveFromTimestamp); + emit DisputePenaltyTimeApplied(prover, newActiveFromTimestamp); + } + /// @notice Internal logic to set the cancel delay. Security checks are performed outside of this function. /// @dev This function is marked as private to prevent child contracts from calling it directly. function _setCancelDelay(uint256 newCancelDelay) private { @@ -106,4 +231,20 @@ contract AdminV2 is AccessControlEnumerable, IAdminV2, IAdminV2Errors { cancelDelay = newCancelDelay; emit CancelDelayUpdated(oldCancelDelay, newCancelDelay); } + + /// @notice Internal logic to set the deploy block. Security checks are performed outside of this function. + /// @dev This function is marked as private to prevent child contracts from calling it directly. + function _setDeployBlock(uint256 blockNumber) private { + deployBlock = blockNumber; + emit DeployBlockSet(blockNumber); + } + + /// @notice Internal logic to set the dispute penalty time. Security checks are performed outside of this function. + /// @dev This function is marked as private to prevent child contracts from calling it directly. + function _setDisputePenaltyTime(uint256 newDisputePenaltyTime) private { + if (newDisputePenaltyTime < MIN_DISPUTE_PENALTY_TIME) revert DisputePenaltyTimeBelowMin(); + uint256 oldDisputePenaltyTime = disputePenaltyTime; + disputePenaltyTime = newDisputePenaltyTime; + emit DisputePenaltyTimeUpdated(oldDisputePenaltyTime, newDisputePenaltyTime); + } } diff --git a/packages/contracts-rfq/contracts/FastBridgeV2.sol b/packages/contracts-rfq/contracts/FastBridgeV2.sol index f5e9cbd363..706859c113 100644 --- a/packages/contracts-rfq/contracts/FastBridgeV2.sol +++ b/packages/contracts-rfq/contracts/FastBridgeV2.sol @@ -52,14 +52,10 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E /// @notice This variable is deprecated and should not be used. /// @dev Replaced by senderNonces. uint256 public immutable nonce = 0; - /// @notice The block number at which this contract was deployed. - uint256 public immutable deployBlock; /// @notice Initializes the FastBridgeV2 contract with the provided default admin, /// sets the default cancel delay, and records the deploy block number. - constructor(address defaultAdmin) AdminV2(defaultAdmin) { - deployBlock = block.number; - } + constructor(address defaultAdmin) AdminV2(defaultAdmin) {} // ══════════════════════════════════════ EXTERNAL MUTABLE (USER FACING) ═══════════════════════════════════════════ @@ -108,9 +104,10 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E function dispute(bytes32 transactionId) external onlyRole(GUARD_ROLE) { // Aggregate the read operations from the same storage slot. BridgeTxDetails storage $ = bridgeTxDetails[transactionId]; + uint16 proverID = $.proverID; address disputedRelayer = $.proofRelayer; BridgeStatus status = $.status; - uint56 proofBlockTimestamp = $.proofBlockTimestamp; + uint40 proofBlockTimestamp = $.proofBlockTimestamp; // Can only dispute a RELAYER_PROVED transaction within the dispute period. if (status != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect(); @@ -118,9 +115,14 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E revert DisputePeriodPassed(); } + // Apply the timeout penalty to the prover that submitted the proof. + // Note: this is a no-op if the prover has already been removed. + _applyDisputePenaltyTime(proverID); + // Update status to REQUESTED and delete the disputed proof details. // Note: these are storage writes. $.status = BridgeStatus.REQUESTED; + $.proverID = 0; $.proofRelayer = address(0); $.proofBlockTimestamp = 0; @@ -347,7 +349,9 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E } /// @inheritdoc IFastBridgeV2 - function proveV2(bytes32 transactionId, bytes32 destTxHash, address relayer) public onlyRole(PROVER_ROLE) { + function proveV2(bytes32 transactionId, bytes32 destTxHash, address relayer) public { + uint16 proverID = getActiveProverID(msg.sender); + if (proverID == 0) revert ProverNotActive(); // Can only prove a REQUESTED transaction. BridgeTxDetails storage $ = bridgeTxDetails[transactionId]; if ($.status != BridgeStatus.REQUESTED) revert StatusIncorrect(); @@ -355,7 +359,8 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E // Update status to RELAYER_PROVED and store the proof details. // Note: these are storage writes. $.status = BridgeStatus.RELAYER_PROVED; - $.proofBlockTimestamp = uint56(block.timestamp); + $.proverID = proverID; + $.proofBlockTimestamp = uint40(block.timestamp); $.proofRelayer = relayer; emit BridgeProofProvided(transactionId, relayer, destTxHash); @@ -371,7 +376,7 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E BridgeTxDetails storage $ = bridgeTxDetails[transactionId]; address proofRelayer = $.proofRelayer; BridgeStatus status = $.status; - uint56 proofBlockTimestamp = $.proofBlockTimestamp; + uint40 proofBlockTimestamp = $.proofBlockTimestamp; // Can only claim a RELAYER_PROVED transaction after the dispute period. if (status != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect(); @@ -472,14 +477,14 @@ contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2E } /// @notice Calculates the time elapsed since a proof was submitted. - /// @dev The proof.timestamp stores block timestamps as uint56 for gas optimization. - /// _timeSince(proof) handles timestamp rollover when block.timestamp > type(uint56).max but - /// proof.timestamp < type(uint56).max via an unchecked statement. + /// @dev The proof.timestamp stores block timestamps as uint40 for gas optimization. + /// _timeSince(proof) handles timestamp rollover when block.timestamp > type(uint40).max but + /// proof.timestamp < type(uint40).max via an unchecked statement. /// @param proofBlockTimestamp The block timestamp when the proof was submitted. /// @return delta The time elapsed since proof submission. - function _timeSince(uint56 proofBlockTimestamp) internal view returns (uint256 delta) { + function _timeSince(uint40 proofBlockTimestamp) internal view returns (uint256 delta) { unchecked { - delta = uint56(block.timestamp) - proofBlockTimestamp; + delta = uint40(block.timestamp) - proofBlockTimestamp; } } diff --git a/packages/contracts-rfq/contracts/interfaces/IAdminV2.sol b/packages/contracts-rfq/contracts/interfaces/IAdminV2.sol index 90115d2f49..728193110a 100644 --- a/packages/contracts-rfq/contracts/interfaces/IAdminV2.sol +++ b/packages/contracts-rfq/contracts/interfaces/IAdminV2.sol @@ -3,13 +3,35 @@ pragma solidity ^0.8.4; interface IAdminV2 { event CancelDelayUpdated(uint256 oldCancelDelay, uint256 newCancelDelay); + event DeployBlockSet(uint256 blockNumber); + event DisputePenaltyTimeUpdated(uint256 oldDisputePenaltyTime, uint256 newDisputePenaltyTime); event FeeRateUpdated(uint256 oldFeeRate, uint256 newFeeRate); event FeesSwept(address token, address recipient, uint256 amount); + event ProverAdded(address prover); + event ProverRemoved(address prover); + event DisputePenaltyTimeApplied(address prover, uint256 inactiveUntilTimestamp); + + /// @notice Allows the role admin to add a new prover to the contract. + function addProver(address prover) external; + + /// @notice Allows the role admin to remove a prover from the contract. + function removeProver(address prover) external; + /// @notice Allows the governor to set the cancel delay. The cancel delay is the time period after the transaction /// deadline during which a transaction can be permissionlessly cancelled if it hasn't been proven by any Relayer. function setCancelDelay(uint256 newCancelDelay) external; + /// @notice Allows the default admin to set the deploy block. + /// @dev This is only relevant for chains like Arbitrum that implement the `block.number` as the underlying L1 + /// block number rather than the chain's native block number. + function setDeployBlock(uint256 blockNumber) external; + + /// @notice Allows the governor to set the dispute penalty time. The dispute penalty time is the time period used to + /// temporarily deactivate a prover if its proof is disputed: prover will be inactive for the dispute penalty time + /// after the dispute is submitted. + function setDisputePenaltyTime(uint256 newDisputePenaltyTime) external; + /// @notice Allows the governor to set the protocol fee rate. The protocol fee is taken from the origin /// amount and is only applied to completed and claimed transactions. /// @dev The protocol fee is abstracted away from the relayers; they always operate using the amounts after fees. @@ -18,4 +40,20 @@ interface IAdminV2 { /// @notice Allows the governor to withdraw the accumulated protocol fees from the contract. function sweepProtocolFees(address token, address recipient) external; + + /// @notice Returns the ID of the active prover, or zero if the prover is not currently active. + function getActiveProverID(address prover) external view returns (uint16); + + /// @notice Returns the information about the prover with the provided address. + /// @return proverID The ID of the prover if it has been added before, or zero otherwise. + /// @return activeFromTimestamp The timestamp when the prover becomes active, or zero if the prover isn't active. + function getProverInfo(address prover) external view returns (uint16 proverID, uint256 activeFromTimestamp); + + /// @notice Returns the information about the prover with the provided ID. + /// @return prover The address of the prover with the provided ID, or zero the ID does not exist. + /// @return activeFromTimestamp The timestamp when the prover becomes active, or zero if the prover isn't active. + function getProverInfoByID(uint16 proverID) external view returns (address prover, uint256 activeFromTimestamp); + + /// @notice Returns the list of the active provers. + function getProvers() external view returns (address[] memory); } diff --git a/packages/contracts-rfq/contracts/interfaces/IAdminV2Errors.sol b/packages/contracts-rfq/contracts/interfaces/IAdminV2Errors.sol index 445087c87c..7fede46ec3 100644 --- a/packages/contracts-rfq/contracts/interfaces/IAdminV2Errors.sol +++ b/packages/contracts-rfq/contracts/interfaces/IAdminV2Errors.sol @@ -4,4 +4,8 @@ pragma solidity ^0.8.4; interface IAdminV2Errors { error CancelDelayBelowMin(); error FeeRateAboveMax(); + error ProverAlreadyActive(); + error ProverCapacityExceeded(); + error ProverNotActive(); + error DisputePenaltyTimeBelowMin(); } diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol index 141b3808a7..e42a923960 100644 --- a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol @@ -15,7 +15,8 @@ interface IFastBridgeV2 is IFastBridge { struct BridgeTxDetails { BridgeStatus status; uint32 destChainId; - uint56 proofBlockTimestamp; + uint16 proverID; + uint40 proofBlockTimestamp; address proofRelayer; } diff --git a/packages/contracts-rfq/contracts/interfaces/ISynapseIntentPreviewer.sol b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentPreviewer.sol new file mode 100644 index 0000000000..f717d036d6 --- /dev/null +++ b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentPreviewer.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {ISynapseIntentRouter} from "./ISynapseIntentRouter.sol"; + +interface ISynapseIntentPreviewer { + /// @notice Preview the completion of a user intent. + /// @dev Will not revert if the intent cannot be completed, returns empty values instead. + /// @dev Returns (amountIn, []) if the intent is a no-op (tokenIn == tokenOut). + /// @param swapQuoter Peripheral contract to use for swap quoting + /// @param forwardTo The address to which the proceeds of the intent should be forwarded to. + /// Note: if no forwarding is required (or done within the intent), use address(0). + /// @param tokenIn Initial token for the intent + /// @param tokenOut Final token for the intent + /// @param amountIn Initial amount of tokens to use for the intent + /// @return amountOut Final amount of tokens to receive. Zero if the intent cannot be completed. + /// @return steps Steps to use in SynapseIntentRouter in order to complete the intent. + /// Empty if the intent cannot be completed, or if intent is a no-op (tokenIn == tokenOut). + function previewIntent( + address swapQuoter, + address forwardTo, + address tokenIn, + address tokenOut, + uint256 amountIn + ) + external + view + returns (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps); +} diff --git a/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouter.sol b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouter.sol new file mode 100644 index 0000000000..592cfdc27d --- /dev/null +++ b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouter.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +interface ISynapseIntentRouter { + /// @notice Parameters for a single Zap step. + /// @param token Address of the token to use for the step + /// @param amount Amount of tokens to use for the step (type(uint256).max to use the full ZapRecipient balance) + /// @param msgValue Amount of native token to supply for the step, out of the total `msg.value` used for the + /// `fulfillIntent` call (could differ from `amount` regardless of the token type) + /// @param zapData Instructions for the ZapRecipient contract on how to execute the Zap + struct StepParams { + address token; + uint256 amount; + uint256 msgValue; + bytes zapData; + } + + /// @notice Kindly ask SIR to complete the provided intent by completing a series of Zap steps using the + /// provided ZapRecipient contract. + /// - Each step is verified to be a correct Zap as per `IZapRecipient` specification. + /// - The amounts used for each step can be predetermined or based on the proceeds from the previous steps. + /// - SIR does not perform any checks on the Zap Data; the user is responsible for ensuring correct encoding. + /// - The user is responsible for selecting the correct ZapRecipient for their intent: ZapRecipient must be + /// able to modify the Zap Data to adjust to possible changes in the passed amount value. + /// - SIR checks that the ZapRecipient balance for every token in `steps` has not increased after the last step. + /// @dev Typical workflow involves a series of preparation steps followed by the last step representing the user + /// intent such as bridging, depositing, or a simple transfer to the final recipient. The ZapRecipient must be + /// the funds recipient for the preparation steps, while the final recipient must be used for the last step. + /// @dev This function will revert in any of the following cases: + /// - The deadline has passed. + /// - The array of StepParams is empty. + /// - The amount of tokens to use for the last step is below the specified minimum. + /// - Any step fails. + /// - `msg.value` does not match `sum(steps[i].msgValue)`. + /// @param zapRecipient Address of the IZapRecipient contract to use for the Zap steps + /// @param amountIn Initial amount of tokens (steps[0].token) to transfer into ZapRecipient + /// @param minLastStepAmountIn Minimum amount of tokens (steps[N-1].token) to use for the last step + /// @param deadline Deadline for the intent to be completed + /// @param steps Parameters for each step. Use amount = type(uint256).max for steps that + /// should use the full ZapRecipient balance. + function completeIntentWithBalanceChecks( + address zapRecipient, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + StepParams[] memory steps + ) + external + payable; + + /// @notice Kindly ask SIR to complete the provided intent by completing a series of Zap steps using the + /// provided ZapRecipient contract. + /// @dev This function is identical to `completeIntentWithBalanceChecks` except that it does not verify that + /// the ZapRecipient balance for every token in `steps` has not increased after the last Zap. + /// Anyone using this function must validate that the funds are fully spent by ZapRecipient + /// using other means like separate on-chain checks or off-chain simulation. + function completeIntent( + address zapRecipient, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + StepParams[] memory steps + ) + external + payable; +} diff --git a/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouterErrors.sol b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouterErrors.sol new file mode 100644 index 0000000000..c0c82ae2a4 --- /dev/null +++ b/packages/contracts-rfq/contracts/interfaces/ISynapseIntentRouterErrors.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +interface ISynapseIntentRouterErrors { + error SIR__AmountInsufficient(); + error SIR__DeadlineExceeded(); + error SIR__MsgValueIncorrect(); + error SIR__StepsNotProvided(); + error SIR__TokenNotContract(); + error SIR__UnspentFunds(); + error SIR__ZapIncorrectReturnValue(); + error SIR__ZapNoReturnValue(); +} diff --git a/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultExtendedPool.sol b/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultExtendedPool.sol index 454ff8f33e..80e1d9ac1c 100644 --- a/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultExtendedPool.sol +++ b/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultExtendedPool.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; import {IDefaultPool} from "./IDefaultPool.sol"; diff --git a/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultPool.sol b/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultPool.sol index 195f71e221..c75bd50a65 100644 --- a/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultPool.sol +++ b/packages/contracts-rfq/contracts/legacy/router/interfaces/IDefaultPool.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; interface IDefaultPool { function swap( diff --git a/packages/contracts-rfq/contracts/legacy/router/interfaces/IWETH9.sol b/packages/contracts-rfq/contracts/legacy/router/interfaces/IWETH9.sol index a451ff5134..2eab02750f 100644 --- a/packages/contracts-rfq/contracts/legacy/router/interfaces/IWETH9.sol +++ b/packages/contracts-rfq/contracts/legacy/router/interfaces/IWETH9.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.17; +pragma solidity ^0.8.17; interface IWETH9 { function deposit() external payable; diff --git a/packages/contracts-rfq/contracts/libs/ZapDataV1.sol b/packages/contracts-rfq/contracts/libs/ZapDataV1.sol index 0b7c13a9d1..37c172eed2 100644 --- a/packages/contracts-rfq/contracts/libs/ZapDataV1.sol +++ b/packages/contracts-rfq/contracts/libs/ZapDataV1.sol @@ -12,13 +12,17 @@ library ZapDataV1 { // Offsets of the fields in the packed ZapData struct // uint16 version [000 .. 002) // uint16 amountPosition [002 .. 004) - // address target [004 .. 024) - // bytes payload [024 .. ***) + // address finalToken [004 .. 024) + // address forwardTo [024 .. 044) + // address target [044 .. 064) + // bytes payload [064 .. ***) // forgefmt: disable-start uint256 private constant OFFSET_AMOUNT_POSITION = 2; - uint256 private constant OFFSET_TARGET = 4; - uint256 private constant OFFSET_PAYLOAD = 24; + uint256 private constant OFFSET_FINAL_TOKEN = 4; + uint256 private constant OFFSET_FORWARD_TO = 24; + uint256 private constant OFFSET_TARGET = 44; + uint256 private constant OFFSET_PAYLOAD = 64; // forgefmt: disable-end error ZapDataV1__InvalidEncoding(); @@ -44,6 +48,14 @@ library ZapDataV1 { /// This will usually be `4 + 32 * n`, where `n` is the position of the token amount in /// the list of parameters of the target function (starting from 0). /// Or `AMOUNT_NOT_PRESENT` if the token amount is not encoded within `payload_`. + /// @param finalToken_ The token produced as a result of the Zap action (ERC20 or native gas token). + /// A zero address value signals that the Zap action doesn't result in any asset per se, + /// like bridging or depositing into a vault without an LP token. + /// Note: this parameter must be set to a non-zero value if the `forwardTo_` parameter is + /// set to a non-zero value. + /// @param forwardTo_ The address to which `finalToken` should be forwarded. This parameter is required only + /// if the Zap action does not automatically transfer the token to the intended recipient. + /// Otherwise, it must be set to address(0). /// @param target_ Address of the target contract. /// @param payload_ ABI-encoded calldata to be used for the `target_` contract call. /// If the target function has the token amount as an argument, any placeholder amount value @@ -51,6 +63,8 @@ library ZapDataV1 { /// be replaced with the actual amount, when the Zap Data is decoded. function encodeV1( uint16 amountPosition_, + address finalToken_, + address forwardTo_, address target_, bytes memory payload_ ) @@ -63,7 +77,7 @@ library ZapDataV1 { if (amountPosition_ != AMOUNT_NOT_PRESENT && (uint256(amountPosition_) + 32 > payload_.length)) { revert ZapDataV1__InvalidEncoding(); } - return abi.encodePacked(VERSION, amountPosition_, target_, payload_); + return abi.encodePacked(VERSION, amountPosition_, finalToken_, forwardTo_, target_, payload_); } /// @notice Extracts the version from the encoded Zap Data. @@ -74,6 +88,22 @@ library ZapDataV1 { } } + /// @notice Extracts the finalToken address from the encoded Zap Data. + function finalToken(bytes calldata encodedZapData) internal pure returns (address finalToken_) { + // Load 32 bytes from the offset and shift it 96 bits to the right to get the highest 160 bits. + assembly { + finalToken_ := shr(96, calldataload(add(encodedZapData.offset, OFFSET_FINAL_TOKEN))) + } + } + + /// @notice Extracts the forwardTo address from the encoded Zap Data. + function forwardTo(bytes calldata encodedZapData) internal pure returns (address forwardTo_) { + // Load 32 bytes from the offset and shift it 96 bits to the right to get the highest 160 bits. + assembly { + forwardTo_ := shr(96, calldataload(add(encodedZapData.offset, OFFSET_FORWARD_TO))) + } + } + /// @notice Extracts the target address from the encoded Zap Data. function target(bytes calldata encodedZapData) internal pure returns (address target_) { // Load 32 bytes from the offset and shift it 96 bits to the right to get the highest 160 bits. diff --git a/packages/contracts-rfq/contracts/router/SynapseIntentPreviewer.sol b/packages/contracts-rfq/contracts/router/SynapseIntentPreviewer.sol new file mode 100644 index 0000000000..870a86c3a6 --- /dev/null +++ b/packages/contracts-rfq/contracts/router/SynapseIntentPreviewer.sol @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +// ════════════════════════════════════════════════ INTERFACES ═════════════════════════════════════════════════════ + +import {ISynapseIntentPreviewer} from "../interfaces/ISynapseIntentPreviewer.sol"; +import {ISynapseIntentRouter} from "../interfaces/ISynapseIntentRouter.sol"; +import {ISwapQuoter} from "../legacy/rfq/interfaces/ISwapQuoter.sol"; +import {IDefaultExtendedPool, IDefaultPool} from "../legacy/router/interfaces/IDefaultExtendedPool.sol"; +import {IWETH9} from "../legacy/router/interfaces/IWETH9.sol"; + +// ═════════════════════════════════════════════ INTERNAL IMPORTS ══════════════════════════════════════════════════ + +import {Action, DefaultParams, LimitedToken, SwapQuery} from "../legacy/router/libs/Structs.sol"; +import {ZapDataV1} from "../libs/ZapDataV1.sol"; + +contract SynapseIntentPreviewer is ISynapseIntentPreviewer { + /// @notice The address reserved for the native gas token (ETH on Ethereum and most L2s, AVAX on Avalanche, etc.). + address public constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// @dev Amount value that signals that the Zap step should be performed using the full ZapRecipient balance. + uint256 internal constant FULL_BALANCE = type(uint256).max; + + error SIP__NoOpForwardNotSupported(); + error SIP__PoolTokenMismatch(); + error SIP__PoolZeroAddress(); + error SIP__RawParamsEmpty(); + error SIP__TokenNotNative(); + + /// @inheritdoc ISynapseIntentPreviewer + // solhint-disable-next-line code-complexity + function previewIntent( + address swapQuoter, + address forwardTo, + address tokenIn, + address tokenOut, + uint256 amountIn + ) + external + view + returns (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) + { + // First, check if the intent is a no-op. + if (tokenIn == tokenOut) { + if (forwardTo != address(0)) revert SIP__NoOpForwardNotSupported(); + return (amountIn, new ISynapseIntentRouter.StepParams[](0)); + } + + // Obtain the swap quote, don't put any restrictions on the actions allowed to complete the intent. + SwapQuery memory query = ISwapQuoter(swapQuoter).getAmountOut( + LimitedToken({token: tokenIn, actionMask: type(uint256).max}), tokenOut, amountIn + ); + + // Check if a quote was returned. + amountOut = query.minAmountOut; + if (amountOut == 0) { + return (0, new ISynapseIntentRouter.StepParams[](0)); + } + + // At this point we have a quote for a non-trivial action, therefore `query.rawParams` is not empty. + if (query.rawParams.length == 0) revert SIP__RawParamsEmpty(); + DefaultParams memory params = abi.decode(query.rawParams, (DefaultParams)); + + // Create the steps for the intent based on the action type. + if (params.action == Action.Swap) { + steps = _createSwapSteps(tokenIn, tokenOut, amountIn, params, forwardTo); + } else if (params.action == Action.AddLiquidity) { + steps = _createAddLiquiditySteps(tokenIn, tokenOut, params, forwardTo); + } else if (params.action == Action.RemoveLiquidity) { + steps = _createRemoveLiquiditySteps(tokenIn, tokenOut, params, forwardTo); + } else { + steps = _createHandleHativeSteps(tokenIn, tokenOut, amountIn, forwardTo); + } + } + + /// @notice Helper function to create steps for a swap. + function _createSwapSteps( + address tokenIn, + address tokenOut, + uint256 amountIn, + DefaultParams memory params, + address forwardTo + ) + internal + view + returns (ISynapseIntentRouter.StepParams[] memory steps) + { + address pool = params.pool; + if (pool == address(0)) revert SIP__PoolZeroAddress(); + // Default Pools can only host wrapped native tokens. + // Check if we start from the native gas token. + if (tokenIn == NATIVE_GAS_TOKEN) { + // Get the address of the wrapped native token. + address wrappedNative = IDefaultPool(pool).getToken(params.tokenIndexFrom); + // Sanity check tokenOut vs tokenIndexTo. + if (IDefaultPool(pool).getToken(params.tokenIndexTo) != tokenOut) revert SIP__PoolTokenMismatch(); + // Native => WrappedNative + WrappedNative => TokenOut. Forwarding is done in the second step. + return _toStepsArray( + _createWrapNativeStep({wrappedNative: wrappedNative, msgValue: amountIn, forwardTo: address(0)}), + _createSwapStep({tokenIn: wrappedNative, tokenOut: tokenOut, params: params, forwardTo: forwardTo}) + ); + } + + // Sanity check tokenIn vs tokenIndexFrom. + if (IDefaultPool(pool).getToken(params.tokenIndexFrom) != tokenIn) revert SIP__PoolTokenMismatch(); + + // Check if we end with the native gas token. + if (tokenOut == NATIVE_GAS_TOKEN) { + // Get the address of the wrapped native token. + address wrappedNative = IDefaultPool(pool).getToken(params.tokenIndexTo); + // TokenIn => WrappedNative + WrappedNative => Native. Forwarding is done in the second step. + return _toStepsArray( + _createSwapStep({tokenIn: tokenIn, tokenOut: wrappedNative, params: params, forwardTo: address(0)}), + _createUnwrapNativeStep({wrappedNative: wrappedNative, forwardTo: forwardTo}) + ); + } + + // Sanity check tokenOut vs tokenIndexTo. + if (IDefaultPool(pool).getToken(params.tokenIndexTo) != tokenOut) revert SIP__PoolTokenMismatch(); + + // TokenIn => TokenOut. + ISynapseIntentRouter.StepParams memory step = + _createSwapStep({tokenIn: tokenIn, tokenOut: tokenOut, params: params, forwardTo: forwardTo}); + return _toStepsArray(step); + } + + /// @notice Helper function to create steps for adding liquidity. + function _createAddLiquiditySteps( + address tokenIn, + address tokenOut, + DefaultParams memory params, + address forwardTo + ) + internal + view + returns (ISynapseIntentRouter.StepParams[] memory steps) + { + address pool = params.pool; + if (pool == address(0)) revert SIP__PoolZeroAddress(); + // Sanity check tokenIn vs tokenIndexFrom. + if (IDefaultPool(pool).getToken(params.tokenIndexFrom) != tokenIn) revert SIP__PoolTokenMismatch(); + // Sanity check tokenOut vs pool's LP token. + _verifyLpToken(pool, tokenOut); + // Figure out how many tokens does the pool support. + uint256[] memory amounts; + for (uint8 i = 0;; i++) { + // solhint-disable-next-line no-empty-blocks + try IDefaultExtendedPool(pool).getToken(i) returns (address) { + // Token exists, continue. + } catch { + // No more tokens, allocate the array using the correct size. + amounts = new uint256[](i); + break; + } + } + return _toStepsArray( + ISynapseIntentRouter.StepParams({ + token: tokenIn, + amount: FULL_BALANCE, + msgValue: 0, + zapData: ZapDataV1.encodeV1({ + target_: pool, + finalToken_: tokenOut, + forwardTo_: forwardTo, + // addLiquidity(amounts, minToMint, deadline) + payload_: abi.encodeCall(IDefaultExtendedPool.addLiquidity, (amounts, 0, type(uint256).max)), + // amountIn is encoded within `amounts` at `TOKEN_IN_INDEX`, `amounts` is encoded after + // (amounts.offset, minToMint, deadline, amounts.length). + amountPosition_: 4 + 32 * 4 + 32 * uint16(params.tokenIndexFrom) + }) + }) + ); + } + + /// @notice Helper function to create steps for removing liquidity. + function _createRemoveLiquiditySteps( + address tokenIn, + address tokenOut, + DefaultParams memory params, + address forwardTo + ) + internal + view + returns (ISynapseIntentRouter.StepParams[] memory steps) + { + address pool = params.pool; + if (pool == address(0)) revert SIP__PoolZeroAddress(); + // Sanity check tokenIn vs pool's LP token. + _verifyLpToken(pool, tokenIn); + // Sanity check tokenOut vs tokenIndexTo. + if (IDefaultPool(pool).getToken(params.tokenIndexTo) != tokenOut) revert SIP__PoolTokenMismatch(); + return _toStepsArray( + ISynapseIntentRouter.StepParams({ + token: tokenIn, + amount: FULL_BALANCE, + msgValue: 0, + zapData: ZapDataV1.encodeV1({ + target_: pool, + finalToken_: tokenOut, + forwardTo_: forwardTo, + // removeLiquidityOneToken(tokenAmount, tokenIndex, minAmount, deadline) + payload_: abi.encodeCall( + IDefaultExtendedPool.removeLiquidityOneToken, (0, params.tokenIndexTo, 0, type(uint256).max) + ), + // amountIn is encoded as the first parameter: tokenAmount + amountPosition_: 4 + }) + }) + ); + } + + function _verifyLpToken(address pool, address token) internal view { + (,,,,,, address lpToken) = IDefaultExtendedPool(pool).swapStorage(); + if (lpToken != token) revert SIP__PoolTokenMismatch(); + } + + /// @notice Helper function to create steps for wrapping or unwrapping native gas tokens. + function _createHandleHativeSteps( + address tokenIn, + address tokenOut, + uint256 amountIn, + address forwardTo + ) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory steps) + { + if (tokenIn == NATIVE_GAS_TOKEN) { + // tokenOut is Wrapped Native + return _toStepsArray( + _createWrapNativeStep({wrappedNative: tokenOut, msgValue: amountIn, forwardTo: forwardTo}) + ); + } + // Sanity check tokenOut + if (tokenOut != NATIVE_GAS_TOKEN) revert SIP__TokenNotNative(); + // tokenIn is Wrapped Native + return _toStepsArray(_createUnwrapNativeStep({wrappedNative: tokenIn, forwardTo: forwardTo})); + } + + /// @notice Helper function to create a single step for a swap. + function _createSwapStep( + address tokenIn, + address tokenOut, + DefaultParams memory params, + address forwardTo + ) + internal + pure + returns (ISynapseIntentRouter.StepParams memory) + { + return ISynapseIntentRouter.StepParams({ + token: tokenIn, + amount: FULL_BALANCE, + msgValue: 0, + zapData: ZapDataV1.encodeV1({ + target_: params.pool, + finalToken_: tokenOut, + forwardTo_: forwardTo, + // swap(tokenIndexFrom, tokenIndexTo, dx, minDy, deadline) + payload_: abi.encodeCall( + IDefaultPool.swap, (params.tokenIndexFrom, params.tokenIndexTo, 0, 0, type(uint256).max) + ), + // amountIn is encoded as the third parameter: `dx` + amountPosition_: 4 + 32 * 2 + }) + }); + } + + /// @notice Helper function to create a single step for wrapping native gas tokens. + function _createWrapNativeStep( + address wrappedNative, + uint256 msgValue, + address forwardTo + ) + internal + pure + returns (ISynapseIntentRouter.StepParams memory) + { + return ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: FULL_BALANCE, + msgValue: msgValue, + zapData: ZapDataV1.encodeV1({ + target_: wrappedNative, + finalToken_: wrappedNative, + forwardTo_: forwardTo, + // deposit() + payload_: abi.encodeCall(IWETH9.deposit, ()), + // amountIn is not encoded + amountPosition_: ZapDataV1.AMOUNT_NOT_PRESENT + }) + }); + } + + /// @notice Helper function to create a single step for unwrapping native gas tokens. + function _createUnwrapNativeStep( + address wrappedNative, + address forwardTo + ) + internal + pure + returns (ISynapseIntentRouter.StepParams memory) + { + return ISynapseIntentRouter.StepParams({ + token: wrappedNative, + amount: FULL_BALANCE, + msgValue: 0, + zapData: ZapDataV1.encodeV1({ + target_: wrappedNative, + finalToken_: NATIVE_GAS_TOKEN, + forwardTo_: forwardTo, + // withdraw(amount) + payload_: abi.encodeCall(IWETH9.withdraw, (0)), + // amountIn encoded as the first parameter + amountPosition_: 4 + }) + }); + } + + /// @notice Helper function to construct an array of steps having a single step. + function _toStepsArray(ISynapseIntentRouter.StepParams memory step0) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory) + { + ISynapseIntentRouter.StepParams[] memory steps = new ISynapseIntentRouter.StepParams[](1); + steps[0] = step0; + return steps; + } + + /// @notice Helper function to construct an array of steps having two steps. + function _toStepsArray( + ISynapseIntentRouter.StepParams memory step0, + ISynapseIntentRouter.StepParams memory step1 + ) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory) + { + ISynapseIntentRouter.StepParams[] memory steps = new ISynapseIntentRouter.StepParams[](2); + steps[0] = step0; + steps[1] = step1; + return steps; + } +} diff --git a/packages/contracts-rfq/contracts/router/SynapseIntentRouter.sol b/packages/contracts-rfq/contracts/router/SynapseIntentRouter.sol new file mode 100644 index 0000000000..33198add8f --- /dev/null +++ b/packages/contracts-rfq/contracts/router/SynapseIntentRouter.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +// ════════════════════════════════════════════════ INTERFACES ═════════════════════════════════════════════════════ + +import {ISynapseIntentRouter} from "../interfaces/ISynapseIntentRouter.sol"; +import {ISynapseIntentRouterErrors} from "../interfaces/ISynapseIntentRouterErrors.sol"; +import {IZapRecipient} from "../interfaces/IZapRecipient.sol"; + +// ═════════════════════════════════════════════ EXTERNAL IMPORTS ══════════════════════════════════════════════════ + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +contract SynapseIntentRouter is ISynapseIntentRouter, ISynapseIntentRouterErrors { + using SafeERC20 for IERC20; + + /// @notice The address reserved for the native gas token (ETH on Ethereum and most L2s, AVAX on Avalanche, etc.). + address public constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// @dev Amount value that signals that the Zap step should be performed using the full ZapRecipient balance. + uint256 internal constant FULL_BALANCE = type(uint256).max; + + /// @inheritdoc ISynapseIntentRouter + function completeIntentWithBalanceChecks( + address zapRecipient, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + StepParams[] calldata steps + ) + external + payable + { + // Record the initial balances of ZapRecipient for each token. + uint256 length = steps.length; + uint256[] memory initialBalances = new uint256[](length); + for (uint256 i = 0; i < length; i++) { + address token = steps[i].token; + initialBalances[i] = + token == NATIVE_GAS_TOKEN ? zapRecipient.balance : IERC20(token).balanceOf(zapRecipient); + } + + // Complete the intent as usual. + completeIntent(zapRecipient, amountIn, minLastStepAmountIn, deadline, steps); + + // Verify that the ZapRecipient balance for each token has not increased. + for (uint256 i = 0; i < length; i++) { + address token = steps[i].token; + uint256 newBalance = + token == NATIVE_GAS_TOKEN ? zapRecipient.balance : IERC20(token).balanceOf(zapRecipient); + if (newBalance > initialBalances[i]) revert SIR__UnspentFunds(); + } + } + + /// @inheritdoc ISynapseIntentRouter + function completeIntent( + address zapRecipient, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + StepParams[] calldata steps + ) + public + payable + { + // Validate the input parameters before proceeding. + uint256 length = steps.length; + if (block.timestamp > deadline) revert SIR__DeadlineExceeded(); + if (length == 0) revert SIR__StepsNotProvided(); + + // Transfer the input asset from the user to ZapRecipient. `steps[0]` exists as per check above. + _transferInputAsset(zapRecipient, steps[0].token, amountIn); + + // Perform the Zap steps, using predetermined amounts or the full balance of ZapRecipient, if instructed. + uint256 totalUsedMsgValue = 0; + for (uint256 i = 0; i < length; i++) { + address token = steps[i].token; + uint256 msgValue = steps[i].msgValue; + + // Adjust amount to be the full balance, if needed. + amountIn = steps[i].amount; + if (amountIn == FULL_BALANCE) { + amountIn = token == NATIVE_GAS_TOKEN + // Existing native balance + msg.value that will be forwarded + ? zapRecipient.balance + msgValue + : IERC20(token).balanceOf(zapRecipient); + } + + _performZap({ + zapRecipient: zapRecipient, + msgValue: msgValue, + zapRecipientCallData: abi.encodeCall(IZapRecipient.zap, (token, amountIn, steps[i].zapData)) + }); + unchecked { + // Can do unchecked addition here since we're guaranteed that the sum of all msg.value + // used for the Zaps won't overflow. + totalUsedMsgValue += msgValue; + } + } + + // Verify amountIn used for the last step, and that we fully spent `msg.value`. + if (amountIn < minLastStepAmountIn) revert SIR__AmountInsufficient(); + if (totalUsedMsgValue < msg.value) revert SIR__MsgValueIncorrect(); + } + + // ═════════════════════════════════════════════ INTERNAL METHODS ══════════════════════════════════════════════════ + + /// @notice Transfers the input asset from the user into ZapRecipient custody. This asset will later be + /// used to perform the zap steps. + function _transferInputAsset(address zapRecipient, address token, uint256 amount) internal { + if (token == NATIVE_GAS_TOKEN) { + // For the native gas token, we just need to check that the supplied `msg.value` is correct. + // We will later forward `msg.value` in the series of the steps using `StepParams.msgValue`. + if (amount != msg.value) revert SIR__MsgValueIncorrect(); + } else { + // For ERC20s, token is transferred from the user to ZapRecipient before performing the zap steps. + // Throw an explicit error if the provided token address is not a contract. + if (token.code.length == 0) revert SIR__TokenNotContract(); + IERC20(token).safeTransferFrom(msg.sender, zapRecipient, amount); + } + } + + /// @notice Performs a Zap step, using the provided msg.value and calldata. + /// Validates the return data from ZapRecipient as per `IZapRecipient` specification. + function _performZap(address zapRecipient, uint256 msgValue, bytes memory zapRecipientCallData) internal { + // Perform the low-level call to ZapRecipient, bubbling up any revert reason. + bytes memory returnData = + Address.functionCallWithValue({target: zapRecipient, data: zapRecipientCallData, value: msgValue}); + + // Explicit revert if no return data at all. + if (returnData.length == 0) revert SIR__ZapNoReturnValue(); + // Check that exactly a single return value was returned. + if (returnData.length != 32) revert SIR__ZapIncorrectReturnValue(); + // Return value should be abi-encoded hook function selector. + if (bytes32(returnData) != bytes32(IZapRecipient.zap.selector)) { + revert SIR__ZapIncorrectReturnValue(); + } + } +} diff --git a/packages/contracts-rfq/contracts/zaps/TokenZapV1.sol b/packages/contracts-rfq/contracts/zaps/TokenZapV1.sol index bd58d8f391..0e0e7859dc 100644 --- a/packages/contracts-rfq/contracts/zaps/TokenZapV1.sol +++ b/packages/contracts-rfq/contracts/zaps/TokenZapV1.sol @@ -26,8 +26,10 @@ contract TokenZapV1 is IZapRecipient { address public constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + error TokenZapV1__FinalTokenBalanceZero(); error TokenZapV1__PayloadLengthAboveMax(); error TokenZapV1__TargetZeroAddress(); + error TokenZapV1__TokenZeroAddress(); /// @notice Allows the contract to receive ETH. /// @dev Leftover ETH can be claimed by anyone. Ensure the full balance is spent during Zaps. @@ -46,6 +48,7 @@ contract TokenZapV1 is IZapRecipient { /// @param zapData Encoded Zap Data containing the target address and calldata for the Zap action. /// @return selector Selector of this function to signal the caller about the success of the Zap action. function zap(address token, uint256 amount, bytes calldata zapData) external payable returns (bytes4) { + if (token == address(0)) revert TokenZapV1__TokenZeroAddress(); // Validate the ZapData format and extract the target address. zapData.validateV1(); address target = zapData.target(); @@ -81,6 +84,11 @@ contract TokenZapV1 is IZapRecipient { // Note: this will bubble up any revert from the target contract, and revert if target is EOA. Address.functionCallWithValue({target: target, data: payload, value: msgValue}); } + // Forward the final token to the specified recipient, if required. + address forwardTo = zapData.forwardTo(); + if (forwardTo != address(0)) { + _forwardToken(zapData.finalToken(), forwardTo); + } // Return function selector to indicate successful execution return this.zap.selector; } @@ -100,10 +108,20 @@ contract TokenZapV1 is IZapRecipient { /// the list of parameters of the target function (starting from 0). /// Any value greater than or equal to `payload.length` can be used if the token amount is /// not an argument of the target function. + /// @param finalToken The token produced as a result of the Zap action (ERC20 or native gas token). + /// A zero address value signals that the Zap action doesn't result in any asset per se, + /// like bridging or depositing into a vault without an LP token. + /// Note: this parameter must be set to a non-zero value if the `forwardTo` parameter is + /// set to a non-zero value. + /// @param forwardTo The address to which `finalToken` should be forwarded. This parameter is required only + /// if the Zap action does not automatically transfer the token to the intended recipient. + /// Otherwise, it must be set to address(0). function encodeZapData( address target, bytes memory payload, - uint256 amountPosition + uint256 amountPosition, + address finalToken, + address forwardTo ) external pure @@ -112,6 +130,10 @@ contract TokenZapV1 is IZapRecipient { if (payload.length > ZapDataV1.AMOUNT_NOT_PRESENT) { revert TokenZapV1__PayloadLengthAboveMax(); } + // Final token needs to be specified if forwarding is required. + if (forwardTo != address(0) && finalToken == address(0)) { + revert TokenZapV1__TokenZeroAddress(); + } // External integrations do not need to understand the specific `AMOUNT_NOT_PRESENT` semantics. // Therefore, they can specify any value greater than or equal to `payload.length` to indicate // that the amount is not present in the payload. @@ -119,7 +141,13 @@ contract TokenZapV1 is IZapRecipient { amountPosition = ZapDataV1.AMOUNT_NOT_PRESENT; } // At this point, we have checked that both `amountPosition` and `payload.length` fit in uint16. - return ZapDataV1.encodeV1(uint16(amountPosition), target, payload); + return ZapDataV1.encodeV1({ + amountPosition_: uint16(amountPosition), + finalToken_: finalToken, + forwardTo_: forwardTo, + target_: target, + payload_: payload + }); } /// @notice Decodes the ZapData for a Zap action. Replaces the placeholder amount with the actual amount, @@ -138,4 +166,18 @@ contract TokenZapV1 is IZapRecipient { target = zapData.target(); payload = zapData.payload(amount); } + + /// @notice Forwards the proceeds of the Zap action to the specified non-zero recipient. + function _forwardToken(address token, address forwardTo) internal { + // Check the token address and its balance to be safely forwarded. + if (token == address(0)) revert TokenZapV1__TokenZeroAddress(); + uint256 amount = token == NATIVE_GAS_TOKEN ? address(this).balance : IERC20(token).balanceOf(address(this)); + if (amount == 0) revert TokenZapV1__FinalTokenBalanceZero(); + // Forward the full balance of the final token to the specified recipient. + if (token == NATIVE_GAS_TOKEN) { + Address.sendValue({recipient: payable(forwardTo), amount: amount}); + } else { + IERC20(token).safeTransfer(forwardTo, amount); + } + } } diff --git a/packages/contracts-rfq/deployments/arbitrum/SynapseIntentPreviewer.json b/packages/contracts-rfq/deployments/arbitrum/SynapseIntentPreviewer.json new file mode 100644 index 0000000000..2bca83a911 --- /dev/null +++ b/packages/contracts-rfq/deployments/arbitrum/SynapseIntentPreviewer.json @@ -0,0 +1,124 @@ +{ + "address": "0x9519E8D136d0a89d7e10D1a66C97249E0135544B", + "constructorArgs": "0x", + "receipt": { + "hash": "0xb34f3d918399ac6fa599ecedfdd4a47bd993f4f0e401698d6256dab2fd928ab9", + "blockNumber": 282619262 + }, + "abi": [ + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "previewIntent", + "inputs": [ + { + "name": "swapQuoter", + "type": "address", + "internalType": "address" + }, + { + "name": "forwardTo", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenIn", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenOut", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "error", + "name": "SIP__NoOpForwardNotSupported", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__PoolTokenMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__PoolZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__RawParamsEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__TokenNotNative", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__InvalidEncoding", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__TargetZeroAddress", + "inputs": [] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/deployments/arbitrum/SynapseIntentRouter.json b/packages/contracts-rfq/deployments/arbitrum/SynapseIntentRouter.json new file mode 100644 index 0000000000..6c10e5f25a --- /dev/null +++ b/packages/contracts-rfq/deployments/arbitrum/SynapseIntentRouter.json @@ -0,0 +1,211 @@ +{ + "address": "0x57203c65DeA2ded4EE4E303a9494bee04df030BF", + "constructorArgs": "0x", + "receipt": { + "hash": "0x5a6c34cc550a0b73a48f412018f04e97a868d60ee411364fa1a67427a0d2708b", + "blockNumber": 281258022 + }, + "abi": [ + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "completeIntent", + "inputs": [ + { + "name": "zapRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "minLastStepAmountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "completeIntentWithBalanceChecks", + "inputs": [ + { + "name": "zapRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "minLastStepAmountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__AmountInsufficient", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__DeadlineExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__MsgValueIncorrect", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__StepsNotProvided", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__TokenNotContract", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__UnspentFunds", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__ZapIncorrectReturnValue", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__ZapNoReturnValue", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/deployments/arbitrum/TokenZapV1.json b/packages/contracts-rfq/deployments/arbitrum/TokenZapV1.json new file mode 100644 index 0000000000..016ce5a627 --- /dev/null +++ b/packages/contracts-rfq/deployments/arbitrum/TokenZapV1.json @@ -0,0 +1,203 @@ +{ + "address": "0x6327797F149a75D506aFda46D5fCE6E74fC409D5", + "constructorArgs": "0x", + "receipt": { + "hash": "0x961a29a85c10275a0d1921ef606f3ed45a79e9106e379b5efd7ae14faa30b1fe", + "blockNumber": 282619267 + }, + "abi": [ + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decodeZapData", + "inputs": [ + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "payload", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "encodeZapData", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "payload", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "amountPosition", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "finalToken", + "type": "address", + "internalType": "address" + }, + { + "name": "forwardTo", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "zap", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "payable" + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TokenZapV1__FinalTokenBalanceZero", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__PayloadLengthAboveMax", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__TargetZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__TokenZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__InvalidEncoding", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__TargetZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__UnsupportedVersion", + "inputs": [ + { + "name": "version", + "type": "uint16", + "internalType": "uint16" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/deployments/optimism/SynapseIntentPreviewer.json b/packages/contracts-rfq/deployments/optimism/SynapseIntentPreviewer.json new file mode 100644 index 0000000000..7a3f5d4950 --- /dev/null +++ b/packages/contracts-rfq/deployments/optimism/SynapseIntentPreviewer.json @@ -0,0 +1,124 @@ +{ + "address": "0x9519E8D136d0a89d7e10D1a66C97249E0135544B", + "constructorArgs": "0x", + "receipt": { + "hash": "0x928a7db8741fb992934302f73e076f7630075151384529b538cb133e797c4bac", + "blockNumber": 129029951 + }, + "abi": [ + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "previewIntent", + "inputs": [ + { + "name": "swapQuoter", + "type": "address", + "internalType": "address" + }, + { + "name": "forwardTo", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenIn", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenOut", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "error", + "name": "SIP__NoOpForwardNotSupported", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__PoolTokenMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__PoolZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__RawParamsEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SIP__TokenNotNative", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__InvalidEncoding", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__TargetZeroAddress", + "inputs": [] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/deployments/optimism/SynapseIntentRouter.json b/packages/contracts-rfq/deployments/optimism/SynapseIntentRouter.json new file mode 100644 index 0000000000..03288efc1a --- /dev/null +++ b/packages/contracts-rfq/deployments/optimism/SynapseIntentRouter.json @@ -0,0 +1,211 @@ +{ + "address": "0x57203c65DeA2ded4EE4E303a9494bee04df030BF", + "constructorArgs": "0x", + "receipt": { + "hash": "0xf68cf0c65d39291cf7b293228ae1664ca8fb0b2afb32e6ed1ecbac80a38f4771", + "blockNumber": 128859363 + }, + "abi": [ + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "completeIntent", + "inputs": [ + { + "name": "zapRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "minLastStepAmountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "completeIntentWithBalanceChecks", + "inputs": [ + { + "name": "zapRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "minLastStepAmountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "steps", + "type": "tuple[]", + "internalType": "struct ISynapseIntentRouter.StepParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "msgValue", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__AmountInsufficient", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__DeadlineExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__MsgValueIncorrect", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__StepsNotProvided", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__TokenNotContract", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__UnspentFunds", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__ZapIncorrectReturnValue", + "inputs": [] + }, + { + "type": "error", + "name": "SIR__ZapNoReturnValue", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/deployments/optimism/TokenZapV1.json b/packages/contracts-rfq/deployments/optimism/TokenZapV1.json new file mode 100644 index 0000000000..7663f76e78 --- /dev/null +++ b/packages/contracts-rfq/deployments/optimism/TokenZapV1.json @@ -0,0 +1,203 @@ +{ + "address": "0x6327797F149a75D506aFda46D5fCE6E74fC409D5", + "constructorArgs": "0x", + "receipt": { + "hash": "0xc306e272b5daa98006c1d9009246fac697c258ed8fb6012ab19f5ef5376899b9", + "blockNumber": 129029951 + }, + "abi": [ + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "NATIVE_GAS_TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decodeZapData", + "inputs": [ + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "payload", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "encodeZapData", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "payload", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "amountPosition", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "finalToken", + "type": "address", + "internalType": "address" + }, + { + "name": "forwardTo", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "zap", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "zapData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "payable" + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TokenZapV1__FinalTokenBalanceZero", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__PayloadLengthAboveMax", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__TargetZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "TokenZapV1__TokenZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__InvalidEncoding", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__TargetZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZapDataV1__UnsupportedVersion", + "inputs": [ + { + "name": "version", + "type": "uint16", + "internalType": "uint16" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/contracts-rfq/foundry.toml b/packages/contracts-rfq/foundry.toml index 48f7927e13..e44a7f6a0a 100644 --- a/packages/contracts-rfq/foundry.toml +++ b/packages/contracts-rfq/foundry.toml @@ -7,6 +7,7 @@ src = 'contracts' out = 'out' libs = ["lib", "node_modules"] ffi = true +gas_limit = 9223372036854775807 fs_permissions = [ { access = "read", path = "./" }, { access = "read-write", path = "./.deployments" } diff --git a/packages/contracts-rfq/package.json b/packages/contracts-rfq/package.json index 17fe284464..21f1731b31 100644 --- a/packages/contracts-rfq/package.json +++ b/packages/contracts-rfq/package.json @@ -1,7 +1,7 @@ { "name": "@synapsecns/contracts-rfq", "license": "MIT", - "version": "0.14.7", + "version": "0.15.1", "description": "FastBridge contracts.", "private": true, "files": [ diff --git a/packages/contracts-rfq/script/DeploySIR.s.sol b/packages/contracts-rfq/script/DeploySIR.s.sol new file mode 100644 index 0000000000..f15352037c --- /dev/null +++ b/packages/contracts-rfq/script/DeploySIR.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {SynapseScript} from "@synapsecns/solidity-devops/src/SynapseScript.sol"; + +// solhint-disable no-empty-blocks +contract DeploySIR is SynapseScript { + string public constant LATEST_SIR = "SynapseIntentRouter"; + string public constant LATEST_SIP = "SynapseIntentPreviewer"; + string public constant LATEST_ZAP = "TokenZapV1"; + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testDeploySIR() external {} + + function run() external broadcastWithHooks { + // TODO: create2 salts + deployAndSave({contractName: LATEST_SIR, constructorArgs: "", deployCodeFunc: cbDeployCreate2}); + deployAndSave({contractName: LATEST_SIP, constructorArgs: "", deployCodeFunc: cbDeployCreate2}); + deployAndSave({contractName: LATEST_ZAP, constructorArgs: "", deployCodeFunc: cbDeployCreate2}); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol index ab9ea59341..51610b1029 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Management.t.sol @@ -2,22 +2,32 @@ pragma solidity ^0.8.20; import {IAdmin} from "../contracts/interfaces/IAdmin.sol"; -import {IAdminV2Errors} from "../contracts/interfaces/IAdminV2Errors.sol"; import {FastBridgeV2, FastBridgeV2Test} from "./FastBridgeV2.t.sol"; // solhint-disable func-name-mixedcase, ordering -contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { +contract FastBridgeV2ManagementTest is FastBridgeV2Test { uint256 public constant FEE_RATE_MAX = 1e4; // 1% bytes32 public constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); uint256 public constant MIN_CANCEL_DELAY = 1 hours; uint256 public constant DEFAULT_CANCEL_DELAY = 1 days; + uint256 public constant MIN_DISPUTE_PENALTY_TIME = 1 minutes; + uint256 public constant DEFAULT_DISPUTE_PENALTY_TIME = 30 minutes; + address public admin = makeAddr("Admin"); address public governorA = makeAddr("Governor A"); + address public proverA = makeAddr("Prover A"); + address public proverB = makeAddr("Prover B"); + + event ProverAdded(address prover); + event ProverRemoved(address prover); + event CancelDelayUpdated(uint256 oldCancelDelay, uint256 newCancelDelay); + event DeployBlockSet(uint256 blockNumber); + event DisputePenaltyTimeUpdated(uint256 oldDisputePenaltyTime, uint256 newDisputePenaltyTime); event FeeRateUpdated(uint256 oldFeeRate, uint256 newFeeRate); event FeesSwept(address token, address recipient, uint256 amount); @@ -36,6 +46,16 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { cheatCollectedProtocolFees(ETH_ADDRESS, 200); } + function addProver(address caller, address prover) public { + vm.prank(caller); + fastBridge.addProver(prover); + } + + function removeProver(address caller, address prover) public { + vm.prank(caller); + fastBridge.removeProver(prover); + } + function setGovernor(address caller, address newGovernor) public { vm.prank(caller); fastBridge.grantRole(GOVERNOR_ROLE, newGovernor); @@ -46,6 +66,16 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { fastBridge.setCancelDelay(newCancelDelay); } + function setDeployBlock(address caller, uint256 blockNumber) public { + vm.prank(caller); + fastBridge.setDeployBlock(blockNumber); + } + + function setDisputePenaltyTime(address caller, uint256 newDisputePenaltyTime) public { + vm.prank(caller); + fastBridge.setDisputePenaltyTime(newDisputePenaltyTime); + } + function setProtocolFeeRate(address caller, uint256 newFeeRate) public { vm.prank(caller); fastBridge.setProtocolFeeRate(newFeeRate); @@ -68,8 +98,158 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { setGovernor(caller, governorA); } - function test_defaultCancelDelay() public view { + function test_defaultValues() public view { assertEq(fastBridge.cancelDelay(), DEFAULT_CANCEL_DELAY); + assertEq(fastBridge.deployBlock(), block.number); + assertEq(fastBridge.disputePenaltyTime(), DEFAULT_DISPUTE_PENALTY_TIME); + assertEq(fastBridge.protocolFeeRate(), 0); + } + + // ════════════════════════════════════════════════ ADD PROVER ═════════════════════════════════════════════════════ + + function checkProverInfo(address prover, uint16 proverID, uint256 activeFromTimestamp) public view { + (uint16 id, uint256 ts) = fastBridge.getProverInfo(prover); + assertEq(id, proverID); + assertEq(ts, activeFromTimestamp); + address p; + (p, ts) = fastBridge.getProverInfoByID(proverID); + if (proverID != 0) { + assertEq(p, prover); + assertEq(ts, activeFromTimestamp); + } else { + assertEq(p, address(0)); + assertEq(ts, 0); + } + } + + function test_addProver() public { + uint256 proverAtime = block.timestamp; + vm.expectEmit(address(fastBridge)); + emit ProverAdded(proverA); + addProver(admin, proverA); + assertEq(fastBridge.getActiveProverID(proverA), 1); + assertEq(fastBridge.getActiveProverID(proverB), 0); + address[] memory provers = fastBridge.getProvers(); + assertEq(provers.length, 1); + assertEq(provers[0], proverA); + checkProverInfo(proverA, 1, proverAtime); + checkProverInfo(proverB, 0, 0); + } + + function test_addProver_twice() public { + uint256 proverAtime = block.timestamp; + test_addProver(); + skip(1 hours); + uint256 proverBtime = block.timestamp; + vm.expectEmit(address(fastBridge)); + emit ProverAdded(proverB); + addProver(admin, proverB); + assertEq(fastBridge.getActiveProverID(proverA), 1); + assertEq(fastBridge.getActiveProverID(proverB), 2); + address[] memory provers = fastBridge.getProvers(); + assertEq(provers.length, 2); + assertEq(provers[0], proverA); + assertEq(provers[1], proverB); + checkProverInfo(proverA, 1, proverAtime); + checkProverInfo(proverB, 2, proverBtime); + } + + function test_addProver_twice_afterRemoval() public { + test_removeProver_twice(); + // Add B back + skip(1 hours); + uint256 proverBtime = block.timestamp; + vm.expectEmit(address(fastBridge)); + emit ProverAdded(proverB); + addProver(admin, proverB); + assertEq(fastBridge.getActiveProverID(proverA), 0); + assertEq(fastBridge.getActiveProverID(proverB), 2); + address[] memory provers = fastBridge.getProvers(); + assertEq(provers.length, 1); + assertEq(provers[0], proverB); + checkProverInfo(proverA, 1, 0); + checkProverInfo(proverB, 2, proverBtime); + // Add A back + skip(1 hours); + uint256 proverAtime = block.timestamp; + vm.expectEmit(address(fastBridge)); + emit ProverAdded(proverA); + addProver(admin, proverA); + assertEq(fastBridge.getActiveProverID(proverA), 1); + assertEq(fastBridge.getActiveProverID(proverB), 2); + provers = fastBridge.getProvers(); + assertEq(provers.length, 2); + assertEq(provers[0], proverA); + assertEq(provers[1], proverB); + checkProverInfo(proverA, 1, proverAtime); + checkProverInfo(proverB, 2, proverBtime); + } + + function test_addProver_revertNotAdmin(address caller) public { + vm.assume(caller != admin); + expectUnauthorized(caller, fastBridge.DEFAULT_ADMIN_ROLE()); + addProver(caller, proverA); + } + + function test_addProver_revertAlreadyActive() public { + test_addProver(); + vm.expectRevert(ProverAlreadyActive.selector); + addProver(admin, proverA); + } + + function test_addProver_revertTooManyProvers() public { + for (uint256 i = 0; i < type(uint16).max; i++) { + addProver(admin, address(uint160(i))); + } + vm.expectRevert(ProverCapacityExceeded.selector); + addProver(admin, proverA); + } + + // ═══════════════════════════════════════════════ REMOVE PROVER ═══════════════════════════════════════════════════ + + function test_removeProver() public { + test_addProver_twice(); + uint256 proverBtime = block.timestamp; + vm.expectEmit(address(fastBridge)); + emit ProverRemoved(proverA); + removeProver(admin, proverA); + assertEq(fastBridge.getActiveProverID(proverA), 0); + assertEq(fastBridge.getActiveProverID(proverB), 2); + address[] memory provers = fastBridge.getProvers(); + assertEq(provers.length, 1); + assertEq(provers[0], proverB); + checkProverInfo(proverA, 1, 0); + checkProverInfo(proverB, 2, proverBtime); + } + + function test_removeProver_twice() public { + test_removeProver(); + vm.expectEmit(address(fastBridge)); + emit ProverRemoved(proverB); + removeProver(admin, proverB); + assertEq(fastBridge.getActiveProverID(proverA), 0); + assertEq(fastBridge.getActiveProverID(proverB), 0); + address[] memory provers = fastBridge.getProvers(); + assertEq(provers.length, 0); + checkProverInfo(proverA, 1, 0); + checkProverInfo(proverB, 2, 0); + } + + function test_removeProver_revertNotAdmin(address caller) public { + vm.assume(caller != admin); + expectUnauthorized(caller, fastBridge.DEFAULT_ADMIN_ROLE()); + removeProver(caller, proverA); + } + + function test_removeProver_revertNeverBeenActive() public { + vm.expectRevert(ProverNotActive.selector); + removeProver(admin, proverA); + } + + function test_removeProver_revertNotActive() public { + test_removeProver(); + vm.expectRevert(ProverNotActive.selector); + removeProver(admin, proverA); } // ═════════════════════════════════════════════ SET CANCEL DELAY ══════════════════════════════════════════════════ @@ -90,7 +270,7 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { } function test_setCancelDelay_revertBelowMin() public { - vm.expectRevert(IAdminV2Errors.CancelDelayBelowMin.selector); + vm.expectRevert(CancelDelayBelowMin.selector); setCancelDelay(governor, MIN_CANCEL_DELAY - 1); } @@ -100,6 +280,49 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { setCancelDelay(caller, 4 days); } + // ═════════════════════════════════════════════ SET DEPLOY BLOCK ══════════════════════════════════════════════════ + + function test_setDeployBlock() public { + vm.expectEmit(address(fastBridge)); + emit DeployBlockSet(123_456); + setDeployBlock(admin, 123_456); + assertEq(fastBridge.deployBlock(), 123_456); + } + + function test_setDeployBlock_revertNotAdmin(address caller) public { + vm.assume(caller != admin); + expectUnauthorized(caller, fastBridge.DEFAULT_ADMIN_ROLE()); + setDeployBlock(caller, 123_456); + } + + // ═════════════════════════════════════════ SET DISPUTE PENALTY TIME ══════════════════════════════════════════════ + + function test_setDisputePenaltyTime() public { + vm.expectEmit(address(fastBridge)); + emit DisputePenaltyTimeUpdated(DEFAULT_DISPUTE_PENALTY_TIME, 1 days); + setDisputePenaltyTime(governor, 1 days); + assertEq(fastBridge.disputePenaltyTime(), 1 days); + } + + function test_setDisputePenaltyTime_twice() public { + test_setDisputePenaltyTime(); + vm.expectEmit(address(fastBridge)); + emit DisputePenaltyTimeUpdated(1 days, 2 days); + setDisputePenaltyTime(governor, 2 days); + assertEq(fastBridge.disputePenaltyTime(), 2 days); + } + + function test_setDisputePenaltyTime_revertBelowMin() public { + vm.expectRevert(DisputePenaltyTimeBelowMin.selector); + setDisputePenaltyTime(governor, MIN_DISPUTE_PENALTY_TIME - 1); + } + + function test_setDisputePenaltyTime_revertNotGovernor(address caller) public { + vm.assume(caller != governor); + expectUnauthorized(caller, fastBridge.GOVERNOR_ROLE()); + setDisputePenaltyTime(caller, 1 days); + } + // ═══════════════════════════════════════════ SET PROTOCOL FEE RATE ═══════════════════════════════════════════════ function test_setProtocolFeeRate() public { @@ -118,7 +341,7 @@ contract FastBridgeV2ManagementTest is FastBridgeV2Test, IAdminV2Errors { } function test_setProtocolFeeRate_revert_tooHigh() public { - vm.expectRevert(IAdminV2Errors.FeeRateAboveMax.selector); + vm.expectRevert(FeeRateAboveMax.selector); setProtocolFeeRate(governor, FEE_RATE_MAX + 1); } diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.Base.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.Base.t.sol index 54853731ef..969fbc3220 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Src.Base.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.Base.t.sol @@ -9,8 +9,9 @@ import {FastBridgeV2, FastBridgeV2Test, IFastBridge, IFastBridgeV2} from "./Fast abstract contract FastBridgeV2SrcBaseTest is FastBridgeV2Test { uint256 public constant MIN_DEADLINE = 30 minutes; uint256 public constant CLAIM_DELAY = 30 minutes; - // Use a value different from the default to ensure it's being set correctly. + // Use values different from the default to ensure it's being set correctly. uint256 public constant PERMISSIONLESS_CANCEL_DELAY = 13.37 hours; + uint256 public constant DISPUTE_PENALTY_TIME = 4.2 minutes; uint256 public constant LEFTOVER_BALANCE = 10 ether; uint256 public constant INITIAL_PROTOCOL_FEES_TOKEN = 456_789; @@ -26,13 +27,15 @@ abstract contract FastBridgeV2SrcBaseTest is FastBridgeV2Test { } function configureFastBridge() public virtual override { - fastBridge.grantRole(fastBridge.PROVER_ROLE(), relayerA); - fastBridge.grantRole(fastBridge.PROVER_ROLE(), relayerB); + fastBridge.addProver(relayerA); + fastBridge.addProver(relayerB); + fastBridge.grantRole(fastBridge.GUARD_ROLE(), guard); fastBridge.grantRole(fastBridge.CANCELER_ROLE(), canceler); fastBridge.grantRole(fastBridge.GOVERNOR_ROLE(), address(this)); fastBridge.setCancelDelay(PERMISSIONLESS_CANCEL_DELAY); + fastBridge.setDisputePenaltyTime(DISPUTE_PENALTY_TIME); } function mintTokens() public virtual override { diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.RefundV1.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.RefundV1.t.sol index 44443a3920..4ea6518cda 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Src.RefundV1.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.RefundV1.t.sol @@ -3,8 +3,11 @@ pragma solidity ^0.8.20; import {BridgeTransactionV2Lib, FastBridgeV2SrcTest, IFastBridgeV2} from "./FastBridgeV2.Src.t.sol"; -// solhint-disable func-name-mixedcase, ordering +// solhint-disable func-name-mixedcase, no-empty-blocks, ordering contract FastBridgeV2SrcRefundV1Test is FastBridgeV2SrcTest { + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testFastBridgeV2SrcRefundV1Test() external {} + function cancel(address caller, IFastBridgeV2.BridgeTransactionV2 memory bridgeTx) public virtual override { vm.prank({msgSender: caller, txOrigin: caller}); fastBridge.refund(BridgeTransactionV2Lib.encodeV2(bridgeTx)); diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol index 5b3db7e3a9..14f7cb184c 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.t.sol @@ -32,6 +32,8 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { event BridgeQuoteDetails(bytes32 indexed transactionId, bytes quoteId); + event DisputePenaltyTimeApplied(address prover, uint256 inactiveUntilTimestamp); + address public claimTo = makeAddr("Claim To"); function expectBridgeRequested(IFastBridgeV2.BridgeTransactionV2 memory bridgeTx, bytes32 txId) public { @@ -92,14 +94,26 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { }); } + function expectDisputePenaltyTimeApplied(address prover) public { + uint256 inactiveUntilTimestamp = block.timestamp + DISPUTE_PENALTY_TIME; + vm.expectEmit(address(fastBridge)); + emit DisputePenaltyTimeApplied(prover, inactiveUntilTimestamp); + } + // ══════════════════════════════════════════════════ BRIDGE ═══════════════════════════════════════════════════════ function checkStatusAndProofAfterBridge(bytes32 txId) public view { assertEq(fastBridge.bridgeStatuses(txId), IFastBridgeV2.BridgeStatus.REQUESTED); - (IFastBridgeV2.BridgeStatus status, uint32 destChainId, uint256 proofBlockTimestamp, address proofRelayer) = - fastBridge.bridgeTxDetails(txId); + ( + IFastBridgeV2.BridgeStatus status, + uint32 destChainId, + uint16 proverID, + uint256 proofBlockTimestamp, + address proofRelayer + ) = fastBridge.bridgeTxDetails(txId); assertEq(status, IFastBridgeV2.BridgeStatus.REQUESTED); assertEq(destChainId, DST_CHAIN_ID); + assertEq(proverID, 0); assertEq(proofBlockTimestamp, 0); assertEq(proofRelayer, address(0)); (proofBlockTimestamp, proofRelayer) = fastBridge.bridgeProofs(txId); @@ -282,12 +296,18 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { // ═══════════════════════════════════════════════════ PROVE ═══════════════════════════════════════════════════════ - function checkStatusAndProofAfterProve(bytes32 txId, address relayer) public view { + function checkStatusAndProofAfterProve(bytes32 txId, uint16 expectedProverID, address relayer) public view { assertEq(fastBridge.bridgeStatuses(txId), IFastBridgeV2.BridgeStatus.RELAYER_PROVED); - (IFastBridgeV2.BridgeStatus status, uint32 destChainId, uint256 proofBlockTimestamp, address proofRelayer) = - fastBridge.bridgeTxDetails(txId); + ( + IFastBridgeV2.BridgeStatus status, + uint32 destChainId, + uint16 proverID, + uint256 proofBlockTimestamp, + address proofRelayer + ) = fastBridge.bridgeTxDetails(txId); assertEq(status, IFastBridgeV2.BridgeStatus.RELAYER_PROVED); assertEq(destChainId, DST_CHAIN_ID); + assertEq(proverID, expectedProverID); assertEq(proofBlockTimestamp, block.timestamp); assertEq(proofRelayer, relayer); (proofBlockTimestamp, proofRelayer) = fastBridge.bridgeProofs(txId); @@ -300,7 +320,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: 0, params: tokenParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 1, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -312,7 +332,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 1, relayerA); assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); } @@ -346,13 +366,25 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); } - function test_prove_revert_callerNotRelayer(address caller) public { + function test_prove_revert_callerNotProver(address caller) public { vm.assume(caller != relayerA && caller != relayerB); bridge({caller: userA, msgValue: 0, params: tokenParams}); - expectUnauthorized(caller, fastBridge.PROVER_ROLE()); + vm.expectRevert(ProverNotActive.selector); prove({caller: caller, bridgeTx: tokenTx, destTxHash: hex"01"}); } + function test_prove_revert_disputePenaltyTime() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + dispute({caller: guard, txId: txId}); + vm.expectRevert(ProverNotActive.selector); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"02"}); + skip(DISPUTE_PENALTY_TIME - 1); + vm.expectRevert(ProverNotActive.selector); + prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"02"}); + } + // ════════════════════════════════════════ PROVE OTHER RELAYER ════════════════════════════════════════════ function test_proveOther_token() public { @@ -360,7 +392,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: 0, params: tokenParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerB, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 2, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -372,7 +404,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerB, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 2, relayerA); assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); } @@ -383,7 +415,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: 0, params: tokenParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerA, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 1, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -395,7 +427,19 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: 0, params: tokenParams}); expectBridgeProofProvided({txId: txId, relayer: address(0x1234), destTxHash: hex"01"}); prove({caller: relayerA, transactionId: txId, destTxHash: hex"01", relayer: address(0x1234)}); - checkStatusAndProofAfterProve(txId, address(0x1234)); + checkStatusAndProofAfterProve(txId, 1, address(0x1234)); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + } + + function test_proveOther_afterDispute() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"01"}); + dispute({caller: guard, txId: txId}); + expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"02"}); + prove({caller: relayerB, relayer: relayerA, transactionId: txId, destTxHash: hex"02"}); + checkStatusAndProofAfterProve(txId, 2, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -406,17 +450,19 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerB, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 2, relayerA); expectBridgeProofDisputed(txId, relayerA); dispute(guard, txId); + skip(DISPUTE_PENALTY_TIME); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"02"}); prove({caller: relayerB, transactionId: txId, destTxHash: hex"02", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 2, relayerA); expectBridgeProofDisputed(txId, relayerA); dispute(guard, txId); + skip(DISPUTE_PENALTY_TIME); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"03"}); prove({caller: relayerB, transactionId: txId, destTxHash: hex"03", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 2, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -428,7 +474,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(10 days); expectBridgeProofProvided({txId: txId, relayer: relayerA, destTxHash: hex"01"}); prove({caller: relayerA, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); - checkStatusAndProofAfterProve(txId, relayerA); + checkStatusAndProofAfterProve(txId, 1, relayerA); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); } @@ -464,18 +510,44 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bytes32 txId = getTxId(tokenTx); vm.assume(caller != relayerA && caller != relayerB); bridge({caller: userA, msgValue: 0, params: tokenParams}); - expectUnauthorized(caller, fastBridge.PROVER_ROLE()); + vm.expectRevert(ProverNotActive.selector); prove({caller: caller, transactionId: txId, destTxHash: hex"01", relayer: relayerA}); } + function test_proveOther_revert_disputePenaltyTime() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"01"}); + dispute({caller: guard, txId: txId}); + vm.expectRevert(ProverNotActive.selector); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"02"}); + skip(DISPUTE_PENALTY_TIME - 1); + vm.expectRevert(ProverNotActive.selector); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"02"}); + } + // ═══════════════════════════════════════════════════ CLAIM ═══════════════════════════════════════════════════════ - function checkStatusAndProofAfterClaim(bytes32 txId, address relayer, uint256 expectedProofTS) public view { + function checkStatusAndProofAfterClaim( + bytes32 txId, + uint16 expectedProverID, + address relayer, + uint256 expectedProofTS + ) + public + view + { assertEq(fastBridge.bridgeStatuses(txId), IFastBridgeV2.BridgeStatus.RELAYER_CLAIMED); - (IFastBridgeV2.BridgeStatus status, uint32 destChainId, uint256 proofBlockTimestamp, address proofRelayer) = - fastBridge.bridgeTxDetails(txId); + ( + IFastBridgeV2.BridgeStatus status, + uint32 destChainId, + uint16 proverID, + uint256 proofBlockTimestamp, + address proofRelayer + ) = fastBridge.bridgeTxDetails(txId); assertEq(status, IFastBridgeV2.BridgeStatus.RELAYER_CLAIMED); assertEq(destChainId, DST_CHAIN_ID); + assertEq(proverID, expectedProverID); assertEq(proofBlockTimestamp, expectedProofTS); assertEq(proofRelayer, relayer); (proofBlockTimestamp, proofRelayer) = fastBridge.bridgeProofs(txId); @@ -499,7 +571,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { assertTrue(fastBridge.canClaim(txId, relayerA)); expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkTokenBalancesAfterClaim(relayerA); } @@ -512,7 +584,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: caller, bridgeTx: tokenTx}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkTokenBalancesAfterClaim(relayerA); } @@ -525,7 +597,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: caller, bridgeTx: tokenTx, to: address(0)}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkTokenBalancesAfterClaim(relayerA); } @@ -537,7 +609,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: claimTo}); claim({caller: relayerA, bridgeTx: tokenTx, to: claimTo}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); assertEq(srcToken.balanceOf(relayerA), 0); checkTokenBalancesAfterClaim(claimTo); } @@ -550,7 +622,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 30 days); expectBridgeDepositClaimed({bridgeTx: tokenTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: relayerA, bridgeTx: tokenTx, to: relayerA}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkTokenBalancesAfterClaim(relayerA); } @@ -571,7 +643,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { assertTrue(fastBridge.canClaim(txId, relayerA)); expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: relayerA, bridgeTx: ethTx, to: relayerA}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkEthBalancesAfterClaim(relayerA); } @@ -585,7 +657,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: caller, bridgeTx: ethTx}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkEthBalancesAfterClaim(relayerA); } @@ -599,7 +671,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: caller, bridgeTx: ethTx, to: address(0)}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkEthBalancesAfterClaim(relayerA); } @@ -612,7 +684,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 1); expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: claimTo}); claim({caller: relayerA, bridgeTx: ethTx, to: claimTo}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkEthBalancesAfterClaim(claimTo); } @@ -625,7 +697,7 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { skip(CLAIM_DELAY + 30 days); expectBridgeDepositClaimed({bridgeTx: ethTx, txId: txId, relayer: relayerA, to: relayerA}); claim({caller: relayerA, bridgeTx: ethTx, to: relayerA}); - checkStatusAndProofAfterClaim(txId, relayerA, expectedProofTS); + checkStatusAndProofAfterClaim(txId, 1, relayerA, expectedProofTS); checkEthBalancesAfterClaim(relayerA); } @@ -704,15 +776,59 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { checkStatusAndProofAfterBridge(txId); } + function checkProver(address prover, uint16 expectedProverID, uint256 expectedActiveFromTimestamp) public view { + (uint16 proverID, uint256 activeFromTimestamp) = fastBridge.getProverInfo(prover); + assertEq(proverID, expectedProverID); + assertEq(activeFromTimestamp, expectedActiveFromTimestamp); + address p; + (p, activeFromTimestamp) = fastBridge.getProverInfoByID(expectedProverID); + assertEq(p, prover); + assertEq(activeFromTimestamp, expectedActiveFromTimestamp); + } + function test_dispute_token() public { bytes32 txId = getTxId(tokenTx); bridge({caller: userA, msgValue: 0, params: tokenParams}); prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); + expectDisputePenaltyTimeApplied({prover: relayerA}); expectBridgeProofDisputed({txId: txId, relayer: relayerA}); dispute({caller: guard, txId: txId}); checkStatusAndProofAfterDispute(txId); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerA), 0); + checkProver(relayerA, 1, block.timestamp + DISPUTE_PENALTY_TIME); + } + + function test_dispute_token_provedOther() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"01"}); + expectDisputePenaltyTimeApplied({prover: relayerA}); + expectBridgeProofDisputed({txId: txId, relayer: relayerB}); + dispute({caller: guard, txId: txId}); + checkStatusAndProofAfterDispute(txId); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerA), 0); + checkProver(relayerA, 1, block.timestamp + DISPUTE_PENALTY_TIME); + } + + function test_dispute_token_proverAlreadyRemoved() public { + bytes32 txId = getTxId(tokenTx); + bridge({caller: userA, msgValue: 0, params: tokenParams}); + prove({caller: relayerA, relayer: relayerB, transactionId: txId, destTxHash: hex"01"}); + fastBridge.removeProver(relayerA); + expectBridgeProofDisputed({txId: txId, relayer: relayerB}); + dispute({caller: guard, txId: txId}); + checkStatusAndProofAfterDispute(txId); + assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); + assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerA), 0); + checkProver(relayerA, 1, 0); } function test_dispute_token_justBeforeDeadline() public { @@ -720,36 +836,79 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { bridge({caller: userA, msgValue: 0, params: tokenParams}); prove({caller: relayerA, bridgeTx: tokenTx, destTxHash: hex"01"}); skip(CLAIM_DELAY); + expectDisputePenaltyTimeApplied({prover: relayerA}); expectBridgeProofDisputed({txId: txId, relayer: relayerA}); dispute({caller: guard, txId: txId}); checkStatusAndProofAfterDispute(txId); assertEq(fastBridge.protocolFees(address(srcToken)), INITIAL_PROTOCOL_FEES_TOKEN); assertEq(srcToken.balanceOf(address(fastBridge)), INITIAL_PROTOCOL_FEES_TOKEN + tokenParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerA), 0); + checkProver(relayerA, 1, block.timestamp + DISPUTE_PENALTY_TIME); } function test_dispute_eth() public { bridge({caller: userA, msgValue: 0, params: tokenParams}); bytes32 txId = getTxId(ethTx); bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); - prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + prove({caller: relayerB, bridgeTx: ethTx, destTxHash: hex"01"}); + expectDisputePenaltyTimeApplied({prover: relayerB}); + expectBridgeProofDisputed({txId: txId, relayer: relayerB}); + dispute({caller: guard, txId: txId}); + checkStatusAndProofAfterDispute(txId); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerB), 0); + checkProver(relayerB, 2, block.timestamp + DISPUTE_PENALTY_TIME); + } + + function test_dispute_eth_provedOther() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerB, relayer: relayerA, transactionId: txId, destTxHash: hex"01"}); + expectDisputePenaltyTimeApplied({prover: relayerB}); expectBridgeProofDisputed({txId: txId, relayer: relayerA}); dispute({caller: guard, txId: txId}); checkStatusAndProofAfterDispute(txId); assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerB), 0); + checkProver(relayerB, 2, block.timestamp + DISPUTE_PENALTY_TIME); + } + + function test_dispute_eth_proverAlreadyRemoved() public { + bridge({caller: userA, msgValue: 0, params: tokenParams}); + bytes32 txId = getTxId(ethTx); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); + prove({caller: relayerB, relayer: relayerA, transactionId: txId, destTxHash: hex"01"}); + fastBridge.removeProver(relayerB); + dispute({caller: guard, txId: txId}); + checkStatusAndProofAfterDispute(txId); + assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); + assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerB), 0); + checkProver(relayerB, 2, 0); } function test_dispute_eth_justBeforeDeadline() public { bridge({caller: userA, msgValue: 0, params: tokenParams}); bytes32 txId = getTxId(ethTx); bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams}); - prove({caller: relayerA, bridgeTx: ethTx, destTxHash: hex"01"}); + prove({caller: relayerB, bridgeTx: ethTx, destTxHash: hex"01"}); skip(CLAIM_DELAY); - expectBridgeProofDisputed({txId: txId, relayer: relayerA}); + expectDisputePenaltyTimeApplied({prover: relayerB}); + expectBridgeProofDisputed({txId: txId, relayer: relayerB}); dispute({caller: guard, txId: txId}); checkStatusAndProofAfterDispute(txId); assertEq(fastBridge.protocolFees(ETH_ADDRESS), INITIAL_PROTOCOL_FEES_ETH); assertEq(address(fastBridge).balance, INITIAL_PROTOCOL_FEES_ETH + ethParams.originAmount); + // Check disputed prover + assertEq(fastBridge.getActiveProverID(relayerB), 0); + checkProver(relayerB, 2, block.timestamp + DISPUTE_PENALTY_TIME); } function test_dispute_revert_afterDeadline() public { @@ -806,10 +965,16 @@ contract FastBridgeV2SrcTest is FastBridgeV2SrcBaseTest { function checkStatusAndProofAfterCancel(bytes32 txId) public view { assertEq(fastBridge.bridgeStatuses(txId), IFastBridgeV2.BridgeStatus.REFUNDED); - (IFastBridgeV2.BridgeStatus status, uint32 destChainId, uint256 proofBlockTimestamp, address proofRelayer) = - fastBridge.bridgeTxDetails(txId); + ( + IFastBridgeV2.BridgeStatus status, + uint32 destChainId, + uint16 proverID, + uint256 proofBlockTimestamp, + address proofRelayer + ) = fastBridge.bridgeTxDetails(txId); assertEq(status, IFastBridgeV2.BridgeStatus.REFUNDED); assertEq(destChainId, DST_CHAIN_ID); + assertEq(proverID, 0); assertEq(proofBlockTimestamp, 0); assertEq(proofRelayer, address(0)); (proofBlockTimestamp, proofRelayer) = fastBridge.bridgeProofs(txId); diff --git a/packages/contracts-rfq/test/FastBridgeV2.t.sol b/packages/contracts-rfq/test/FastBridgeV2.t.sol index 04bfeb7a36..d4fab0a1ae 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.t.sol @@ -9,6 +9,7 @@ import {IFastBridge} from "../contracts/interfaces/IFastBridge.sol"; import {IFastBridgeV2} from "../contracts/interfaces/IFastBridgeV2.sol"; import {FastBridgeV2} from "../contracts/FastBridgeV2.sol"; +import {IAdminV2Errors} from "../contracts/interfaces/IAdminV2Errors.sol"; import {IFastBridgeV2Errors} from "../contracts/interfaces/IFastBridgeV2Errors.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; @@ -18,7 +19,7 @@ import {Test} from "forge-std/Test.sol"; import {StdStorage, stdStorage} from "forge-std/Test.sol"; // solhint-disable no-empty-blocks, max-states-count, ordering -abstract contract FastBridgeV2Test is Test, IFastBridgeV2Errors { +abstract contract FastBridgeV2Test is Test, IAdminV2Errors, IFastBridgeV2Errors { using stdStorage for StdStorage; uint32 public constant SRC_CHAIN_ID = 1337; diff --git a/packages/contracts-rfq/test/harnesses/BridgeTransactionV2Harness.sol b/packages/contracts-rfq/test/harnesses/BridgeTransactionV2Harness.sol index 947f8f2f16..08247407e2 100644 --- a/packages/contracts-rfq/test/harnesses/BridgeTransactionV2Harness.sol +++ b/packages/contracts-rfq/test/harnesses/BridgeTransactionV2Harness.sol @@ -3,7 +3,11 @@ pragma solidity 0.8.24; import {BridgeTransactionV2Lib, IFastBridgeV2} from "../../contracts/libs/BridgeTransactionV2.sol"; +// solhint-disable no-empty-blocks contract BridgeTransactionV2Harness { + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testBridgeTransactionV2Harness() external {} + function encodeV2(IFastBridgeV2.BridgeTransactionV2 memory bridgeTx) public pure returns (bytes memory) { return BridgeTransactionV2Lib.encodeV2(bridgeTx); } diff --git a/packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol b/packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol index b1b5cef18e..af342f9e65 100644 --- a/packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol +++ b/packages/contracts-rfq/test/harnesses/ZapDataV1Harness.sol @@ -3,13 +3,22 @@ pragma solidity 0.8.24; import {ZapDataV1} from "../../contracts/libs/ZapDataV1.sol"; +// solhint-disable no-empty-blocks contract ZapDataV1Harness { + uint16 public constant VERSION = ZapDataV1.VERSION; + uint16 public constant AMOUNT_NOT_PRESENT = ZapDataV1.AMOUNT_NOT_PRESENT; + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testZapDataV1Harness() external {} + function validateV1(bytes calldata encodedZapData) public pure { ZapDataV1.validateV1(encodedZapData); } function encodeV1( uint16 amountPosition_, + address finalToken_, + address forwardTo_, address target_, bytes memory payload_ ) @@ -17,13 +26,21 @@ contract ZapDataV1Harness { pure returns (bytes memory encodedZapData) { - return ZapDataV1.encodeV1(amountPosition_, target_, payload_); + return ZapDataV1.encodeV1(amountPosition_, finalToken_, forwardTo_, target_, payload_); } function version(bytes calldata encodedZapData) public pure returns (uint16) { return ZapDataV1.version(encodedZapData); } + function finalToken(bytes calldata encodedZapData) public pure returns (address) { + return ZapDataV1.finalToken(encodedZapData); + } + + function forwardTo(bytes calldata encodedZapData) public pure returns (address) { + return ZapDataV1.forwardTo(encodedZapData); + } + function target(bytes calldata encodedZapData) public pure returns (address) { return ZapDataV1.target(encodedZapData); } diff --git a/packages/contracts-rfq/test/integration/FastBridge.MulticallTarget.t.sol b/packages/contracts-rfq/test/integration/FastBridge.MulticallTarget.t.sol index 182b25f050..08b432466e 100644 --- a/packages/contracts-rfq/test/integration/FastBridge.MulticallTarget.t.sol +++ b/packages/contracts-rfq/test/integration/FastBridge.MulticallTarget.t.sol @@ -5,7 +5,11 @@ import {FastBridge} from "../../contracts/FastBridge.sol"; import {IFastBridge, MulticallTargetIntegrationTest} from "./MulticallTarget.t.sol"; +// solhint-disable no-empty-blocks contract FastBridgeMulticallTargetTest is MulticallTargetIntegrationTest { + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testFastBridgeMulticallTargetTest() external {} + function deployAndConfigureFastBridge() public override returns (address) { FastBridge fastBridge = new FastBridge(address(this)); fastBridge.grantRole(fastBridge.RELAYER_ROLE(), relayer); diff --git a/packages/contracts-rfq/test/integration/FastBridgeV2.MulticallTarget.t.sol b/packages/contracts-rfq/test/integration/FastBridgeV2.MulticallTarget.t.sol index 4a23e98169..ebb0f78689 100644 --- a/packages/contracts-rfq/test/integration/FastBridgeV2.MulticallTarget.t.sol +++ b/packages/contracts-rfq/test/integration/FastBridgeV2.MulticallTarget.t.sol @@ -6,11 +6,15 @@ import {BridgeTransactionV2Lib} from "../../contracts/libs/BridgeTransactionV2.s import {IFastBridge, MulticallTargetIntegrationTest} from "./MulticallTarget.t.sol"; +// solhint-disable no-empty-blocks contract FastBridgeV2MulticallTargetTest is MulticallTargetIntegrationTest { + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testFastBridgeV2MulticallTargetTest() external {} + function deployAndConfigureFastBridge() public override returns (address) { - FastBridgeV2 fastBridge = new FastBridgeV2(address(this)); - fastBridge.grantRole(fastBridge.PROVER_ROLE(), relayer); - return address(fastBridge); + FastBridgeV2 fb = new FastBridgeV2(address(this)); + fb.addProver(relayer); + return address(fb); } function getEncodedBridgeTx(IFastBridge.BridgeTransaction memory bridgeTx) diff --git a/packages/contracts-rfq/test/integration/TokenZapV1.t.sol b/packages/contracts-rfq/test/integration/TokenZapV1.t.sol index 92d3874970..8cb8a502ac 100644 --- a/packages/contracts-rfq/test/integration/TokenZapV1.t.sol +++ b/packages/contracts-rfq/test/integration/TokenZapV1.t.sol @@ -11,7 +11,7 @@ import {VaultManyArguments} from "../mocks/VaultManyArguments.sol"; import {Test} from "forge-std/Test.sol"; -// solhint-disable ordering +// solhint-disable no-empty-blocks, ordering abstract contract TokenZapV1IntegrationTest is Test { address internal constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; @@ -43,9 +43,12 @@ abstract contract TokenZapV1IntegrationTest is Test { IFastBridgeV2.BridgeParamsV2 internal depositNativeNoAmountParams; IFastBridgeV2.BridgeParamsV2 internal depositNativeRevertParams; + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testTokenZapV1IntegrationTest() external {} + function setUp() public virtual { fastBridge = new FastBridgeV2(address(this)); - fastBridge.grantRole(fastBridge.PROVER_ROLE(), relayer); + fastBridge.addProver(relayer); srcToken = new MockERC20("SRC", 18); dstToken = new MockERC20("DST", 18); @@ -84,7 +87,9 @@ abstract contract TokenZapV1IntegrationTest is Test { bytes memory zapData = dstZap.encodeZapData({ target: address(dstVault), payload: getDepositPayload(address(dstToken)), - amountPosition: 4 + 32 * 2 + amountPosition: 4 + 32 * 2, + finalToken: address(0), + forwardTo: address(0) }); depositTokenParams.zapData = zapData; depositTokenWithZapNativeParams.zapData = zapData; @@ -93,24 +98,32 @@ abstract contract TokenZapV1IntegrationTest is Test { depositNativeParams.zapData = dstZap.encodeZapData({ target: address(dstVault), payload: getDepositPayload(NATIVE_GAS_TOKEN), - amountPosition: 4 + 32 * 2 + amountPosition: 4 + 32 * 2, + finalToken: address(0), + forwardTo: address(0) }); // Deposit no amount depositNativeNoAmountParams.zapData = dstZap.encodeZapData({ target: address(dstVault), payload: getDepositNoAmountPayload(), - amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT + amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT, + finalToken: address(0), + forwardTo: address(0) }); // Deposit revert depositTokenRevertParams.zapData = dstZap.encodeZapData({ target: address(dstVault), payload: getDepositRevertPayload(), - amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT + amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT, + finalToken: address(0), + forwardTo: address(0) }); depositNativeRevertParams.zapData = dstZap.encodeZapData({ target: address(dstVault), payload: getDepositRevertPayload(), - amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT + amountPosition: ZapDataV1.AMOUNT_NOT_PRESENT, + finalToken: address(0), + forwardTo: address(0) }); } diff --git a/packages/contracts-rfq/test/libs/ZapDataV1.t.sol b/packages/contracts-rfq/test/libs/ZapDataV1.t.sol index f592782ca8..6e02424d8e 100644 --- a/packages/contracts-rfq/test/libs/ZapDataV1.t.sol +++ b/packages/contracts-rfq/test/libs/ZapDataV1.t.sol @@ -18,6 +18,8 @@ contract ZapDataV1Test is Test { function encodeZapData( uint16 version, uint16 amountPosition, + address finalToken, + address forwardTo, address target, bytes memory payload ) @@ -25,10 +27,12 @@ contract ZapDataV1Test is Test { pure returns (bytes memory) { - return abi.encodePacked(version, amountPosition, target, payload); + return abi.encodePacked(version, amountPosition, finalToken, forwardTo, target, payload); } function test_roundtrip_withAmount( + address finalToken, + address forwardTo, address target, uint256 amount, bytes memory prefix, @@ -46,37 +50,54 @@ contract ZapDataV1Test is Test { // We expect the correct amount to be substituted in the payload at the time of Zap. bytes memory finalPayload = abi.encodePacked(prefix, amount, postfix); - bytes memory zapData = harness.encodeV1(amountPosition, target, encodedPayload); + bytes memory zapData = harness.encodeV1(amountPosition, finalToken, forwardTo, target, encodedPayload); harness.validateV1(zapData); assertEq(harness.version(zapData), 1); + assertEq(harness.finalToken(zapData), finalToken); + assertEq(harness.forwardTo(zapData), forwardTo); assertEq(harness.target(zapData), target); assertEq(harness.payload(zapData, amount), finalPayload); // Check against manually encoded ZapData. - assertEq(zapData, encodeZapData(EXPECTED_VERSION, amountPosition, target, encodedPayload)); + assertEq( + zapData, encodeZapData(EXPECTED_VERSION, amountPosition, finalToken, forwardTo, target, encodedPayload) + ); } - function test_roundtrip_noAmount(address target, uint256 amount, bytes memory payload) public view { + function test_roundtrip_noAmount( + address finalToken, + address forwardTo, + address target, + uint256 amount, + bytes memory payload + ) + public + view + { vm.assume(payload.length < type(uint16).max); vm.assume(target != address(0)); uint16 amountPosition = type(uint16).max; - bytes memory zapData = harness.encodeV1(amountPosition, target, payload); + bytes memory zapData = harness.encodeV1(amountPosition, finalToken, forwardTo, target, payload); harness.validateV1(zapData); assertEq(harness.version(zapData), 1); + assertEq(harness.finalToken(zapData), finalToken); + assertEq(harness.forwardTo(zapData), forwardTo); assertEq(harness.target(zapData), target); assertEq(harness.payload(zapData, amount), payload); // Check against manually encoded ZapData. - assertEq(zapData, encodeZapData(EXPECTED_VERSION, amountPosition, target, payload)); + assertEq(zapData, encodeZapData(EXPECTED_VERSION, amountPosition, finalToken, forwardTo, target, payload)); } function test_encodeV1_revert_targetZeroAddress() public { vm.expectRevert(ZapDataV1.ZapDataV1__TargetZeroAddress.selector); - harness.encodeV1(type(uint16).max, address(0), ""); + harness.encodeV1(type(uint16).max, address(0), address(0), address(0), ""); } function test_encodeDecodeV1_revert_invalidAmountPosition( + address finalToken, + address forwardTo, address target, uint16 amountPosition, uint256 amount, @@ -90,13 +111,16 @@ contract ZapDataV1Test is Test { uint16 incorrectMin = payload.length > 31 ? uint16(payload.length) - 31 : 0; uint16 incorrectMax = type(uint16).max - 1; amountPosition = uint16(bound(uint256(amountPosition), incorrectMin, incorrectMax)); - bytes memory invalidEncodedZapData = abi.encodePacked(uint16(1), amountPosition, target, payload); + bytes memory invalidEncodedZapData = + encodeZapData(uint16(1), amountPosition, finalToken, forwardTo, target, payload); vm.expectRevert(ZapDataV1.ZapDataV1__InvalidEncoding.selector); - harness.encodeV1(amountPosition, target, payload); + harness.encodeV1(amountPosition, finalToken, forwardTo, target, payload); // Validation should pass harness.validateV1(invalidEncodedZapData); + harness.finalToken(invalidEncodedZapData); + harness.forwardTo(invalidEncodedZapData); harness.target(invalidEncodedZapData); // But payload extraction should revert vm.expectRevert(ZapDataV1.ZapDataV1__InvalidEncoding.selector); @@ -105,6 +129,8 @@ contract ZapDataV1Test is Test { function test_validateV1_revert_unsupportedVersion_withAmount( uint16 version, + address finalToken, + address forwardTo, address target, bytes memory prefix, bytes memory postfix @@ -117,7 +143,8 @@ contract ZapDataV1Test is Test { uint16 amountPosition = uint16(prefix.length); bytes memory encodedPayload = abi.encodePacked(prefix, uint256(0), postfix); - bytes memory invalidEncodedZapData = encodeZapData(version, amountPosition, target, encodedPayload); + bytes memory invalidEncodedZapData = + encodeZapData(version, amountPosition, finalToken, forwardTo, target, encodedPayload); vm.expectRevert(abi.encodeWithSelector(ZapDataV1.ZapDataV1__UnsupportedVersion.selector, version)); harness.validateV1(invalidEncodedZapData); @@ -125,6 +152,8 @@ contract ZapDataV1Test is Test { function test_validateV1_revert_unsupportedVersion_noAmount( uint16 version, + address finalToken, + address forwardTo, address target, bytes memory payload ) @@ -134,14 +163,16 @@ contract ZapDataV1Test is Test { vm.assume(payload.length < type(uint16).max); uint16 amountPosition = type(uint16).max; - bytes memory invalidEncodedZapData = encodeZapData(version, amountPosition, target, payload); + bytes memory invalidEncodedZapData = + encodeZapData(version, amountPosition, finalToken, forwardTo, target, payload); vm.expectRevert(abi.encodeWithSelector(ZapDataV1.ZapDataV1__UnsupportedVersion.selector, version)); harness.validateV1(invalidEncodedZapData); } function test_validateV1_revert_invalidLength(bytes calldata fuzzData) public { - bytes memory minimumValidZapData = encodeZapData(EXPECTED_VERSION, type(uint16).max, address(0), ""); + bytes memory minimumValidZapData = + encodeZapData(EXPECTED_VERSION, type(uint16).max, address(0), address(0), address(0), ""); uint256 invalidLength = fuzzData.length % minimumValidZapData.length; bytes calldata invalidEncodedZapData = fuzzData[:invalidLength]; diff --git a/packages/contracts-rfq/test/mocks/DefaultPoolMock.sol b/packages/contracts-rfq/test/mocks/DefaultPoolMock.sol new file mode 100644 index 0000000000..ef48c50010 --- /dev/null +++ b/packages/contracts-rfq/test/mocks/DefaultPoolMock.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {IDefaultPool} from "../../contracts/legacy/router/interfaces/IDefaultPool.sol"; + +// solhint-disable no-empty-blocks +contract DefaultPoolMock is IDefaultPool { + uint8 private constant TOKENS = 3; + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testDefaultPoolMock() external {} + + function swap( + uint8 tokenIndexFrom, + uint8 tokenIndexTo, + uint256 dx, + uint256 minDy, + uint256 deadline + ) + external + returns (uint256 amountOut) + {} + + function calculateSwap( + uint8 tokenIndexFrom, + uint8 tokenIndexTo, + uint256 dx + ) + external + view + returns (uint256 amountOut) + {} + + function swapStorage() + external + view + returns ( + uint256 initialA, + uint256 futureA, + uint256 initialATime, + uint256 futureATime, + uint256 swapFee, + uint256 adminFee, + address lpToken + ) + {} + + function getToken(uint8 index) external pure returns (address token) { + if (index < TOKENS) { + // Will be overridden by vm.mockCall + return address(uint160(1 + index)); + } + revert("Token does not exist"); + } +} diff --git a/packages/contracts-rfq/test/mocks/MockRevertingRecipient.sol b/packages/contracts-rfq/test/mocks/MockRevertingRecipient.sol index 2068022f3c..fbf0e0ef76 100644 --- a/packages/contracts-rfq/test/mocks/MockRevertingRecipient.sol +++ b/packages/contracts-rfq/test/mocks/MockRevertingRecipient.sol @@ -1,8 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.17; +// solhint-disable no-empty-blocks contract MockRevertingRecipient { receive() external payable { revert("GM"); } + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testMockRevertingRecipient() external {} } diff --git a/packages/contracts-rfq/test/mocks/PoolMock.sol b/packages/contracts-rfq/test/mocks/PoolMock.sol new file mode 100644 index 0000000000..7ec46f9479 --- /dev/null +++ b/packages/contracts-rfq/test/mocks/PoolMock.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +// solhint-disable no-empty-blocks +/// @notice Pool mock for testing purposes. DO NOT USE IN PRODUCTION. +contract PoolMock { + using SafeERC20 for IERC20; + + address public immutable token0; + address public immutable token1; + + uint256 public ratioWei = 1e18; + + error PoolMock__TokenNotSupported(); + + constructor(address token0_, address token1_) { + token0 = token0_; + token1 = token1_; + } + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testPoolMock() external {} + + function setRatioWei(uint256 ratioWei_) external { + ratioWei = ratioWei_; + } + + function swap(uint256 amountIn, address tokenIn) external returns (uint256 amountOut) { + address tokenOut; + if (tokenIn == token0) { + tokenOut = token1; + amountOut = amountIn * ratioWei / 1e18; + } else if (tokenIn == token1) { + tokenOut = token0; + amountOut = amountIn * 1e18 / ratioWei; + } else { + revert PoolMock__TokenNotSupported(); + } + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenOut).safeTransfer(msg.sender, amountOut); + } +} diff --git a/packages/contracts-rfq/test/mocks/SwapQuoterMock.sol b/packages/contracts-rfq/test/mocks/SwapQuoterMock.sol new file mode 100644 index 0000000000..83f1b448f9 --- /dev/null +++ b/packages/contracts-rfq/test/mocks/SwapQuoterMock.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {ISwapQuoter, LimitedToken, SwapQuery} from "../../contracts/legacy/rfq/interfaces/ISwapQuoter.sol"; + +// solhint-disable no-empty-blocks +contract SwapQuoterMock is ISwapQuoter { + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testSwapQuoterMock() external {} + + function getAmountOut( + LimitedToken memory tokenIn, + address tokenOut, + uint256 amountIn + ) + external + view + returns (SwapQuery memory query) + {} +} diff --git a/packages/contracts-rfq/test/mocks/VaultMock.sol b/packages/contracts-rfq/test/mocks/VaultMock.sol index b4d1f514ec..4df738b05e 100644 --- a/packages/contracts-rfq/test/mocks/VaultMock.sol +++ b/packages/contracts-rfq/test/mocks/VaultMock.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +// solhint-disable no-empty-blocks /// @notice Vault mock for testing purposes. DO NOT USE IN PRODUCTION. abstract contract VaultMock { using SafeERC20 for IERC20; @@ -13,6 +14,9 @@ abstract contract VaultMock { error VaultMock__AmountIncorrect(); + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testVaultMock() external {} + function _deposit(address user, address token, uint256 amount) internal { if (token == NATIVE_GAS_TOKEN) { if (msg.value != amount) revert VaultMock__AmountIncorrect(); diff --git a/packages/contracts-rfq/test/mocks/WETHMock.sol b/packages/contracts-rfq/test/mocks/WETHMock.sol index 75b5c4d5dd..ae79a21e1a 100644 --- a/packages/contracts-rfq/test/mocks/WETHMock.sol +++ b/packages/contracts-rfq/test/mocks/WETHMock.sol @@ -4,9 +4,11 @@ pragma solidity ^0.8.20; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {CommonBase} from "forge-std/Base.sol"; + // solhint-disable no-empty-blocks /// @notice WETH mock for testing purposes. DO NOT USE IN PRODUCTION. -contract WETHMock is ERC20 { +contract WETHMock is ERC20, CommonBase { constructor() ERC20("Mock Wrapped Ether", "Mock WETH") {} receive() external payable { @@ -16,6 +18,12 @@ contract WETHMock is ERC20 { /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. function testWETHMock() external {} + function mint(address to, uint256 amount) external { + uint256 newBalance = address(this).balance + amount; + vm.deal(address(this), newBalance); + _mint(to, amount); + } + function withdraw(uint256 amount) external { _burn(msg.sender, amount); Address.sendValue(payable(msg.sender), amount); diff --git a/packages/contracts-rfq/test/router/SynapseIntentPreviewer.t.sol b/packages/contracts-rfq/test/router/SynapseIntentPreviewer.t.sol new file mode 100644 index 0000000000..19b431b14b --- /dev/null +++ b/packages/contracts-rfq/test/router/SynapseIntentPreviewer.t.sol @@ -0,0 +1,652 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {ISynapseIntentRouter} from "../../contracts/interfaces/ISynapseIntentRouter.sol"; +import {IDefaultExtendedPool} from "../../contracts/legacy/router/interfaces/IDefaultExtendedPool.sol"; +import {Action, DefaultParams} from "../../contracts/legacy/router/libs/Structs.sol"; +import {SynapseIntentPreviewer} from "../../contracts/router/SynapseIntentPreviewer.sol"; + +import {ZapDataV1Harness} from "../harnesses/ZapDataV1Harness.sol"; + +import {DefaultPoolMock} from "../mocks/DefaultPoolMock.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; +import {LimitedToken, SwapQuery, SwapQuoterMock} from "../mocks/SwapQuoterMock.sol"; +import {WETHMock} from "../mocks/WETHMock.sol"; + +import {Test} from "forge-std/Test.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract SynapseIntentPreviewerTest is Test { + address internal constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint256 internal constant AMOUNT_IN = 1.337 ether; + uint256 internal constant SWAP_AMOUNT_OUT = 4.2 ether; + uint256 internal constant ALL_ACTIONS_MASK = type(uint256).max; + uint256 internal constant FULL_AMOUNT = type(uint256).max; + + uint8 internal constant TOKEN_IN_INDEX = 2; + uint8 internal constant TOKEN_OUT_INDEX = 1; + uint8 internal constant TOKENS = 3; + uint8 internal constant LP_TOKEN_INDEX = type(uint8).max; + + ZapDataV1Harness internal zapDataLib; + + SynapseIntentPreviewer internal sip; + address internal defaultPoolMock; + address internal swapQuoterMock; + + address internal weth; + address internal tokenA; + address internal tokenB; + address internal lpToken; + + address internal routerAdapterMock = makeAddr("Router Adapter Mock"); + address internal user = makeAddr("User"); + + function setUp() public { + sip = new SynapseIntentPreviewer(); + + defaultPoolMock = address(new DefaultPoolMock()); + swapQuoterMock = address(new SwapQuoterMock()); + + weth = address(new WETHMock()); + tokenA = address(new MockERC20("A", 18)); + tokenB = address(new MockERC20("B", 18)); + lpToken = address(new MockERC20("LP", 18)); + + zapDataLib = new ZapDataV1Harness(); + + vm.label(defaultPoolMock, "DefaultPoolMock"); + vm.label(swapQuoterMock, "SwapQuoterMock"); + vm.label(weth, "WETHMock"); + vm.label(tokenA, "TokenA"); + vm.label(tokenB, "TokenB"); + vm.label(lpToken, "LPToken"); + vm.label(address(zapDataLib), "ZapDataV1Harness"); + + vm.mockCall({ + callee: defaultPoolMock, + data: abi.encodeCall(DefaultPoolMock.swapStorage, ()), + returnData: abi.encode(0, 0, 0, 0, 0, 0, lpToken) + }); + } + + function mockGetAmountOut(address tokenIn, address tokenOut, uint256 amountIn, SwapQuery memory mockQuery) public { + LimitedToken memory token = LimitedToken({actionMask: ALL_ACTIONS_MASK, token: tokenIn}); + vm.mockCall({ + callee: swapQuoterMock, + data: abi.encodeCall(SwapQuoterMock.getAmountOut, (token, tokenOut, amountIn)), + returnData: abi.encode(mockQuery) + }); + } + + function mockGetToken(uint8 tokenIndex, address token) public { + vm.mockCall({ + callee: defaultPoolMock, + data: abi.encodeCall(DefaultPoolMock.getToken, (tokenIndex)), + returnData: abi.encode(token) + }); + } + + function getSwapQuery(address tokenOut) public view returns (SwapQuery memory) { + return SwapQuery({ + routerAdapter: routerAdapterMock, + tokenOut: tokenOut, + minAmountOut: SWAP_AMOUNT_OUT, + deadline: type(uint256).max, + rawParams: abi.encode( + DefaultParams({ + action: Action.Swap, + pool: defaultPoolMock, + tokenIndexFrom: TOKEN_IN_INDEX, + tokenIndexTo: TOKEN_OUT_INDEX + }) + ) + }); + } + + function getSwapZapData(address forwardTo) public view returns (bytes memory) { + return getSwapZapData(TOKEN_IN_INDEX, TOKEN_OUT_INDEX, forwardTo); + } + + function getSwapZapData(uint8 indexIn, uint8 indexOut, address forwardTo) public view returns (bytes memory) { + return zapDataLib.encodeV1({ + target_: defaultPoolMock, + finalToken_: DefaultPoolMock(defaultPoolMock).getToken(indexOut), + forwardTo_: forwardTo, + // swap(tokenIndexFrom, tokenIndexTo, dx, minDy, deadline) + payload_: abi.encodeCall(DefaultPoolMock.swap, (indexIn, indexOut, 0, 0, type(uint256).max)), + // Amount (dx) is encoded as the third parameter + amountPosition_: 4 + 32 * 2 + }); + } + + function checkSwapZapData(address forwardTo) public view { + for (uint8 i = 0; i < TOKENS; i++) { + for (uint8 j = 0; j < TOKENS; j++) { + bytes memory zapData = getSwapZapData(i, j, forwardTo); + bytes memory payload = zapDataLib.payload(zapData, AMOUNT_IN); + // swap(tokenIndexFrom, tokenIndexTo, dx, minDy, deadline) + assertEq(payload, abi.encodeCall(DefaultPoolMock.swap, (i, j, AMOUNT_IN, 0, type(uint256).max))); + assertEq(zapDataLib.forwardTo(zapData), forwardTo); + } + } + } + + function test_getSwapZapData_noForward() public view { + checkSwapZapData(address(0)); + } + + function test_getSwapZapData_withForward() public view { + checkSwapZapData(user); + } + + function getAddLiquidityQuery(address tokenOut) public view returns (SwapQuery memory) { + return SwapQuery({ + routerAdapter: routerAdapterMock, + tokenOut: tokenOut, + minAmountOut: SWAP_AMOUNT_OUT, + deadline: type(uint256).max, + rawParams: abi.encode( + DefaultParams({ + action: Action.AddLiquidity, + pool: defaultPoolMock, + tokenIndexFrom: TOKEN_IN_INDEX, + tokenIndexTo: LP_TOKEN_INDEX + }) + ) + }); + } + + function getAddLiquidityZapData(address forwardTo) public view returns (bytes memory) { + return getAddLiquidityZapData(TOKEN_IN_INDEX, forwardTo); + } + + function getAddLiquidityZapData(uint8 indexIn, address forwardTo) public view returns (bytes memory) { + uint256[] memory amounts = new uint256[](TOKENS); + return zapDataLib.encodeV1({ + target_: defaultPoolMock, + finalToken_: lpToken, + forwardTo_: forwardTo, + // addLiquidity(amounts, minToMint, deadline) + payload_: abi.encodeCall(IDefaultExtendedPool.addLiquidity, (amounts, 0, type(uint256).max)), + // Amount is encoded within `amounts` at `TOKEN_IN_INDEX`, `amounts` is encoded after + // (amounts.offset, minToMint, deadline, amounts.length) + amountPosition_: 4 + 32 * (4 + indexIn) + }); + } + + function checkAddLiquidityZapData(address forwardTo) public view { + for (uint8 i = 0; i < TOKENS; i++) { + bytes memory zapData = getAddLiquidityZapData(i, forwardTo); + bytes memory payload = zapDataLib.payload(zapData, AMOUNT_IN); + uint256[] memory amounts = new uint256[](TOKENS); + amounts[i] = AMOUNT_IN; + // addLiquidity(amounts, minToMint, deadline) + assertEq(payload, abi.encodeCall(IDefaultExtendedPool.addLiquidity, (amounts, 0, type(uint256).max))); + assertEq(zapDataLib.forwardTo(zapData), forwardTo); + } + } + + function test_getAddLiquidityZapData_noForward() public view { + checkAddLiquidityZapData(address(0)); + } + + function test_getAddLiquidityZapData_withForward() public view { + checkAddLiquidityZapData(user); + } + + function getRemoveLiquidityQuery(address tokenOut) public view returns (SwapQuery memory) { + return SwapQuery({ + routerAdapter: routerAdapterMock, + tokenOut: tokenOut, + minAmountOut: SWAP_AMOUNT_OUT, + deadline: type(uint256).max, + rawParams: abi.encode( + DefaultParams({ + action: Action.RemoveLiquidity, + pool: defaultPoolMock, + tokenIndexFrom: LP_TOKEN_INDEX, + tokenIndexTo: TOKEN_OUT_INDEX + }) + ) + }); + } + + function getRemoveLiquidityZapData(address forwardTo) public view returns (bytes memory) { + return getRemoveLiquidityZapData(TOKEN_OUT_INDEX, forwardTo); + } + + function getRemoveLiquidityZapData(uint8 indexOut, address forwardTo) public view returns (bytes memory) { + return zapDataLib.encodeV1({ + target_: defaultPoolMock, + finalToken_: DefaultPoolMock(defaultPoolMock).getToken(indexOut), + forwardTo_: forwardTo, + // removeLiquidityOneToken(tokenAmount, tokenIndex, minAmount, deadline) + payload_: abi.encodeCall(IDefaultExtendedPool.removeLiquidityOneToken, (0, indexOut, 0, type(uint256).max)), + // Amount (tokenAmount) is encoded as the first parameter + amountPosition_: 4 + }); + } + + function checkRemoveLiquidityZapData(address forwardTo) public view { + for (uint8 i = 0; i < TOKENS; i++) { + bytes memory zapData = getRemoveLiquidityZapData(i, forwardTo); + bytes memory payload = zapDataLib.payload(zapData, AMOUNT_IN); + // removeLiquidityOneToken(tokenAmount, tokenIndex, minAmount, deadline) + assertEq( + payload, + abi.encodeCall(IDefaultExtendedPool.removeLiquidityOneToken, (AMOUNT_IN, i, 0, type(uint256).max)) + ); + assertEq(zapDataLib.forwardTo(zapData), forwardTo); + } + } + + function test_getRemoveLiquidityZapData_noForward() public view { + checkRemoveLiquidityZapData(address(0)); + } + + function test_getRemoveLiquidityZapData_withForward() public view { + checkRemoveLiquidityZapData(user); + } + + function getWrapETHQuery(address tokenOut) public view returns (SwapQuery memory) { + return SwapQuery({ + routerAdapter: routerAdapterMock, + tokenOut: tokenOut, + minAmountOut: AMOUNT_IN, + deadline: type(uint256).max, + rawParams: abi.encode( + DefaultParams({ + action: Action.HandleEth, + pool: address(0), + tokenIndexFrom: LP_TOKEN_INDEX, + tokenIndexTo: LP_TOKEN_INDEX + }) + ) + }); + } + + function getWrapETHZapData(address forwardTo) public view returns (bytes memory) { + return zapDataLib.encodeV1({ + target_: weth, + finalToken_: weth, + forwardTo_: forwardTo, + // deposit() + payload_: abi.encodeCall(WETHMock.deposit, ()), + // Amount is not encoded + amountPosition_: zapDataLib.AMOUNT_NOT_PRESENT() + }); + } + + function checkWrapETHZapData(address forwardTo) public view { + bytes memory zapData = getWrapETHZapData(forwardTo); + bytes memory payload = zapDataLib.payload(zapData, AMOUNT_IN); + // deposit() + assertEq(payload, abi.encodeCall(WETHMock.deposit, ())); + assertEq(zapDataLib.forwardTo(zapData), forwardTo); + } + + function test_getWrapETHZapData_noForward() public view { + checkWrapETHZapData(address(0)); + } + + function test_getWrapETHZapData_withForward() public view { + checkWrapETHZapData(user); + } + + function getUnwrapWETHQuery(address tokenOut) public view returns (SwapQuery memory) { + return SwapQuery({ + routerAdapter: routerAdapterMock, + tokenOut: tokenOut, + minAmountOut: AMOUNT_IN, + deadline: type(uint256).max, + rawParams: abi.encode( + DefaultParams({ + action: Action.HandleEth, + pool: address(0), + tokenIndexFrom: LP_TOKEN_INDEX, + tokenIndexTo: LP_TOKEN_INDEX + }) + ) + }); + } + + function getUnwrapWETHZapData(address forwardTo) public view returns (bytes memory) { + return zapDataLib.encodeV1({ + target_: weth, + finalToken_: NATIVE_GAS_TOKEN, + forwardTo_: forwardTo, + // withdraw(amount) + payload_: abi.encodeCall(WETHMock.withdraw, (0)), + // Amount is encoded as the first parameter + amountPosition_: 4 + }); + } + + function checkUnwrapWETHZapData(address forwardTo) public view { + bytes memory zapData = getUnwrapWETHZapData(forwardTo); + bytes memory payload = zapDataLib.payload(zapData, AMOUNT_IN); + // withdraw(amount) + assertEq(payload, abi.encodeCall(WETHMock.withdraw, (AMOUNT_IN))); + assertEq(zapDataLib.forwardTo(zapData), forwardTo); + } + + function test_getUnwrapWETHZapData_noForward() public view { + checkUnwrapWETHZapData(address(0)); + } + + function test_getUnwrapWETHZapData_withForward() public view { + checkUnwrapWETHZapData(user); + } + + function assertEq(ISynapseIntentRouter.StepParams memory a, ISynapseIntentRouter.StepParams memory b) public pure { + assertEq(a.token, b.token); + assertEq(a.amount, b.amount); + assertEq(a.msgValue, b.msgValue); + assertEq(a.zapData, b.zapData); + } + + // ════════════════════════════════════════════════ ZERO STEPS ═════════════════════════════════════════════════════ + + function test_previewIntent_noOp_token() public view { + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: address(0), + tokenIn: tokenA, + tokenOut: tokenA, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, AMOUNT_IN); + assertEq(steps.length, 0); + } + + function test_previewIntent_noOp_token_revert_withForward() public { + // forwardTo is not allowed for no-op intents + vm.expectRevert(SynapseIntentPreviewer.SIP__NoOpForwardNotSupported.selector); + sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: user, + tokenIn: tokenA, + tokenOut: tokenA, + amountIn: AMOUNT_IN + }); + } + + function test_previewIntent_noOp_native() public view { + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: address(0), + tokenIn: NATIVE_GAS_TOKEN, + tokenOut: NATIVE_GAS_TOKEN, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, AMOUNT_IN); + assertEq(steps.length, 0); + } + + function test_previewIntent_noOp_native_revert_withForward() public { + // forwardTo is not allowed for no-op intents + vm.expectRevert(SynapseIntentPreviewer.SIP__NoOpForwardNotSupported.selector); + sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: user, + tokenIn: NATIVE_GAS_TOKEN, + tokenOut: NATIVE_GAS_TOKEN, + amountIn: AMOUNT_IN + }); + } + + function test_previewIntent_zeroAmountOut() public { + // tokenOut is always populated + SwapQuery memory emptyQuery; + emptyQuery.tokenOut = tokenB; + mockGetAmountOut({tokenIn: tokenA, tokenOut: tokenB, amountIn: AMOUNT_IN, mockQuery: emptyQuery}); + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: address(0), + tokenIn: tokenA, + tokenOut: tokenB, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, 0); + assertEq(steps.length, 0); + } + + function test_previewIntent_zeroAmountOut_withForward() public { + // tokenOut is always populated + SwapQuery memory emptyQuery; + emptyQuery.tokenOut = tokenB; + mockGetAmountOut({tokenIn: tokenA, tokenOut: tokenB, amountIn: AMOUNT_IN, mockQuery: emptyQuery}); + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: user, + tokenIn: tokenA, + tokenOut: tokenB, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, 0); + assertEq(steps.length, 0); + } + + // ════════════════════════════════════════════════ SINGLE STEP ════════════════════════════════════════════════════ + + function checkSingleStepIntent( + address tokenIn, + address tokenOut, + uint256 expectedAmountOut, + ISynapseIntentRouter.StepParams memory expectedStep, + address forwardTo + ) + public + view + { + // Preview intent + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: forwardTo, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, expectedAmountOut); + assertEq(steps.length, 1); + assertEq(steps[0], expectedStep); + } + + function checkPreviewIntentSwap(address forwardTo) public { + SwapQuery memory mockQuery = getSwapQuery(tokenB); + mockGetToken(TOKEN_IN_INDEX, tokenA); + mockGetToken(TOKEN_OUT_INDEX, tokenB); + mockGetAmountOut({tokenIn: tokenA, tokenOut: tokenB, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + ISynapseIntentRouter.StepParams memory expectedStep = ISynapseIntentRouter.StepParams({ + token: tokenA, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getSwapZapData(forwardTo) + }); + checkSingleStepIntent(tokenA, tokenB, SWAP_AMOUNT_OUT, expectedStep, forwardTo); + } + + function test_previewIntent_swap() public { + checkPreviewIntentSwap(address(0)); + } + + function test_previewIntent_swap_withForward() public { + checkPreviewIntentSwap(user); + } + + function checkPreviewIntentAddLiquidity(address forwardTo) public { + SwapQuery memory mockQuery = getAddLiquidityQuery(lpToken); + mockGetToken(TOKEN_IN_INDEX, tokenA); + mockGetAmountOut({tokenIn: tokenA, tokenOut: lpToken, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + ISynapseIntentRouter.StepParams memory expectedStep = ISynapseIntentRouter.StepParams({ + token: tokenA, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getAddLiquidityZapData(forwardTo) + }); + checkSingleStepIntent(tokenA, lpToken, SWAP_AMOUNT_OUT, expectedStep, forwardTo); + } + + function test_previewIntent_addLiquidity() public { + checkPreviewIntentAddLiquidity(address(0)); + } + + function test_previewIntent_addLiquidity_withForward() public { + checkPreviewIntentAddLiquidity(user); + } + + function checkPreviewIntentRemoveLiquidity(address forwardTo) public { + SwapQuery memory mockQuery = getRemoveLiquidityQuery(tokenB); + mockGetToken(TOKEN_OUT_INDEX, tokenB); + mockGetAmountOut({tokenIn: lpToken, tokenOut: tokenB, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + ISynapseIntentRouter.StepParams memory expectedStep = ISynapseIntentRouter.StepParams({ + token: lpToken, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getRemoveLiquidityZapData(forwardTo) + }); + checkSingleStepIntent(lpToken, tokenB, SWAP_AMOUNT_OUT, expectedStep, forwardTo); + } + + function test_previewIntent_removeLiquidity() public { + checkPreviewIntentRemoveLiquidity(address(0)); + } + + function test_previewIntent_removeLiquidity_withForward() public { + checkPreviewIntentRemoveLiquidity(user); + } + + function checkPreviewIntentWrapETH(address forwardTo) public { + SwapQuery memory mockQuery = getWrapETHQuery(weth); + mockGetAmountOut({tokenIn: NATIVE_GAS_TOKEN, tokenOut: weth, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + ISynapseIntentRouter.StepParams memory expectedStep = ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: FULL_AMOUNT, + msgValue: AMOUNT_IN, + zapData: getWrapETHZapData(forwardTo) + }); + checkSingleStepIntent(NATIVE_GAS_TOKEN, weth, AMOUNT_IN, expectedStep, forwardTo); + } + + function test_previewIntent_wrapETH() public { + checkPreviewIntentWrapETH(address(0)); + } + + function test_previewIntent_wrapETH_withForward() public { + checkPreviewIntentWrapETH(user); + } + + function checkPreviewIntentUnwrapWETH(address forwardTo) public { + SwapQuery memory mockQuery = getUnwrapWETHQuery(NATIVE_GAS_TOKEN); + mockGetAmountOut({tokenIn: weth, tokenOut: NATIVE_GAS_TOKEN, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + ISynapseIntentRouter.StepParams memory expectedStep = ISynapseIntentRouter.StepParams({ + token: weth, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getUnwrapWETHZapData(forwardTo) + }); + checkSingleStepIntent(weth, NATIVE_GAS_TOKEN, AMOUNT_IN, expectedStep, forwardTo); + } + + function test_previewIntent_unwrapWETH() public { + checkPreviewIntentUnwrapWETH(address(0)); + } + + function test_previewIntent_unwrapWETH_withForward() public { + checkPreviewIntentUnwrapWETH(user); + } + + // ════════════════════════════════════════════════ DOUBLE STEP ════════════════════════════════════════════════════ + + function checkDoubleStepIntent( + address tokenIn, + address tokenOut, + uint256 expectedAmountOut, + ISynapseIntentRouter.StepParams memory expectedStep0, + ISynapseIntentRouter.StepParams memory expectedStep1, + address forwardTo + ) + public + view + { + // Preview intent + (uint256 amountOut, ISynapseIntentRouter.StepParams[] memory steps) = sip.previewIntent({ + swapQuoter: swapQuoterMock, + forwardTo: forwardTo, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountIn: AMOUNT_IN + }); + // Checks + assertEq(amountOut, expectedAmountOut); + assertEq(steps.length, 2); + assertEq(steps[0], expectedStep0); + assertEq(steps[1], expectedStep1); + } + + function checkPreviewIntentSwapUnwrapWETH(address forwardTo) public { + SwapQuery memory mockQuery = getSwapQuery(weth); + mockGetToken(TOKEN_IN_INDEX, tokenA); + mockGetToken(TOKEN_OUT_INDEX, weth); + mockGetAmountOut({tokenIn: tokenA, tokenOut: NATIVE_GAS_TOKEN, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + // step0: tokenA -> weth, always no forwaring + ISynapseIntentRouter.StepParams memory expectedStep0 = ISynapseIntentRouter.StepParams({ + token: tokenA, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getSwapZapData(address(0)) + }); + // step1: weth -> NATIVE_GAS_TOKEN, optional forwarding + ISynapseIntentRouter.StepParams memory expectedStep1 = ISynapseIntentRouter.StepParams({ + token: weth, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getUnwrapWETHZapData(forwardTo) + }); + checkDoubleStepIntent(tokenA, NATIVE_GAS_TOKEN, SWAP_AMOUNT_OUT, expectedStep0, expectedStep1, forwardTo); + } + + function test_previewIntent_swapUnwrapWETH() public { + checkPreviewIntentSwapUnwrapWETH(address(0)); + } + + function test_previewIntent_swapUnwrapWETH_withForward() public { + checkPreviewIntentSwapUnwrapWETH(user); + } + + function checkPreviewIntentWrapETHSwap(address forwardTo) public { + SwapQuery memory mockQuery = getSwapQuery(tokenB); + mockGetToken(TOKEN_IN_INDEX, weth); + mockGetToken(TOKEN_OUT_INDEX, tokenB); + mockGetAmountOut({tokenIn: NATIVE_GAS_TOKEN, tokenOut: tokenB, amountIn: AMOUNT_IN, mockQuery: mockQuery}); + // step0: NATIVE_GAS_TOKEN -> weth, always no forwaring + ISynapseIntentRouter.StepParams memory expectedStep0 = ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: FULL_AMOUNT, + msgValue: AMOUNT_IN, + zapData: getWrapETHZapData(address(0)) + }); + // step1: weth -> tokenB, optional forwarding + ISynapseIntentRouter.StepParams memory expectedStep1 = ISynapseIntentRouter.StepParams({ + token: weth, + amount: FULL_AMOUNT, + msgValue: 0, + zapData: getSwapZapData(forwardTo) + }); + checkDoubleStepIntent(NATIVE_GAS_TOKEN, tokenB, SWAP_AMOUNT_OUT, expectedStep0, expectedStep1, forwardTo); + } + + function test_previewIntent_wrapETHSwap() public { + checkPreviewIntentWrapETHSwap(address(0)); + } + + function test_previewIntent_wrapETHSwap_withForward() public { + checkPreviewIntentWrapETHSwap(user); + } +} diff --git a/packages/contracts-rfq/test/router/SynapseIntentRouter.BalanceChecks.t.sol b/packages/contracts-rfq/test/router/SynapseIntentRouter.BalanceChecks.t.sol new file mode 100644 index 0000000000..7a6ba01019 --- /dev/null +++ b/packages/contracts-rfq/test/router/SynapseIntentRouter.BalanceChecks.t.sol @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {ISynapseIntentRouter, SynapseIntentRouterTest} from "./SynapseIntentRouter.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract SynapseIntentRouterBalanceChecksTest is SynapseIntentRouterTest { + function completeUserIntent( + uint256 msgValue, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + ISynapseIntentRouter.StepParams[] memory steps + ) + public + virtual + override + { + vm.prank(user); + router.completeIntentWithBalanceChecks{value: msgValue}({ + zapRecipient: address(tokenZap), + amountIn: amountIn, + minLastStepAmountIn: minLastStepAmountIn, + deadline: deadline, + steps: steps + }); + } + + // ═════════════════════════════════════════ SINGLE ZAP UNSPENT FUNDS ══════════════════════════════════════════════ + + function test_depositERC20_exactAmount_revert_unspentERC20() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(AMOUNT); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT + 1, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_depositERC20_exactAmount_extraFunds_revert_unspentERC20() public withExtraFunds { + test_depositERC20_exactAmount_revert_unspentERC20(); + } + + function test_depositNative_exactAmount_revert_unspentNative() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + steps[0].msgValue = AMOUNT + 1; + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: AMOUNT + 1, + amountIn: AMOUNT + 1, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_depositNative_exactAmount_extraFunds_revert_unspentNative() public withExtraFunds { + test_depositNative_exactAmount_revert_unspentNative(); + } + + // ═════════════════════════════════════════ DOUBLE ZAP UNSPENT FUNDS ══════════════════════════════════════════════ + + function test_swapDepositERC20_exactAmounts_revert_unspentERC20() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT + 1, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + } + + function test_swapDepositERC20_exactAmounts_revert_unspentWETH() public { + uint256 amountReduced = AMOUNT * TOKEN_PRICE - 1; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountReduced); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountReduced, + deadline: block.timestamp, + steps: steps + }); + } + + function test_swapDepositERC20_exactAmounts_extraFunds_revert_unspentERC20() public withExtraFunds { + test_swapDepositERC20_exactAmounts_revert_unspentERC20(); + } + + function test_swapDepositERC20_exactAmounts_extraFunds_revert_unspentWETH() public withExtraFunds { + test_swapDepositERC20_exactAmounts_revert_unspentWETH(); + } + + function test_swapDepositERC20_exactAmount1_extraFunds_revertWithBalanceChecks() public override withExtraFunds { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + } + + function test_wrapDepositWETH_exactAmounts_revert_unspentNative() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, AMOUNT); + steps[0].msgValue = AMOUNT + 1; + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: AMOUNT + 1, + amountIn: AMOUNT + 1, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_wrapDepositWETH_exactAmounts_revert_unspentWETH() public { + uint256 amountReduced = AMOUNT - 1; + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, amountReduced); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: amountReduced, + deadline: block.timestamp, + steps: steps + }); + } + + function test_wrapDepositWETH_exactAmounts_extraFunds_revert_unspentNative() public withExtraFunds { + test_wrapDepositWETH_exactAmounts_revert_unspentNative(); + } + + function test_wrapDepositWETH_exactAmounts_extraFunds_revert_unspentWETH() public withExtraFunds { + test_wrapDepositWETH_exactAmounts_revert_unspentWETH(); + } + + function test_wrapDepositWETH_exactAmount1_extraFunds_revertWithBalanceChecks() public override withExtraFunds { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_unwrapDepositNative_exactAmounts_revert_unspentWETH() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, AMOUNT); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT + 1, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_unwrapDepositNative_exactAmounts_revert_unspentNative() public { + uint256 amountReduced = AMOUNT - 1; + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, amountReduced); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountReduced, + deadline: block.timestamp, + steps: steps + }); + } + + function test_unwrapDepositNative_exactAmounts_extraFunds_revert_unspentWETH() public withExtraFunds { + test_unwrapDepositNative_exactAmounts_revert_unspentWETH(); + } + + function test_unwrapDepositNative_exactAmounts_extraFunds_revert_unspentNative() public withExtraFunds { + test_unwrapDepositNative_exactAmounts_revert_unspentNative(); + } + + function test_unwrapDepositNative_exactAmount1_extraFunds_revertWithBalanceChecks() + public + override + withExtraFunds + { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, AMOUNT); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + } + + function test_swapUnwrapForwardNative_exactAmounts_revert_unspentERC20() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT + 1, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + } + + function test_swapUnwrapForwardNative_exactAmounts_revert_unspentWETH() public { + uint256 amountReduced = AMOUNT / TOKEN_PRICE - 1; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountReduced); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountReduced, + deadline: block.timestamp, + steps: steps + }); + } + + function test_swapUnwrapForwardNative_exactAmounts_extraFunds_revert_unspentERC20() public withExtraFunds { + test_swapUnwrapForwardNative_exactAmounts_revert_unspentERC20(); + } + + function test_swapUnwrapForwardNative_exactAmounts_extraFunds_revert_unspentWETH() public withExtraFunds { + test_swapUnwrapForwardNative_exactAmounts_revert_unspentWETH(); + } + + function test_swapUnwrapForwardNative_exactAmount1_extraFunds_revertWithBalanceChecks() + public + override + withExtraFunds + { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + vm.expectRevert(SIR__UnspentFunds.selector); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + } +} diff --git a/packages/contracts-rfq/test/router/SynapseIntentRouter.t.sol b/packages/contracts-rfq/test/router/SynapseIntentRouter.t.sol new file mode 100644 index 0000000000..35501041d3 --- /dev/null +++ b/packages/contracts-rfq/test/router/SynapseIntentRouter.t.sol @@ -0,0 +1,1379 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { + ISynapseIntentRouter, + ISynapseIntentRouterErrors, + SynapseIntentRouter +} from "../../contracts/router/SynapseIntentRouter.sol"; +import {TokenZapV1} from "../../contracts/zaps/TokenZapV1.sol"; + +import {MockERC20} from "../mocks/MockERC20.sol"; +import {PoolMock} from "../mocks/PoolMock.sol"; +import {SimpleVaultMock} from "../mocks/SimpleVaultMock.sol"; +import {WETHMock} from "../mocks/WETHMock.sol"; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Test} from "forge-std/Test.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract SynapseIntentRouterTest is Test, ISynapseIntentRouterErrors { + address internal constant NATIVE_GAS_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint256 internal constant AMOUNT = 1 ether; + uint256 internal constant EXTRA_FUNDS = 0.1337 ether; + uint256 internal constant TOKEN_PRICE = 2; // in ETH + uint256 internal constant FULL_BALANCE = type(uint256).max; + + SynapseIntentRouter internal router; + TokenZapV1 internal tokenZap; + + MockERC20 internal erc20; + WETHMock internal weth; + PoolMock internal pool; + SimpleVaultMock internal vault; + + address internal user; + + modifier withExtraFunds() { + erc20.mint(address(tokenZap), EXTRA_FUNDS); + weth.mint(address(tokenZap), EXTRA_FUNDS); + deal(address(tokenZap), EXTRA_FUNDS); + _; + } + + function setUp() public { + router = new SynapseIntentRouter(); + tokenZap = new TokenZapV1(); + + erc20 = new MockERC20("TKN", 18); + weth = new WETHMock(); + vault = new SimpleVaultMock(); + + pool = new PoolMock(address(weth), address(erc20)); + pool.setRatioWei(TOKEN_PRICE * 1e18); + + user = makeAddr("User"); + + // Deal funds to the user + erc20.mint(user, 10 * AMOUNT); + weth.mint(user, 10 * AMOUNT); + deal(user, 10 * AMOUNT); + + // Deal funds to the pool + erc20.mint(address(pool), 1000 * AMOUNT); + weth.mint(address(pool), 1000 * AMOUNT); + deal(address(pool), 1000 * AMOUNT); + + // Approve the router + vm.prank(user); + erc20.approve(address(router), type(uint256).max); + vm.prank(user); + weth.approve(address(router), type(uint256).max); + } + + function getWrapZapData() public view returns (bytes memory) { + return tokenZap.encodeZapData({ + target: address(weth), + payload: abi.encodeCall(weth.deposit, ()), + // Amount is not encoded + amountPosition: type(uint256).max, + finalToken: address(weth), + forwardTo: address(0) + }); + } + + function getUnwrapZapData(address forwardTo) public view returns (bytes memory) { + return tokenZap.encodeZapData({ + target: address(weth), + payload: abi.encodeCall(weth.withdraw, (AMOUNT)), + // Amount is encoded as the first parameter + amountPosition: 4, + finalToken: NATIVE_GAS_TOKEN, + forwardTo: forwardTo + }); + } + + function getSwapZapData(address token, address forwardTo) public view returns (bytes memory) { + address otherToken = token == address(weth) ? address(erc20) : address(weth); + return tokenZap.encodeZapData({ + target: address(pool), + // Use placeholder zero amount + payload: abi.encodeCall(pool.swap, (0, token)), + // Amount is encoded as the first parameter + amountPosition: 4, + finalToken: otherToken, + forwardTo: forwardTo + }); + } + + function getDepositZapData(address token) public view returns (bytes memory) { + return tokenZap.encodeZapData({ + target: address(vault), + // Use placeholder zero amount + payload: abi.encodeCall(vault.deposit, (token, 0, user)), + // Amount is encoded as the second parameter + amountPosition: 4 + 32, + finalToken: address(0), + forwardTo: address(0) + }); + } + + function completeUserIntent( + uint256 msgValue, + uint256 amountIn, + uint256 minLastStepAmountIn, + uint256 deadline, + ISynapseIntentRouter.StepParams[] memory steps + ) + public + virtual + { + vm.prank(user); + router.completeIntent{value: msgValue}({ + zapRecipient: address(tokenZap), + amountIn: amountIn, + minLastStepAmountIn: minLastStepAmountIn, + deadline: deadline, + steps: steps + }); + } + + function checkRevertMsgValueAboveExpectedWithERC20( + ISynapseIntentRouter.StepParams[] memory steps, + uint256 lastStepAmountIn + ) + public + { + vm.expectRevert(SIR__MsgValueIncorrect.selector); + completeUserIntent({ + msgValue: 1, + amountIn: AMOUNT, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp, + steps: steps + }); + } + + function checkRevertsMsgValueAboveExpectedWithNative( + ISynapseIntentRouter.StepParams[] memory steps, + uint256 lastStepAmountIn + ) + public + { + // Just msg.value is too high + vm.expectRevert(SIR__MsgValueIncorrect.selector); + completeUserIntent({ + msgValue: AMOUNT + 1, + amountIn: AMOUNT, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp, + steps: steps + }); + // Both msg.value and amountIn are too high + vm.expectRevert(SIR__MsgValueIncorrect.selector); + completeUserIntent({ + msgValue: AMOUNT + 1, + amountIn: AMOUNT + 1, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp, + steps: steps + }); + } + + function checkRevertsMsgValueBelowExpectedWithNative( + ISynapseIntentRouter.StepParams[] memory steps, + uint256 lastStepAmountIn + ) + public + { + // Just msg.value is too low + vm.expectRevert(SIR__MsgValueIncorrect.selector); + completeUserIntent({ + msgValue: AMOUNT - 1, + amountIn: AMOUNT, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp, + steps: steps + }); + // Both msg.value and amountIn are too low + vm.expectRevert(abi.encodeWithSelector(Address.AddressInsufficientBalance.selector, router)); + completeUserIntent({ + msgValue: AMOUNT - 1, + amountIn: AMOUNT - 1, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp, + steps: steps + }); + } + + function checkRevertDeadlineExceeded( + uint256 msgValue, + uint256 lastStepAmountIn, + ISynapseIntentRouter.StepParams[] memory steps + ) + public + { + vm.expectRevert(SIR__DeadlineExceeded.selector); + completeUserIntent({ + msgValue: msgValue, + amountIn: AMOUNT, + minLastStepAmountIn: lastStepAmountIn, + deadline: block.timestamp - 1, + steps: steps + }); + } + + function checkRevertAmountInsufficient( + uint256 msgValue, + uint256 lastStepAmountIn, + ISynapseIntentRouter.StepParams[] memory steps + ) + public + { + vm.expectRevert(SIR__AmountInsufficient.selector); + completeUserIntent({ + msgValue: msgValue, + amountIn: AMOUNT, + minLastStepAmountIn: lastStepAmountIn + 1, + deadline: block.timestamp, + steps: steps + }); + } + + // ═══════════════════════════════════════════════ DEPOSIT ERC20 ═══════════════════════════════════════════════════ + + function getDepositERC20Steps(uint256 amount) public view returns (ISynapseIntentRouter.StepParams[] memory) { + return toArray( + ISynapseIntentRouter.StepParams({ + token: address(erc20), + amount: amount, + msgValue: 0, + zapData: getDepositZapData(address(erc20)) + }) + ); + } + + function test_depositERC20_exactAmount() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(AMOUNT); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), AMOUNT); + } + + /// @notice Extra funds should have no effect on "exact amount" instructions. + function test_depositERC20_exactAmount_extraFunds() public withExtraFunds { + test_depositERC20_exactAmount(); + } + + function test_depositERC20_exactAmount_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(AMOUNT); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_depositERC20_exactAmount_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(AMOUNT); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_depositERC20_exactAmount_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(AMOUNT); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_depositERC20_fullBalance() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), AMOUNT); + } + + /// @notice Extra funds should be used with "full balance" instructions. + function test_depositERC20_fullBalance_extraFunds() public withExtraFunds { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, address(erc20)), AMOUNT + EXTRA_FUNDS); + } + + function test_depositERC20_fullBalance_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_depositERC20_fullBalance_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_depositERC20_fullBalance_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositERC20Steps(FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + // ══════════════════════════════════════════════ DEPOSIT NATIVE ═══════════════════════════════════════════════════ + + function getDepositNativeSteps(uint256 amount) public view returns (ISynapseIntentRouter.StepParams[] memory) { + return toArray( + ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: amount, + msgValue: AMOUNT, + zapData: getDepositZapData(NATIVE_GAS_TOKEN) + }) + ); + } + + function test_depositNative_exactAmount() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Extra funds should have no effect on "exact amount" instructions. + function test_depositNative_exactAmount_extraFunds() public withExtraFunds { + test_depositNative_exactAmount(); + } + + function test_depositNative_exactAmount_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_depositNative_exactAmount_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_depositNative_exactAmount_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_depositNative_exactAmount_revert_msgValueBelowExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(AMOUNT); + checkRevertsMsgValueBelowExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT - 1}); + } + + function test_depositNative_fullBalance() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Extra funds should be used with "full balance" instructions. + function test_depositNative_fullBalance_extraFunds() public withExtraFunds { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT + EXTRA_FUNDS); + } + + function test_depositNative_fullBalance_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_depositNative_fullBalance_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_depositNative_fullBalance_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_depositNative_fullBalance_revert_msgValueBelowExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getDepositNativeSteps(FULL_BALANCE); + checkRevertsMsgValueBelowExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT - 1}); + } + + // ═══════════════════════════════════════════ SWAP & FORWARD ERC20 ════════════════════════════════════════════════ + + function getSwapForwardERC20Steps(uint256 amountSwap) + public + view + returns (ISynapseIntentRouter.StepParams[] memory) + { + return toArray( + // WETH -> ERC20 + ISynapseIntentRouter.StepParams({ + token: address(weth), + amount: amountSwap, + msgValue: 0, + zapData: getSwapZapData(address(weth), user) + }) + ); + } + + function test_swapForwardERC20_exactAmount() public { + uint256 initialBalance = erc20.balanceOf(user); + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(AMOUNT); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check the user erc20 balance + assertEq(erc20.balanceOf(user), initialBalance + AMOUNT * TOKEN_PRICE); + } + + /// @notice Extra funds should be used with "forward" instructions. + function test_swapForwardERC20_exactAmount_extraFunds() public withExtraFunds { + uint256 initialBalance = erc20.balanceOf(user); + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(AMOUNT); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check the user erc20 balance + assertEq(erc20.balanceOf(user), initialBalance + AMOUNT * TOKEN_PRICE + EXTRA_FUNDS); + } + + function test_swapForwardERC20_exactAmount_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(AMOUNT); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_swapForwardERC20_exactAmount_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(AMOUNT); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_swapForwardERC20_exactAmount_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(AMOUNT); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_swapForwardERC20_fullBalance() public { + uint256 initialBalance = erc20.balanceOf(user); + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check the user erc20 balance + assertEq(erc20.balanceOf(user), initialBalance + AMOUNT * TOKEN_PRICE); + } + + /// @notice Extra funds should be used with "full balance" instructions. + function test_swapForwardERC20_fullBalance_extraFunds() public withExtraFunds { + uint256 initialBalance = erc20.balanceOf(user); + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check the user erc20 balance with the extra funds + assertEq(erc20.balanceOf(user), initialBalance + (AMOUNT + EXTRA_FUNDS) * TOKEN_PRICE + EXTRA_FUNDS); + } + + function test_swapForwardERC20_fullBalance_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_swapForwardERC20_fullBalance_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_swapForwardERC20_fullBalance_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getSwapForwardERC20Steps(FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + // ══════════════════════════════════════ SWAP & UNWRAP & FORWARD NATIVE ═══════════════════════════════════════════ + + function getSwapUnwrapForwardNativeSteps( + uint256 amountSwap, + uint256 amountUnwrap + ) + public + view + returns (ISynapseIntentRouter.StepParams[] memory) + { + return toArray( + // ERC20 -> WETH + ISynapseIntentRouter.StepParams({ + token: address(erc20), + amount: amountSwap, + msgValue: 0, + zapData: getSwapZapData(address(erc20), address(0)) + }), + // WETH -> ETH + ISynapseIntentRouter.StepParams({ + token: address(weth), + amount: amountUnwrap, + msgValue: 0, + zapData: getUnwrapZapData(user) + }) + ); + } + + function test_swapUnwrapForwardNative_exactAmounts() public { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap); + } + + /// @notice Extra funds should be used with the last forward instruction. + function test_swapUnwrapForwardNative_exactAmounts_extraFunds() public withExtraFunds { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap + EXTRA_FUNDS); + } + + function test_swapUnwrapForwardNative_exactAmounts_revert_deadlineExceeded() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountSwap, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmounts_revert_lastStepAmountInsufficient() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountSwap + 1, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmounts_revert_msgValueAboveExpected() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, amountSwap); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountSwap}); + } + + function test_swapUnwrapForwardNative_exactAmount0() public { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap); + } + + /// @notice Extra funds should be used with the last "full balance" and forward instructions. + function test_swapUnwrapForwardNative_exactAmount0_extraFunds() public withExtraFunds { + uint256 initialBalance = user.balance; + uint256 amountSwapExtra = AMOUNT / TOKEN_PRICE + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwapExtra, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance with the extra funds + assertEq(user.balance, initialBalance + amountSwapExtra + EXTRA_FUNDS); + } + + function test_swapUnwrapForwardNative_exactAmount0_revert_deadlineExceeded() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountSwap, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmount0_revert_lastStepAmountInsufficient() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountSwap + 1, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmount0_revert_msgValueAboveExpected() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountSwap}); + } + + function test_swapUnwrapForwardNative_exactAmount1() public { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap); + } + + /// @notice Should succeed with extra funds if no balance checks are performed. + /// Extra funds should be used with the last forward instruction. + function test_swapUnwrapForwardNative_exactAmount1_extraFunds_revertWithBalanceChecks() + public + virtual + withExtraFunds + { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap + EXTRA_FUNDS); + } + + function test_swapUnwrapForwardNative_exactAmount1_revert_deadlineExceeded() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountSwap, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmount1_revert_lastStepAmountInsufficient() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountSwap + 1, steps: steps}); + } + + function test_swapUnwrapForwardNative_exactAmount1_revert_msgValueAboveExpected() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, amountSwap); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountSwap}); + } + + function test_swapUnwrapForwardNative_fullBalances() public { + uint256 initialBalance = user.balance; + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwap, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance + assertEq(user.balance, initialBalance + amountSwap); + } + + /// @notice Extra funds should be used with both "full balance" instructions, and with the last forward instruction. + function test_swapUnwrapForwardNative_fullBalances_extraFunds() public withExtraFunds { + uint256 initialBalance = user.balance; + uint256 amountSwapExtra = (AMOUNT + EXTRA_FUNDS) / TOKEN_PRICE + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountSwapExtra, + deadline: block.timestamp, + steps: steps + }); + // Check the user native balance with the extra funds + assertEq(user.balance, initialBalance + amountSwapExtra + EXTRA_FUNDS); + } + + function test_swapUnwrapForwardNative_fullBalances_revert_deadlineExceeded() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountSwap, steps: steps}); + } + + function test_swapUnwrapForwardNative_fullBalances_revert_lastStepAmountInsufficient() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountSwap + 1, steps: steps}); + } + + function test_swapUnwrapForwardNative_fullBalances_revert_msgValueAboveExpected() public { + uint256 amountSwap = AMOUNT / TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapUnwrapForwardNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountSwap}); + } + + // ═══════════════════════════════════════════ SWAP & DEPOSIT ERC20 ════════════════════════════════════════════════ + + function getSwapDepositERC20Steps( + uint256 amountSwap, + uint256 amountDeposit + ) + public + view + returns (ISynapseIntentRouter.StepParams[] memory) + { + return toArray( + // WETH -> ERC20 + ISynapseIntentRouter.StepParams({ + token: address(weth), + amount: amountSwap, + msgValue: 0, + zapData: getSwapZapData(address(weth), address(0)) + }), + // deposit ERC20 + ISynapseIntentRouter.StepParams({ + token: address(erc20), + amount: amountDeposit, + msgValue: 0, + zapData: getDepositZapData(address(erc20)) + }) + ); + } + + function test_swapDepositERC20_exactAmounts() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, amountDeposit); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + /// @notice Extra funds should have no effect on "exact amount" instructions. + function test_swapDepositERC20_exactAmounts_extraFunds() public withExtraFunds { + test_swapDepositERC20_exactAmounts(); + } + + function test_swapDepositERC20_exactAmounts_revert_deadlineExceeded() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, amountDeposit); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountDeposit, steps: steps}); + } + + function test_swapDepositERC20_exactAmounts_revert_lastStepAmountInsufficient() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, amountDeposit); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountDeposit + 1, steps: steps}); + } + + function test_swapDepositERC20_exactAmounts_revert_msgValueAboveExpected() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, amountDeposit); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountDeposit}); + } + + function test_swapDepositERC20_exactAmount0() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + /// @notice Extra funds should be used with the final "full balance" instruction. + function test_swapDepositERC20_exactAmount0_extraFunds() public withExtraFunds { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + function test_swapDepositERC20_exactAmount0_revert_deadlineExceeded() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountDeposit, steps: steps}); + } + + function test_swapDepositERC20_exactAmount0_revert_lastStepAmountInsufficient() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountDeposit + 1, steps: steps}); + } + + function test_swapDepositERC20_exactAmount0_revert_msgValueAboveExpected() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(AMOUNT, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountDeposit}); + } + + function test_swapDepositERC20_exactAmount1() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + /// @notice Should succeed with extra funds if no balance checks are performed. + /// Last action is "use exact amount", so extra funds have no effect. + function test_swapDepositERC20_exactAmount1_extraFunds_revertWithBalanceChecks() public virtual withExtraFunds { + test_swapDepositERC20_exactAmount1(); + } + + function test_swapDepositERC20_exactAmount1_revert_deadlineExceeded() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountDeposit, steps: steps}); + } + + function test_swapDepositERC20_exactAmount1_revert_lastStepAmountInsufficient() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountDeposit + 1, steps: steps}); + } + + function test_swapDepositERC20_exactAmount1_revert_msgValueAboveExpected() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, amountDeposit); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountDeposit}); + } + + function test_swapDepositERC20_fullBalances() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + /// @notice Extra funds should be used with both "full balance" instructions. + function test_swapDepositERC20_fullBalances_extraFunds() public withExtraFunds { + uint256 amountDeposit = (AMOUNT + EXTRA_FUNDS) * TOKEN_PRICE + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, address(erc20)), amountDeposit); + } + + function test_swapDepositERC20_fullBalances_revert_deadlineExceeded() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: 0, lastStepAmountIn: amountDeposit, steps: steps}); + } + + function test_swapDepositERC20_fullBalances_revert_lastStepAmountInsufficient() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: 0, lastStepAmountIn: amountDeposit + 1, steps: steps}); + } + + function test_swapDepositERC20_fullBalances_revert_msgValueAboveExpected() public { + uint256 amountDeposit = AMOUNT * TOKEN_PRICE; + ISynapseIntentRouter.StepParams[] memory steps = getSwapDepositERC20Steps(FULL_BALANCE, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: amountDeposit}); + } + + // ════════════════════════════════════════════ WRAP & DEPOSIT WETH ════════════════════════════════════════════════ + + function getWrapDepositWETHSteps( + uint256 amountWrap, + uint256 amountDeposit + ) + public + view + returns (ISynapseIntentRouter.StepParams[] memory) + { + return toArray( + // ETH -> WETH + ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: amountWrap, + msgValue: AMOUNT, + zapData: getWrapZapData() + }), + // deposit WETH + ISynapseIntentRouter.StepParams({ + token: address(weth), + amount: amountDeposit, + msgValue: 0, + zapData: getDepositZapData(address(weth)) + }) + ); + } + + function test_wrapDepositWETH_exactAmounts() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, AMOUNT); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); + } + + /// @notice Extra funds should have no effect on "exact amount" instructions. + function test_wrapDepositWETH_exactAmounts_extraFunds() public withExtraFunds { + test_wrapDepositWETH_exactAmounts(); + } + + function test_wrapDepositWETH_exactAmounts_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, AMOUNT); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_wrapDepositWETH_exactAmounts_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, AMOUNT); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_wrapDepositWETH_exactAmounts_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, AMOUNT); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_wrapDepositWETH_exactAmount0() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); + } + + /// @notice Extra funds should be used with the final "full balance" instruction. + function test_wrapDepositWETH_exactAmount0_extraFunds() public withExtraFunds { + uint256 amountDeposit = AMOUNT + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, address(weth)), amountDeposit); + } + + function test_wrapDepositWETH_exactAmount0_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_wrapDepositWETH_exactAmount0_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_wrapDepositWETH_exactAmount0_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_wrapDepositWETH_exactAmount0_revert_msgValueBelowExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(AMOUNT, FULL_BALANCE); + checkRevertsMsgValueBelowExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT - 1}); + } + + function test_wrapDepositWETH_exactAmount1() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); + } + + /// @notice Should succeed with extra funds if no balance checks are performed. + /// Last action is "use exact amount", so extra funds have no effect. + function test_wrapDepositWETH_exactAmount1_extraFunds_revertWithBalanceChecks() public virtual withExtraFunds { + test_wrapDepositWETH_exactAmount1(); + } + + function test_wrapDepositWETH_exactAmount1_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_wrapDepositWETH_exactAmount1_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_wrapDepositWETH_exactAmount1_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_wrapDepositWETH_exactAmount1_revert_msgValueBelowExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, AMOUNT); + checkRevertsMsgValueBelowExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT - 1}); + } + + function test_wrapDepositWETH_fullBalances() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); + } + + /// @notice Extra funds should be used with both "full balance" instructions. + function test_wrapDepositWETH_fullBalances_extraFunds() public withExtraFunds { + uint256 amountDeposit = AMOUNT + 2 * EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: AMOUNT, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, address(weth)), amountDeposit); + } + + function test_wrapDepositWETH_fullBalances_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_wrapDepositWETH_fullBalances_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_wrapDepositWETH_fullBalances_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertsMsgValueAboveExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_wrapDepositWETH_fullBalances_revert_msgValueBelowExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getWrapDepositWETHSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertsMsgValueBelowExpectedWithNative({steps: steps, lastStepAmountIn: AMOUNT - 1}); + } + + // ══════════════════════════════════════════ UNWRAP & DEPOSIT NATIVE ══════════════════════════════════════════════ + + function getUnwrapDepositNativeSteps( + uint256 amountUnwrap, + uint256 amountDeposit + ) + public + view + returns (ISynapseIntentRouter.StepParams[] memory) + { + return toArray( + // WETH -> ETH + ISynapseIntentRouter.StepParams({ + token: address(weth), + amount: amountUnwrap, + msgValue: 0, + zapData: getUnwrapZapData(address(0)) + }), + // Deposit ETH + ISynapseIntentRouter.StepParams({ + token: NATIVE_GAS_TOKEN, + amount: amountDeposit, + msgValue: 0, + zapData: getDepositZapData(NATIVE_GAS_TOKEN) + }) + ); + } + + function test_unwrapDepositNative_exactAmounts() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, AMOUNT); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Extra funds should have no effect on "exact amount" instructions. + function test_unwrapDepositNative_exactAmounts_extraFunds() public withExtraFunds { + test_unwrapDepositNative_exactAmounts(); + } + + function test_unwrapDepositNative_exactAmounts_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, AMOUNT); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_unwrapDepositNative_exactAmounts_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, AMOUNT); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_unwrapDepositNative_exactAmounts_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, AMOUNT); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_unwrapDepositNative_exactAmount0() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Extra funds should be used with the final "full balance" instruction. + function test_unwrapDepositNative_exactAmount0_extraFunds() public withExtraFunds { + uint256 amountDeposit = AMOUNT + EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), amountDeposit); + } + + function test_unwrapDepositNative_exactAmount0_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_unwrapDepositNative_exactAmount0_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_unwrapDepositNative_exactAmount0_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(AMOUNT, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_unwrapDepositNative_exactAmount1() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, AMOUNT); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Should succeed with extra funds if no balance checks are performed. + /// Last action is "use exact amount", so extra funds have no effect. + function test_unwrapDepositNative_exactAmount1_extraFunds_revertWithBalanceChecks() public virtual withExtraFunds { + test_unwrapDepositNative_exactAmount1(); + } + + function test_unwrapDepositNative_exactAmount1_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, AMOUNT); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_unwrapDepositNative_exactAmount1_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, AMOUNT); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_unwrapDepositNative_exactAmount1_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, AMOUNT); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + function test_unwrapDepositNative_fullBalances() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: AMOUNT, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), AMOUNT); + } + + /// @notice Extra funds should be used with both "full balance" instructions. + function test_unwrapDepositNative_fullBalances_extraFunds() public withExtraFunds { + uint256 amountDeposit = AMOUNT + 2 * EXTRA_FUNDS; + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, FULL_BALANCE); + completeUserIntent({ + msgValue: 0, + amountIn: AMOUNT, + minLastStepAmountIn: amountDeposit, + deadline: block.timestamp, + steps: steps + }); + // Check that the vault registered the deposit with the extra funds + assertEq(vault.balanceOf(user, NATIVE_GAS_TOKEN), amountDeposit); + } + + function test_unwrapDepositNative_fullBalances_revert_deadlineExceeded() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertDeadlineExceeded({msgValue: AMOUNT, lastStepAmountIn: AMOUNT, steps: steps}); + } + + function test_unwrapDepositNative_fullBalances_revert_lastStepAmountInsufficient() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertAmountInsufficient({msgValue: AMOUNT, lastStepAmountIn: AMOUNT + 1, steps: steps}); + } + + function test_unwrapDepositNative_fullBalances_revert_msgValueAboveExpected() public { + ISynapseIntentRouter.StepParams[] memory steps = getUnwrapDepositNativeSteps(FULL_BALANCE, FULL_BALANCE); + checkRevertMsgValueAboveExpectedWithERC20({steps: steps, lastStepAmountIn: AMOUNT}); + } + + // ═══════════════════════════════════════════════════ UTILS ═══════════════════════════════════════════════════════ + + function toArray(ISynapseIntentRouter.StepParams memory a) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory arr) + { + arr = new ISynapseIntentRouter.StepParams[](1); + arr[0] = a; + return arr; + } + + function toArray( + ISynapseIntentRouter.StepParams memory a, + ISynapseIntentRouter.StepParams memory b + ) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory arr) + { + arr = new ISynapseIntentRouter.StepParams[](2); + arr[0] = a; + arr[1] = b; + return arr; + } + + function toArray( + ISynapseIntentRouter.StepParams memory a, + ISynapseIntentRouter.StepParams memory b, + ISynapseIntentRouter.StepParams memory c + ) + internal + pure + returns (ISynapseIntentRouter.StepParams[] memory arr) + { + arr = new ISynapseIntentRouter.StepParams[](3); + arr[0] = a; + arr[1] = b; + arr[2] = c; + return arr; + } +} diff --git a/packages/contracts-rfq/test/zaps/TokenZapV1.GasBench.t.sol b/packages/contracts-rfq/test/zaps/TokenZapV1.GasBench.t.sol index 5352a5e4fb..9c9798c997 100644 --- a/packages/contracts-rfq/test/zaps/TokenZapV1.GasBench.t.sol +++ b/packages/contracts-rfq/test/zaps/TokenZapV1.GasBench.t.sol @@ -42,7 +42,7 @@ contract TokenZapV1GasBenchmarkTest is Test { function getZapData(bytes memory originalPayload) public view returns (bytes memory) { // Amount is the second argument of the deposit function. - return tokenZap.encodeZapData(address(vault), originalPayload, 4 + 32); + return tokenZap.encodeZapData(address(vault), originalPayload, 4 + 32, address(0), address(0)); } function test_deposit_erc20() public { diff --git a/packages/contracts-rfq/test/zaps/TokenZapV1.t.sol b/packages/contracts-rfq/test/zaps/TokenZapV1.t.sol index e081831372..7f7574c8c1 100644 --- a/packages/contracts-rfq/test/zaps/TokenZapV1.t.sol +++ b/packages/contracts-rfq/test/zaps/TokenZapV1.t.sol @@ -54,13 +54,13 @@ contract TokenZapV1Test is Test { return abi.encodeCall(vault.depositWithRevert, ()); } - function getZapData(bytes memory originalPayload) public view returns (bytes memory) { + function getZapDataDeposit(bytes memory originalPayload) public view returns (bytes memory) { // Amount is the third argument of the deposit function - return tokenZap.encodeZapData(address(vault), originalPayload, 4 + 32 * 2); + return tokenZap.encodeZapData(address(vault), originalPayload, 4 + 32 * 2, address(0), address(0)); } - function getZapDataNoAmount(bytes memory originalPayload) public view returns (bytes memory) { - return tokenZap.encodeZapData(address(vault), originalPayload, originalPayload.length); + function getZapDataDepositNoAmount(bytes memory originalPayload) public view returns (bytes memory) { + return tokenZap.encodeZapData(address(vault), originalPayload, originalPayload.length, address(0), address(0)); } function checkERC20HappyPath(bytes memory zapData, uint256 msgValue) public { @@ -73,25 +73,25 @@ contract TokenZapV1Test is Test { } function test_zap_erc20_placeholderZero() public { - bytes memory zapData = getZapData(getVaultPayload(address(erc20), 0)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(address(erc20), 0)); checkERC20HappyPath(zapData, 0); } function test_zap_erc20_placeholderNonZero() public { // Use the approximate amount of tokens as placeholder - bytes memory zapData = getZapData(getVaultPayload(address(erc20), 1 ether)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(address(erc20), 1 ether)); checkERC20HappyPath(zapData, 0); } function test_zap_erc20_placeholderZero_withMsgValue() public { - bytes memory zapData = getZapData(getVaultPayload(address(erc20), 0)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(address(erc20), 0)); checkERC20HappyPath(zapData, 123_456); // Should forward the msg.value to the vault assertEq(address(vault).balance, 123_456); } function test_zap_erc20_placeholderNonZero_withMsgValue() public { - bytes memory zapData = getZapData(getVaultPayload(address(erc20), 1 ether)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(address(erc20), 1 ether)); checkERC20HappyPath(zapData, 123_456); // Should forward the msg.value to the vault assertEq(address(vault).balance, 123_456); @@ -119,18 +119,18 @@ contract TokenZapV1Test is Test { } function test_zap_native_placeholderZero() public { - bytes memory zapData = getZapData(getVaultPayload(nativeGasToken, 0)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(nativeGasToken, 0)); checkNativeHappyPath(zapData); } function test_zap_native_placeholderNonZero() public { // Use the approximate amount of tokens as placeholder - bytes memory zapData = getZapData(getVaultPayload(nativeGasToken, 1 ether)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(nativeGasToken, 1 ether)); checkNativeHappyPath(zapData); } function test_zap_native_noAmount() public { - bytes memory zapData = getZapDataNoAmount(getVaultPayloadNoAmount()); + bytes memory zapData = getZapDataDepositNoAmount(getVaultPayloadNoAmount()); checkNativeHappyPath(zapData); } @@ -157,7 +157,7 @@ contract TokenZapV1Test is Test { /// @notice Should be able to use amount lower than msg.value. function test_zap_native_msgValueHigherThanAmount() public { - bytes memory zapData = getZapData(getVaultPayload(nativeGasToken, 1 ether)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(nativeGasToken, 1 ether)); bytes4 returnValue = tokenZap.zap{value: AMOUNT + 1 wei}(nativeGasToken, AMOUNT, zapData); assertEq(returnValue, tokenZap.zap.selector); // Check that the vault registered the deposit @@ -169,7 +169,7 @@ contract TokenZapV1Test is Test { /// @notice Should be able to utilize both msg.value and existing native balance. function test_zap_native_msgValueLowerThanAmount_extraNative() public { deal(address(tokenZap), 1337); - bytes memory zapData = getZapData(getVaultPayload(nativeGasToken, 1 ether)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(nativeGasToken, 1 ether)); bytes4 returnValue = tokenZap.zap{value: AMOUNT - 1337}(nativeGasToken, AMOUNT, zapData); assertEq(returnValue, tokenZap.zap.selector); // Check that the vault registered the deposit @@ -178,16 +178,52 @@ contract TokenZapV1Test is Test { // ═════════════════════════════════════════════════ MULTIHOPS ═════════════════════════════════════════════════════ - function getZapDataWithdraw(uint256 amount) public view returns (bytes memory) { - return tokenZap.encodeZapData(address(weth), abi.encodeCall(WETHMock.withdraw, (amount)), 4); + function getZapDataUnwrap(uint256 amount) public view returns (bytes memory) { + return tokenZap.encodeZapData( + address(weth), abi.encodeCall(WETHMock.withdraw, (amount)), 4, nativeGasToken, address(0) + ); + } + + function getZapDataUnwrapAndForward( + uint256 amount, + address finalToken, + address forwardTo + ) + public + view + returns (bytes memory) + { + return tokenZap.encodeZapData({ + target: address(weth), + payload: abi.encodeCall(WETHMock.withdraw, (amount)), + amountPosition: 4, + finalToken: finalToken, + forwardTo: forwardTo + }); + } + + function getZapDataWrap() public view returns (bytes memory) { + return tokenZap.encodeZapData( + address(weth), abi.encodeCall(WETHMock.deposit, ()), type(uint256).max, address(0), address(0) + ); + } + + function getZapDataWrapAndForward(address finalToken, address forwardTo) public view returns (bytes memory) { + return tokenZap.encodeZapData({ + target: address(weth), + payload: abi.encodeCall(WETHMock.deposit, ()), + amountPosition: type(uint256).max, + finalToken: finalToken, + forwardTo: forwardTo + }); } - function test_zap_withdraw_depositNative_placeholderZero() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataDeposit = getZapDataNoAmount(getVaultPayloadNoAmount()); + function test_zap_unwrap_depositNative_placeholderZero() public { + bytes memory zapDataUnwrap = getZapDataUnwrap(0); + bytes memory zapDataDeposit = getZapDataDepositNoAmount(getVaultPayloadNoAmount()); weth.transfer(address(tokenZap), AMOUNT); // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrap); assertEq(returnValue, tokenZap.zap.selector); returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataDeposit); assertEq(returnValue, tokenZap.zap.selector); @@ -195,13 +231,13 @@ contract TokenZapV1Test is Test { assertEq(vault.balanceOf(user, nativeGasToken), AMOUNT); } - function test_zap_withdraw_depositNative_placeholderNonZero() public { + function test_zap_unwrap_depositNative_placeholderNonZero() public { // Use the approximate amount of tokens as placeholder - bytes memory zapDataWithdraw = getZapDataWithdraw(1 ether); - bytes memory zapDataDeposit = getZapDataNoAmount(getVaultPayloadNoAmount()); + bytes memory zapDataUnwrap = getZapDataUnwrap(1 ether); + bytes memory zapDataDeposit = getZapDataDepositNoAmount(getVaultPayloadNoAmount()); weth.transfer(address(tokenZap), AMOUNT); // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrap); assertEq(returnValue, tokenZap.zap.selector); returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataDeposit); assertEq(returnValue, tokenZap.zap.selector); @@ -209,142 +245,218 @@ contract TokenZapV1Test is Test { assertEq(vault.balanceOf(user, nativeGasToken), AMOUNT); } - function test_zap_withdraw_depositNative_placeholderZero_extraTokens() public { + function test_zap_unwrap_depositNative_placeholderZero_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_depositNative_placeholderZero(); - } - - function test_zap_withdraw_depositNative_placeholderZero_extraNative() public { - // Transfer some extra native tokens to the zap contract deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_depositNative_placeholderZero(); + test_zap_unwrap_depositNative_placeholderZero(); } - function test_zap_withdraw_depositNative_placeholderNonZero_extraTokens() public { + function test_zap_unwrap_depositNative_placeholderNonZero_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_depositNative_placeholderNonZero(); - } - - function test_zap_withdraw_depositNative_placeholderNonZero_extraNative() public { - // Transfer some extra native tokens to the zap contract deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_depositNative_placeholderNonZero(); + test_zap_unwrap_depositNative_placeholderNonZero(); } - function test_zap_withdraw_transferNativeEOA_placeholderZero() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataTransfer = tokenZap.encodeZapData({target: user, payload: "", amountPosition: 0}); + function test_zap_unwrapForwardNativeEOA_placeholderZero() public { + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(0, nativeGasToken, user); weth.transfer(address(tokenZap), AMOUNT); - // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); - assertEq(returnValue, tokenZap.zap.selector); - returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataTransfer); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); assertEq(returnValue, tokenZap.zap.selector); // Check that the user received the native tokens assertEq(user.balance, AMOUNT); } - function test_zap_withdraw_transferNativeEOA_placeholderNonZero() public { + function test_zap_unwrapForwardNativeEOA_placeholderNonZero() public { // Use the approximate amount of tokens as placeholder - bytes memory zapDataWithdraw = getZapDataWithdraw(1 ether); - bytes memory zapDataTransfer = tokenZap.encodeZapData({target: user, payload: "", amountPosition: 0}); + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(1 ether, nativeGasToken, user); weth.transfer(address(tokenZap), AMOUNT); - // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); - assertEq(returnValue, tokenZap.zap.selector); - returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataTransfer); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); assertEq(returnValue, tokenZap.zap.selector); // Check that the user received the native tokens assertEq(user.balance, AMOUNT); } - function test_zap_withdraw_transferNativeEOA_placeholderZero_extraTokens() public { + function test_zap_unwrapForwardNativeEOA_placeholderZero_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_transferNativeEOA_placeholderZero(); + deal(address(tokenZap), AMOUNT); + // Extra funds will be used when forwarding the proceeds + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(0, nativeGasToken, user); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the user received the native tokens with extra funds + assertEq(weth.balanceOf(address(tokenZap)), AMOUNT); + assertEq(user.balance, 2 * AMOUNT); } - function test_zap_withdraw_transferNativeEOA_placeholderZero_extraNative() public { + function test_zap_unwrapForwardNativeEOA_placeholderNonZero_extraFunds() public { // Transfer some extra native tokens to the zap contract + weth.transfer(address(tokenZap), AMOUNT); deal(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_transferNativeEOA_placeholderZero(); + // Extra funds will be used when forwarding the proceeds + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(1 ether, nativeGasToken, user); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the user received the native tokens with extra funds + assertEq(weth.balanceOf(address(tokenZap)), AMOUNT); + assertEq(user.balance, 2 * AMOUNT); + } + + function test_zap_unwrapForwardNativeContract_placeholderZero() public { + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(0, nativeGasToken, payableMock); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the contract received the native tokens + assertEq(payableMock.balance, AMOUNT); } - function test_zap_withdraw_transferNativeEOA_placeholderNonZero_extraTokens() public { + function test_zap_unwrapForwardNativeContract_placeholderNonZero() public { + // Use the approximate amount of tokens as placeholder + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(1 ether, nativeGasToken, payableMock); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the contract received the native tokens + assertEq(payableMock.balance, AMOUNT); + } + + function test_zap_unwrapForwardNativeContract_placeholderZero_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_transferNativeEOA_placeholderNonZero(); + deal(address(tokenZap), AMOUNT); + // Extra funds will be used when forwarding the proceeds + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(0, nativeGasToken, payableMock); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the contract received the native tokens with extra funds + assertEq(weth.balanceOf(address(tokenZap)), AMOUNT); + assertEq(payableMock.balance, 2 * AMOUNT); } - function test_zap_withdraw_transferNativeEOA_placeholderNonZero_extraNative() public { + function test_zap_unwrapForwardNativeContract_placeholderNonZero_extraFunds() public { // Transfer some extra native tokens to the zap contract + weth.transfer(address(tokenZap), AMOUNT); deal(address(tokenZap), AMOUNT); - // Should not affect the zap - test_zap_withdraw_transferNativeEOA_placeholderNonZero(); + // Extra funds will be used when forwarding the proceeds + bytes memory zapDataUnwrapAndForward = getZapDataUnwrapAndForward(1 ether, nativeGasToken, payableMock); + weth.transfer(address(tokenZap), AMOUNT); + bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataUnwrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the contract received the native tokens with extra funds + assertEq(weth.balanceOf(address(tokenZap)), AMOUNT); + assertEq(payableMock.balance, 2 * AMOUNT); } - function test_zap_withdraw_transferNativeContract_placeholderZero() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataTransfer = tokenZap.encodeZapData({target: payableMock, payload: "", amountPosition: 0}); - weth.transfer(address(tokenZap), AMOUNT); + function test_zap_wrap_depositWETH_placeholderZero() public { + bytes memory zapDataWrap = getZapDataWrap(); + bytes memory zapDataDeposit = getZapDataDeposit(getVaultPayload(address(weth), 0)); // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapDataWrap); assertEq(returnValue, tokenZap.zap.selector); - returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataTransfer); + returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataDeposit); assertEq(returnValue, tokenZap.zap.selector); - // Check that the contract received the native tokens - assertEq(payableMock.balance, AMOUNT); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); } - function test_zap_withdraw_transferNativeContract_placeholderNonZero() public { + function test_zap_wrap_depositWETH_placeholderNonZero() public { // Use the approximate amount of tokens as placeholder - bytes memory zapDataWithdraw = getZapDataWithdraw(1 ether); - bytes memory zapDataTransfer = tokenZap.encodeZapData({target: payableMock, payload: "", amountPosition: 0}); - weth.transfer(address(tokenZap), AMOUNT); + bytes memory zapDataWrap = getZapDataWrap(); + bytes memory zapDataDeposit = getZapDataDeposit(getVaultPayload(address(weth), 1 ether)); // Do two Zaps in a row - bytes4 returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapDataWrap); assertEq(returnValue, tokenZap.zap.selector); - returnValue = tokenZap.zap(nativeGasToken, AMOUNT, zapDataTransfer); + returnValue = tokenZap.zap(address(weth), AMOUNT, zapDataDeposit); assertEq(returnValue, tokenZap.zap.selector); - // Check that the contract received the native tokens - assertEq(payableMock.balance, AMOUNT); + // Check that the vault registered the deposit + assertEq(vault.balanceOf(user, address(weth)), AMOUNT); } - function test_zap_withdraw_transferNativeContract_placeholderZero_extraTokens() public { + function test_zap_wrap_depositWETH_placeholderZero_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); + deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_transferNativeContract_placeholderZero(); + test_zap_wrap_depositWETH_placeholderZero(); } - function test_zap_withdraw_transferNativeContract_placeholderZero_extraNative() public { + function test_zap_wrap_depositWETH_placeholderNonZero_extraFunds() public { // Transfer some extra native tokens to the zap contract + weth.transfer(address(tokenZap), AMOUNT); deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_transferNativeContract_placeholderZero(); + test_zap_wrap_depositWETH_placeholderNonZero(); + } + + function test_zap_wrapForward() public { + bytes memory zapDataWrapAndForward = getZapDataWrapAndForward(address(weth), user); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapDataWrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the user received WETH + assertEq(weth.balanceOf(user), AMOUNT); } - function test_zap_withdraw_transferNativeContract_placeholderNonZero_extraTokens() public { + function test_zap_wrapForward_extraFunds() public { // Transfer some extra tokens to the zap contract weth.transfer(address(tokenZap), AMOUNT); + deal(address(tokenZap), AMOUNT); + // Extra funds will be used when forwarding the proceeds + bytes memory zapDataWrapAndForward = getZapDataWrapAndForward(address(weth), user); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapDataWrapAndForward); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the user received WETH with extra funds + assertEq(address(tokenZap).balance, AMOUNT); + assertEq(weth.balanceOf(user), 2 * AMOUNT); + } + + function getZapDataTransferNative(address target) public view returns (bytes memory) { + return tokenZap.encodeZapData({ + target: target, + payload: "", + amountPosition: type(uint256).max, + finalToken: address(0), + forwardTo: address(0) + }); + } + + function test_zap_transferNativeEOA() public { + bytes memory zapData = getZapDataTransferNative(user); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the user received the native tokens + assertEq(user.balance, AMOUNT); + } + + function test_zap_transferNativeContract() public { + bytes memory zapData = getZapDataTransferNative(payableMock); + bytes4 returnValue = tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); + assertEq(returnValue, tokenZap.zap.selector); + // Check that the contract received the native tokens + assertEq(payableMock.balance, AMOUNT); + } + + function test_zap_transferNativeEOA_extraFunds() public { + // Transfer some extra tokens to the zap contract + weth.transfer(address(tokenZap), AMOUNT); + deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_transferNativeContract_placeholderNonZero(); + test_zap_transferNativeEOA(); } - function test_zap_withdraw_transferNativeContract_placeholderNonZero_extraNative() public { + function test_zap_transferNativeContract_extraFunds() public { // Transfer some extra native tokens to the zap contract + weth.transfer(address(tokenZap), AMOUNT); deal(address(tokenZap), AMOUNT); // Should not affect the zap - test_zap_withdraw_transferNativeContract_placeholderNonZero(); + test_zap_transferNativeContract(); } // ═════════════════════════════════════════════════ ENCODING ══════════════════════════════════════════════════════ @@ -353,7 +465,7 @@ contract TokenZapV1Test is Test { bytes memory originalPayload = getVaultPayload(token, placeholderAmount); bytes memory expectedPayload = getVaultPayload(token, amount); - bytes memory zapData = getZapData(originalPayload); + bytes memory zapData = getZapDataDeposit(originalPayload); (address target, bytes memory payload) = tokenZap.decodeZapData(zapData, amount); assertEq(target, address(vault)); @@ -365,7 +477,7 @@ contract TokenZapV1Test is Test { // Any value >= payload.length could be used to signal that the amount is not an argument of the target function amountPosition = bound(amountPosition, payload.length, type(uint256).max); - bytes memory zapData = tokenZap.encodeZapData(address(vault), payload, amountPosition); + bytes memory zapData = tokenZap.encodeZapData(address(vault), payload, amountPosition, address(0), address(0)); (address target, bytes memory decodedPayload) = tokenZap.decodeZapData(zapData, 0); assertEq(target, address(vault)); assertEq(decodedPayload, payload); @@ -375,11 +487,25 @@ contract TokenZapV1Test is Test { function getZeroTargetZapData(bytes memory payload, uint16 amountPosition) public pure returns (bytes memory) { // Encode manually as the library checks for zero address - return abi.encodePacked(ZapDataV1.VERSION, amountPosition, address(0), payload); + return abi.encodePacked(ZapDataV1.VERSION, amountPosition, address(0), address(0), address(0), payload); + } + + function getZeroFinalTokenZapData( + bytes memory payload, + uint16 amountPosition, + address target, + address forwardTo + ) + public + pure + returns (bytes memory) + { + // Encode manually as the library checks for zero address + return abi.encodePacked(ZapDataV1.VERSION, amountPosition, address(0), forwardTo, target, payload); } function test_zap_erc20_revert_notEnoughTokens() public { - bytes memory zapData = getZapData(getVaultPayload(address(erc20), 0)); + bytes memory zapData = getZapDataDeposit(getVaultPayload(address(erc20), 0)); // Transfer tokens to the zap contract first, but not enough erc20.transfer(address(tokenZap), AMOUNT - 1); vm.expectRevert(); @@ -387,7 +513,7 @@ contract TokenZapV1Test is Test { } function test_zap_erc20_revert_targetReverted() public { - bytes memory zapData = getZapData(getVaultPayloadWithRevert()); + bytes memory zapData = getZapDataDeposit(getVaultPayloadWithRevert()); // Transfer tokens to the zap contract first erc20.transfer(address(tokenZap), AMOUNT); vm.expectRevert(VaultManyArguments.VaultManyArguments__SomeError.selector); @@ -415,7 +541,9 @@ contract TokenZapV1Test is Test { bytes memory zapData = tokenZap.encodeZapData({ target: user, payload: getVaultPayload(address(erc20), 0), - amountPosition: 4 + 32 * 2 + amountPosition: 4 + 32 * 2, + finalToken: address(0), + forwardTo: address(0) }); // Transfer tokens to the zap contract first erc20.transfer(address(tokenZap), AMOUNT); @@ -424,7 +552,13 @@ contract TokenZapV1Test is Test { } function test_zap_erc20_revert_targetEOA_emptyPayload() public { - bytes memory zapData = tokenZap.encodeZapData({target: user, payload: "", amountPosition: 0}); + bytes memory zapData = tokenZap.encodeZapData({ + target: user, + payload: "", + amountPosition: 0, + finalToken: address(0), + forwardTo: address(0) + }); // Transfer tokens to the zap contract first erc20.transfer(address(tokenZap), AMOUNT); vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, user)); @@ -432,67 +566,117 @@ contract TokenZapV1Test is Test { } function test_zap_native_revert_targetReverted() public { - bytes memory zapData = getZapData(getVaultPayloadWithRevert()); + bytes memory zapData = getZapDataDeposit(getVaultPayloadWithRevert()); vm.expectRevert(VaultManyArguments.VaultManyArguments__SomeError.selector); tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); } function test_zap_native_revert_msgValueLowerThanExpected() public { bytes memory originalPayload = getVaultPayload(nativeGasToken, 0); - bytes memory zapData = getZapData(originalPayload); + bytes memory zapData = getZapDataDeposit(originalPayload); vm.expectRevert(abi.encodeWithSelector(Address.AddressInsufficientBalance.selector, tokenZap)); tokenZap.zap{value: 1 ether - 1 wei}(nativeGasToken, 1 ether, zapData); } - function test_zap_withdraw_transferNative_revert_targetReverted() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataTransfer = tokenZap.encodeZapData({target: nonPayableMock, payload: "", amountPosition: 0}); + function test_zap_unwrapForwardNative_revert_targetReverted() public { + bytes memory zapDataWithdrawAndForward = getZapDataUnwrapAndForward(0, nativeGasToken, nonPayableMock); weth.transfer(address(tokenZap), AMOUNT); - tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); vm.expectRevert(Address.FailedInnerCall.selector); - tokenZap.zap(address(weth), AMOUNT, zapDataTransfer); + tokenZap.zap(address(weth), AMOUNT, zapDataWithdrawAndForward); } - function test_zap_withdraw_transferNative_revert_targetZeroAddress_emptyPayload() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataTransfer = getZeroTargetZapData({payload: "", amountPosition: 0}); - weth.transfer(address(tokenZap), AMOUNT); - tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + function test_zap_transferNative_revert_targetReverted() public { + bytes memory zapData = getZapDataTransferNative(nonPayableMock); + vm.expectRevert(Address.FailedInnerCall.selector); + tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); + } + + function test_zap_native_revert_targetZeroAddress_emptyPayload() public { + bytes memory zapData = getZeroTargetZapData({payload: "", amountPosition: 0}); vm.expectRevert(TokenZapV1.TokenZapV1__TargetZeroAddress.selector); - tokenZap.zap(address(weth), AMOUNT, zapDataTransfer); + tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); } - function test_zap_withdraw_transferNative_revert_targetZeroAddress_nonEmptyPayload() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory payload = getVaultPayloadNoAmount(); - bytes memory zapDataTransfer = getZeroTargetZapData({payload: payload, amountPosition: uint16(payload.length)}); - weth.transfer(address(tokenZap), AMOUNT); - tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + function test_zap_native_revert_targetZeroAddress_nonEmptyPayload() public { + bytes memory zapData = getZeroTargetZapData({payload: getVaultPayloadNoAmount(), amountPosition: 0}); vm.expectRevert(TokenZapV1.TokenZapV1__TargetZeroAddress.selector); - tokenZap.zap(address(weth), AMOUNT, zapDataTransfer); + tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); + } + + function test_zap_wrapForward_revert_zeroFinalToken() public { + bytes memory zapData = getZeroFinalTokenZapData({ + payload: abi.encodeCall(WETHMock.deposit, ()), + amountPosition: type(uint16).max, + target: address(weth), + forwardTo: user + }); + vm.expectRevert(TokenZapV1.TokenZapV1__TokenZeroAddress.selector); + tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); + } + + function test_zap_wrapForward_revert_incorrectFinalToken() public { + bytes memory zapData = getZapDataWrapAndForward(nativeGasToken, user); + vm.expectRevert(TokenZapV1.TokenZapV1__FinalTokenBalanceZero.selector); + tokenZap.zap{value: AMOUNT}(nativeGasToken, AMOUNT, zapData); } - function test_zap_withdraw_transferNative_revert_targetEOA_nonEmptyPayload() public { - bytes memory zapDataWithdraw = getZapDataWithdraw(0); - bytes memory zapDataTransfer = - tokenZap.encodeZapData({target: user, payload: getVaultPayloadNoAmount(), amountPosition: 0}); + function test_zap_unwrapForward_revert_zeroFinalToken() public { + bytes memory zapData = getZeroFinalTokenZapData({ + payload: abi.encodeCall(WETHMock.withdraw, (0)), + amountPosition: 4, + target: address(weth), + forwardTo: user + }); + weth.transfer(address(tokenZap), AMOUNT); + vm.expectRevert(TokenZapV1.TokenZapV1__TokenZeroAddress.selector); + tokenZap.zap(address(weth), AMOUNT, zapData); + } + + function test_zap_unwrapForward_revert_incorrectFinalToken() public { + bytes memory zapData = getZapDataUnwrapAndForward(0, address(weth), user); + weth.transfer(address(tokenZap), AMOUNT); + vm.expectRevert(TokenZapV1.TokenZapV1__FinalTokenBalanceZero.selector); + tokenZap.zap(address(weth), AMOUNT, zapData); + } + + function test_zap_unwrap_transferNative_revert_targetEOA_nonEmptyPayload() public { + bytes memory zapDataUnwrap = getZapDataUnwrap(0); + bytes memory zapDataTransfer = tokenZap.encodeZapData({ + target: user, + payload: getVaultPayloadNoAmount(), + amountPosition: 0, + finalToken: address(0), + forwardTo: address(0) + }); weth.transfer(address(tokenZap), AMOUNT); - tokenZap.zap(address(weth), AMOUNT, zapDataWithdraw); + tokenZap.zap(address(weth), AMOUNT, zapDataUnwrap); vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, user)); tokenZap.zap(address(weth), AMOUNT, zapDataTransfer); } + function test_zap_revert_tokenZeroAddress() public { + bytes memory zapData = getZapDataDepositNoAmount(getVaultPayloadNoAmount()); + vm.expectRevert(TokenZapV1.TokenZapV1__TokenZeroAddress.selector); + tokenZap.zap(address(0), AMOUNT, zapData); + } + function test_encodeZapData_revert_payloadLengthAboveMax() public { bytes memory tooLongPayload = new bytes(2 ** 16); vm.expectRevert(TokenZapV1.TokenZapV1__PayloadLengthAboveMax.selector); - tokenZap.encodeZapData(address(vault), tooLongPayload, 0); + tokenZap.encodeZapData(address(vault), tooLongPayload, 0, address(0), address(0)); } function test_encodeZapData_revert_targetZeroAddress() public { bytes memory payload = getVaultPayloadNoAmount(); vm.expectRevert(ZapDataV1.ZapDataV1__TargetZeroAddress.selector); - tokenZap.encodeZapData(address(0), payload, payload.length); + tokenZap.encodeZapData(address(0), payload, payload.length, address(0), address(0)); + } + + function test_encodeZapData_revert_finalTokenZeroAddressWithForwardTo() public { + bytes memory payload = getVaultPayloadNoAmount(); + vm.expectRevert(TokenZapV1.TokenZapV1__TokenZeroAddress.selector); + tokenZap.encodeZapData(address(vault), payload, payload.length, address(0), user); } } diff --git a/packages/synapse-interface/CHANGELOG.md b/packages/synapse-interface/CHANGELOG.md index d68d4a38b3..5e9ee4b000 100644 --- a/packages/synapse-interface/CHANGELOG.md +++ b/packages/synapse-interface/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.40.24](https://github.com/synapsecns/sanguine/compare/@synapsecns/synapse-interface@0.40.23...@synapsecns/synapse-interface@0.40.24) (2024-12-12) + +**Note:** Version bump only for package @synapsecns/synapse-interface + + + + + ## [0.40.23](https://github.com/synapsecns/sanguine/compare/@synapsecns/synapse-interface@0.40.22...@synapsecns/synapse-interface@0.40.23) (2024-12-06) **Note:** Version bump only for package @synapsecns/synapse-interface diff --git a/packages/synapse-interface/assets/chains/hyperliquid.svg b/packages/synapse-interface/assets/chains/hyperliquid.svg new file mode 100644 index 0000000000..c9eb0bd097 --- /dev/null +++ b/packages/synapse-interface/assets/chains/hyperliquid.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/synapse-interface/components/HyperliquidDepositInfo.tsx b/packages/synapse-interface/components/HyperliquidDepositInfo.tsx new file mode 100644 index 0000000000..6d04bd8e1d --- /dev/null +++ b/packages/synapse-interface/components/HyperliquidDepositInfo.tsx @@ -0,0 +1,181 @@ +import { ARBITRUM } from '@/constants/chains/master' + +export const HyperliquidDepositInfo = ({ + fromChainId, + isOnArbitrum, + hasDepositedOnHyperliquid, +}) => { + if (fromChainId !== ARBITRUM.id) { + return ( +
+
+
+
Step 1
+
+ +
Bridge (Arbitrum)
+
+
+
+
Step 2
+
+ +
Deposit (Hyperliquid)
+
+
+
+
+ ) + } + + if (hasDepositedOnHyperliquid) { + return ( +
+
+
+
Step 1
+
+ +
Bridge (Arbitrum)
+
+
+
+
Step 2
+
+ +
Deposit (Hyperliquid)
+
+
+
+
+ ) + } + + if (fromChainId === ARBITRUM.id && isOnArbitrum) { + return ( +
+
+
+
Step 1
+
+ +
Bridge (Arbitrum)
+
+
+
+
Step 2
+
+ +
Deposit (Hyperliquid)
+
+
+
+
+ ) + } +} + +const CompletedCheckMarkCircle = () => { + return ( + + + + + + ) +} + +const GreenStep1Circle = () => { + return ( + + + + + ) +} + +const GreenStep2Circle = () => { + return ( + + + + + ) +} + +const GrayStep2Circle = () => { + return ( + + + + + ) +} diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx index 86717650ba..4c511d5951 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx @@ -42,7 +42,7 @@ const AnimatedLoadingCircle = () => { fill="none" className="absolute block -rotate-90" > - + - + HYPERLIQUID_MINIMUM_DEPOSIT + ? true + : false + : true + const { hasValidInput, hasValidQuote, @@ -78,7 +87,8 @@ export const BridgeTransactionButton = ({ (isConnected && !hasValidQuote) || (isConnected && !hasSufficientBalance) || (isConnected && isQuoteStale) || - (destinationAddress && !isAddress(destinationAddress)) + (destinationAddress && !isAddress(destinationAddress)) || + !hasHyperliquidMinDeposit let buttonProperties @@ -138,6 +148,11 @@ export const BridgeTransactionButton = ({ label: t('Amount must be greater than fee'), onClick: null, } + } else if (!hasHyperliquidMinDeposit) { + buttonProperties = { + label: `${HYPERLIQUID_MINIMUM_DEPOSIT} USDC Minimum`, + onClick: null, + } } else if ( bridgeQuote.bridgeModuleName !== null && !isLoading && diff --git a/packages/synapse-interface/components/StateManagedBridge/HyperliquidDepositButton.tsx b/packages/synapse-interface/components/StateManagedBridge/HyperliquidDepositButton.tsx new file mode 100644 index 0000000000..f089a15ca2 --- /dev/null +++ b/packages/synapse-interface/components/StateManagedBridge/HyperliquidDepositButton.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react' +import { useAccount, useAccountEffect, useSwitchChain } from 'wagmi' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useTranslations } from 'next-intl' +import { erc20Abi } from 'viem' +import { + simulateContract, + waitForTransactionReceipt, + writeContract, +} from '@wagmi/core' + +import { wagmiConfig } from '@/wagmiConfig' +import { useAppDispatch } from '@/store/hooks' +import { useWalletState } from '@/slices/wallet/hooks' +import { useBridgeState } from '@/slices/bridge/hooks' +import { TransactionButton } from '@/components/buttons/TransactionButton' +import { useBridgeValidations } from './hooks/useBridgeValidations' +import { USDC } from '@/constants/tokens/bridgeable' +import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' +import { stringToBigInt } from '@/utils/bigint/format' +import { fetchAndStoreSingleNetworkPortfolioBalances } from '@/slices/portfolio/hooks' +import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' +import { addPendingBridgeTransaction } from '@/slices/transactions/actions' +import { getUnixTimeMinutesFromNow } from '@/utils/time' +import { HYPERLIQUID_MINIMUM_DEPOSIT } from '@/constants' + +const HYPERLIQUID_DEPOSIT_ADDRESS = '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7' + +const deposit = async (amount: bigint) => { + try { + const { request } = await simulateContract(wagmiConfig, { + chainId: ARBITRUM.id, + address: USDC.addresses[ARBITRUM.id], + abi: erc20Abi, + functionName: 'transfer', + args: [HYPERLIQUID_DEPOSIT_ADDRESS, amount], + }) + + const hash = await writeContract(wagmiConfig, request) + + const txReceipt = await waitForTransactionReceipt(wagmiConfig, { hash }) + + return txReceipt + } catch (error) { + console.error('Confirmation error:', error) + throw error + } +} + +export const HyperliquidTransactionButton = ({ + isTyping, + hasDepositedOnHyperliquid, + setHasDepositedOnHyperliquid, +}) => { + const [isDepositing, setIsDepositing] = useState(false) + + const { address } = useAccount() + + const dispatch = useAppDispatch() + const { openConnectModal } = useConnectModal() + const [isConnected, setIsConnected] = useState(false) + + const { isConnected: isConnectedInit } = useAccount() + const { chains, switchChain } = useSwitchChain() + + const { fromToken, fromChainId, debouncedFromValue } = useBridgeState() + + const { isWalletPending } = useWalletState() + + const { hasValidInput, hasSufficientBalance, onSelectedChain } = + useBridgeValidations() + + const depositingMinimumAmount = + Number(debouncedFromValue) >= HYPERLIQUID_MINIMUM_DEPOSIT + + const t = useTranslations('Bridge') + + const amount = stringToBigInt( + debouncedFromValue, + fromToken?.decimals[fromChainId] + ) + + const handleDeposit = async () => { + setIsDepositing(true) + const currentTimestamp: number = getUnixTimeMinutesFromNow(0) + try { + const txReceipt = await deposit(amount) + + setHasDepositedOnHyperliquid(true) + segmentAnalyticsEvent(`[Hyperliquid Deposit]`, { + inputAmount: debouncedFromValue, + }) + dispatch( + fetchAndStoreSingleNetworkPortfolioBalances({ + address, + chainId: ARBITRUM.id, + }) + ) + dispatch( + addPendingBridgeTransaction({ + id: currentTimestamp, + originChain: ARBITRUM, + originToken: fromToken, + originValue: debouncedFromValue, + destinationChain: HYPERLIQUID, + destinationToken: undefined, + transactionHash: txReceipt.transactionHash, + timestamp: undefined, + isSubmitted: false, + estimatedTime: undefined, + bridgeModuleName: undefined, + destinationAddress: undefined, + routerAddress: undefined, + }) + ) + } catch (error) { + console.error('Deposit error:', error) + } finally { + setIsDepositing(false) + } + } + + useAccountEffect({ + onDisconnect() { + setIsConnected(false) + }, + }) + + useEffect(() => { + setIsConnected(isConnectedInit) + }, [isConnectedInit]) + + const isButtonDisabled = + isTyping || + isDepositing || + !depositingMinimumAmount || + isWalletPending || + !hasValidInput || + (isConnected && !hasSufficientBalance) + + let buttonProperties + + if (isConnected && !hasSufficientBalance) { + buttonProperties = { + label: t('Insufficient balance'), + onClick: null, + } + } else if (!depositingMinimumAmount) { + buttonProperties = { + label: `${HYPERLIQUID_MINIMUM_DEPOSIT} USDC Minimum`, + onClick: null, + } + } else if (!isConnected && hasValidInput) { + buttonProperties = { + label: t('Connect Wallet to Bridge'), + onClick: openConnectModal, + } + } else if (!onSelectedChain && hasValidInput) { + buttonProperties = { + label: t('Switch to {chainName}', { + chainName: chains.find((c) => c.id === fromChainId)?.name, + }), + onClick: () => switchChain({ chainId: fromChainId }), + pendingLabel: t('Switching chains'), + } + } else { + buttonProperties = { + onClick: handleDeposit, + label: t('Deposit {symbol}', { symbol: fromToken?.symbol }), + pendingLabel: t('Depositing'), + } + } + + return ( + buttonProperties && ( + <> +
+ +
+ + ) + ) +} diff --git a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx index 5af733cb91..6fed2c1b97 100644 --- a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx @@ -16,6 +16,7 @@ import { useWalletState } from '@/slices/wallet/hooks' import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks' import { useBridgeValidations } from './hooks/useBridgeValidations' import { useTranslations } from 'next-intl' +import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' interface OutputContainerProps { isQuoteStale: boolean @@ -26,6 +27,7 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => { const { bridgeQuote, isLoading } = useBridgeQuoteState() const { showDestinationAddress } = useBridgeDisplayState() const { hasValidInput, hasValidQuote } = useBridgeValidations() + const { debouncedFromValue, fromChainId, toChainId } = useBridgeState() const showValue = useMemo(() => { if (!hasValidInput) { @@ -43,7 +45,7 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => {
- {showDestinationAddress ? ( + {showDestinationAddress && toChainId !== HYPERLIQUID.id ? ( ) : null}
@@ -52,7 +54,11 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => { diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts index b3f31ab0f6..58084cf65b 100644 --- a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts +++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts @@ -8,6 +8,7 @@ import { BridgeQuoteState } from '@/slices/bridgeQuote/reducer' import { EMPTY_BRIDGE_QUOTE } from '@/constants/bridge' import { hasOnlyZeroes } from '@/utils/hasOnlyZeroes' import { useBridgeSelections } from './useBridgeSelections' +import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' export const useBridgeValidations = () => { const { chainId } = useAccount() @@ -66,7 +67,7 @@ export const useBridgeValidations = () => { debouncedFromValue, fromChainId, fromToken, - toChainId, + toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, toToken ) }, [debouncedFromValue, fromChainId, fromToken, toChainId, toToken]) diff --git a/packages/synapse-interface/components/_Transaction/_Transaction.tsx b/packages/synapse-interface/components/_Transaction/_Transaction.tsx index 1aea90352b..4ffb331305 100644 --- a/packages/synapse-interface/components/_Transaction/_Transaction.tsx +++ b/packages/synapse-interface/components/_Transaction/_Transaction.tsx @@ -20,6 +20,7 @@ import { RightArrow } from '@/components/icons/RightArrow' import { Address } from 'viem' import { useIsTxReverted } from './helpers/useIsTxReverted' import { useTxRefundStatus } from './helpers/useTxRefundStatus' +import { HYPERLIQUID } from '@/constants/chains/master' interface _TransactionProps { connectedAddress: string @@ -185,13 +186,15 @@ export const _Transaction = ({ iconUrl={originChain?.explorerImg} /> )} - {!isNull(destExplorerAddressLink) && !isTxReverted && ( - - )} + {destinationChain.id !== HYPERLIQUID.id && + !isNull(destExplorerAddressLink) && + !isTxReverted && ( + + )} ) diff --git a/packages/synapse-interface/components/layouts/LandingPageWrapper/index.tsx b/packages/synapse-interface/components/layouts/LandingPageWrapper/index.tsx index aef82543a5..b92be9138f 100644 --- a/packages/synapse-interface/components/layouts/LandingPageWrapper/index.tsx +++ b/packages/synapse-interface/components/layouts/LandingPageWrapper/index.tsx @@ -59,10 +59,10 @@ export function LandingPageWrapper({ children }: { children: any }) { style={TODO_REMOVE_wrapperStyle} > diff --git a/packages/synapse-interface/constants/chains/master.tsx b/packages/synapse-interface/constants/chains/master.tsx index ad44fa27a6..5c687828bb 100644 --- a/packages/synapse-interface/constants/chains/master.tsx +++ b/packages/synapse-interface/constants/chains/master.tsx @@ -11,6 +11,7 @@ import dfkImg from '@assets/chains/dfk.svg' import dogechainImg from '@assets/chains/dogechain.svg' import ethImg from '@assets/chains/ethereum.svg' import fantomImg from '@assets/chains/fantom.svg' +import hyperliquidImg from '@assets/chains/hyperliquid.svg' import harmonyImg from '@assets/chains/harmony.svg' import klaytnImg from '@assets/chains/klaytn.svg' import metisImg from '@assets/chains/metis.svg' @@ -615,5 +616,31 @@ export const WORLDCHAIN: Chain = { icon: ethImg, }, color: 'black', +} + +export const HYPERLIQUID: Chain = { + priorityRank: 99, + id: 998, // this is Hyperliquid Testnet from their docs + chainSymbol: 'HYPERLIQUID', + name: 'Hyperliquid', + chainImg: hyperliquidImg, + layer: 2, + blockTime: 300, + rpcUrls: { + primary: + 'https://arb-mainnet.g.alchemy.com/v2/7kjdkqKTh1zQ1mRYGi4nJJbxbyJXHkef', + fallback: 'https://arb1.arbitrum.io/rpc', + }, + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + address: zeroAddress, + icon: ethImg, + }, + explorerUrl: 'https://arbiscan.io', + explorerName: 'Arbiscan', + explorerImg: arbitrumExplorerImg, + color: 'gray', isNew: true, } diff --git a/packages/synapse-interface/constants/existingBridgeRoutes.ts b/packages/synapse-interface/constants/existingBridgeRoutes.ts index a1ec85b06f..3f1213399a 100644 --- a/packages/synapse-interface/constants/existingBridgeRoutes.ts +++ b/packages/synapse-interface/constants/existingBridgeRoutes.ts @@ -1,5 +1,8 @@ +import _ from 'lodash' + import { BRIDGE_MAP } from '@/constants/bridgeMap' import { flattenPausedTokens } from '@/utils/flattenPausedTokens' +import { HYPERLIQUID } from './chains/master' export type BridgeRoutes = Record @@ -46,9 +49,19 @@ const constructJSON = (swappableMap, exclusionList) => { return result } +const addUSDCHyperLiquid = (routes) => { + const usdcHyperliquid = `USDC-${HYPERLIQUID.id}` + + return _.mapValues(routes, (innerList, key) => { + // If the key is USDC-42161 OR if the innerList includes USDC-42161 + if (key === 'USDC-42161' || innerList.includes('USDC-42161')) { + return [...innerList, usdcHyperliquid] + } + return innerList + }) +} const PAUSED_TOKENS = flattenPausedTokens() -export const EXISTING_BRIDGE_ROUTES: BridgeRoutes = constructJSON( - BRIDGE_MAP, - PAUSED_TOKENS +export const EXISTING_BRIDGE_ROUTES: BridgeRoutes = addUSDCHyperLiquid( + constructJSON(BRIDGE_MAP, PAUSED_TOKENS) ) diff --git a/packages/synapse-interface/constants/index.ts b/packages/synapse-interface/constants/index.ts index 85e1dee41c..bcbf8a4ed2 100644 --- a/packages/synapse-interface/constants/index.ts +++ b/packages/synapse-interface/constants/index.ts @@ -2,3 +2,5 @@ export const MAX_UINT256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935n export const ETHEREUM_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + +export const HYPERLIQUID_MINIMUM_DEPOSIT = 5 diff --git a/packages/synapse-interface/constants/urls/index.tsx b/packages/synapse-interface/constants/urls/index.tsx index 5c1c5df241..328b564a36 100644 --- a/packages/synapse-interface/constants/urls/index.tsx +++ b/packages/synapse-interface/constants/urls/index.tsx @@ -53,7 +53,7 @@ export const HOW_TO_STAKE_URL = export const BUILD_ON_URL = 'https://docs.synapseprotocol.com/synapse-interchain-network-sin/build-on-the-synapse-interchain-network' export const TRANSACTION_SUPPORT_URL = - 'https://docs.synapseprotocol.com/synapse-bridge/synapse-bridge/transaction-support-faq' + 'https://docs.synapseprotocol.com/docs/Support/Transaction-Support' /** Construct URL Helper Functions */ export const getPoolUrl = (token: Token) => { diff --git a/packages/synapse-interface/messages/ar.json b/packages/synapse-interface/messages/ar.json index 09f07631d4..91739c4166 100644 --- a/packages/synapse-interface/messages/ar.json +++ b/packages/synapse-interface/messages/ar.json @@ -18,6 +18,8 @@ "Please select Destination network": "يرجى اختيار شبكة الوجهة", "Please select an Origin token": "يرجى اختيار رمز المصدر", "Bridge {symbol}": "جسر {symbol}", + "Deposit {symbol}": "إيداع {symbol}", + "Depositing": "الإيداع", "Connect Wallet to Bridge": "اتصل بالمحفظة للجسر", "Amount must be greater than fee": "يجب أن يكون المبلغ أكبر من الرسوم", "Error in bridge quote": "خطأ في عرض الجسر", diff --git a/packages/synapse-interface/messages/en-US.json b/packages/synapse-interface/messages/en-US.json index 8452406b49..c510d85681 100644 --- a/packages/synapse-interface/messages/en-US.json +++ b/packages/synapse-interface/messages/en-US.json @@ -18,6 +18,8 @@ "Please select Destination network": "Please select Destination network", "Please select an Origin token": "Please select an Origin token", "Bridge {symbol}": "Bridge {symbol}", + "Deposit {symbol}": "Deposit {symbol}", + "Depositing": "Depositing", "Connect Wallet to Bridge": "Connect Wallet to Bridge", "Amount must be greater than fee": "Amount must be greater than fee", "Error in bridge quote": "Error in bridge quote", diff --git a/packages/synapse-interface/messages/es.json b/packages/synapse-interface/messages/es.json index 5c121dfbe6..d36bd2ed97 100644 --- a/packages/synapse-interface/messages/es.json +++ b/packages/synapse-interface/messages/es.json @@ -18,6 +18,8 @@ "Please select Destination network": "Por favor, selecciona la red de Destino", "Please select an Origin token": "Por favor, selecciona un token de Origen", "Bridge {symbol}": "Puente {symbol}", + "Deposit {symbol}": "Depósito {symbol}", + "Depositing": "Depositando", "Connect Wallet to Bridge": "Conecta la Wallet para usar el Puente", "Amount must be greater than fee": "La cantidad debe ser mayor que la comisión", "Error in bridge quote": "Error en la cotización del puente", diff --git a/packages/synapse-interface/messages/fr.json b/packages/synapse-interface/messages/fr.json index 5f4608d38e..1294c50566 100644 --- a/packages/synapse-interface/messages/fr.json +++ b/packages/synapse-interface/messages/fr.json @@ -18,6 +18,8 @@ "Please select Destination network": "Veuillez sélectionner le réseau de destination", "Please select an Origin token": "Veuillez sélectionner un jeton d'origine", "Bridge {symbol}": "Bridge {symbol}", + "Deposit {symbol}": "Dépôt {symbol}", + "Depositing": "Dépôt", "Connect Wallet to Bridge": "Connecter le portefeuille au bridge", "Amount must be greater than fee": "Le montant doit être supérieur aux frais", "Error in bridge quote": "Erreur dans la citation du bridge", diff --git a/packages/synapse-interface/messages/jp.json b/packages/synapse-interface/messages/jp.json index ee05d2a385..bfc4354507 100644 --- a/packages/synapse-interface/messages/jp.json +++ b/packages/synapse-interface/messages/jp.json @@ -18,6 +18,8 @@ "Please select Destination network": "宛先ネットワークを選択してください", "Please select an Origin token": "オリジントークンを選択してください", "Bridge {symbol}": "{symbol}をブリッジ", + "Deposit {symbol}": "デポジット {symbol}", + "Depositing": "入金", "Connect Wallet to Bridge": "ウォレットを接続してブリッジ", "Amount must be greater than fee": "金額は手数料より大きくなければなりません", "Error in bridge quote": "ブリッジ見積もりでエラーが発生しました", diff --git a/packages/synapse-interface/messages/tr.json b/packages/synapse-interface/messages/tr.json index d51a91667a..a281269809 100644 --- a/packages/synapse-interface/messages/tr.json +++ b/packages/synapse-interface/messages/tr.json @@ -18,6 +18,8 @@ "Please select Destination network": "Lütfen Hedef ağı seçin", "Please select an Origin token": "Lütfen bir Kaynak token seçin", "Bridge {symbol}": "{symbol} Köprüsü", + "Deposit {symbol}": "Depozito {symbol}", + "Depositing": "Para yatırma", "Connect Wallet to Bridge": "Köprü için Cüzdanı Bağla", "Amount must be greater than fee": "Miktar ücretten büyük olmalıdır", "Error in bridge quote": "Köprü teklifinde hata", diff --git a/packages/synapse-interface/messages/zh-CN.json b/packages/synapse-interface/messages/zh-CN.json index d6a864354b..72f05a5048 100644 --- a/packages/synapse-interface/messages/zh-CN.json +++ b/packages/synapse-interface/messages/zh-CN.json @@ -18,6 +18,8 @@ "Please select Destination network": "请选择目标网络", "Please select an Origin token": "请选择来源代币", "Bridge {symbol}": "桥接 {symbol}", + "Deposit {symbol}": "存入 {symbol}", + "Depositing": "存款", "Connect Wallet to Bridge": "连接钱包以桥接", "Amount must be greater than fee": "金额必须大于费用", "Error in bridge quote": "桥接报价错误", diff --git a/packages/synapse-interface/package.json b/packages/synapse-interface/package.json index d2e2ddb88e..b391262239 100644 --- a/packages/synapse-interface/package.json +++ b/packages/synapse-interface/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/synapse-interface", - "version": "0.40.23", + "version": "0.40.24", "private": true, "engines": { "node": ">=18.18.0" diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx index f328d668e7..f4c3f63298 100644 --- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx +++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx @@ -9,6 +9,7 @@ import { getWalletClient, getPublicClient, waitForTransactionReceipt, + switchChain, } from '@wagmi/core' import { useTranslations } from 'next-intl' @@ -47,7 +48,10 @@ import { Token } from '@/utils/types' import { txErrorHandler } from '@/utils/txErrorHandler' import { approveToken } from '@/utils/approveToken' import { stringToBigInt } from '@/utils/bigint/format' -import { fetchAndStoreSingleNetworkPortfolioBalances } from '@/slices/portfolio/hooks' +import { + fetchAndStoreSingleNetworkPortfolioBalances, + usePortfolioState, +} from '@/slices/portfolio/hooks' import { updatePendingBridgeTransaction, addPendingBridgeTransaction, @@ -69,10 +73,17 @@ import { isTransactionUserRejectedError } from '@/utils/isTransactionUserRejecte import { BridgeQuoteResetTimer } from '@/components/StateManagedBridge/BridgeQuoteResetTimer' import { useBridgeValidations } from '@/components/StateManagedBridge/hooks/useBridgeValidations' import { useStaleQuoteUpdater } from '@/components/StateManagedBridge/hooks/useStaleQuoteUpdater' +import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' +import { HyperliquidTransactionButton } from '@/components/StateManagedBridge/HyperliquidDepositButton' +import { USDC } from '@/constants/tokens/bridgeable' +import { CheckCircleIcon } from '@heroicons/react/outline' +import Image from 'next/image' +import { HyperliquidDepositInfo } from '@/components/HyperliquidDepositInfo' const StateManagedBridge = () => { const dispatch = useAppDispatch() - const { address, isConnected } = useAccount() + const { address, isConnected, chain: connectedChain } = useAccount() + const { balances } = usePortfolioState() const { synapseSDK } = useSynapseContext() const router = useRouter() const { query, pathname } = router @@ -86,6 +97,9 @@ const StateManagedBridge = () => { const [isTyping, setIsTyping] = useState(false) + const [hasDepositedOnHyperliquid, setHasDepositedOnHyperliquid] = + useState(false) + const { fromChainId, toChainId, @@ -154,7 +168,7 @@ const StateManagedBridge = () => { fetchBridgeQuote({ synapseSDK, fromChainId, - toChainId, + toChainId: toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, fromToken, toToken, debouncedFromValue, @@ -182,7 +196,10 @@ const StateManagedBridge = () => { quoteToastRef.current.id = toast(message, { duration: 3000 }) } - if (fetchBridgeQuote.rejected.match(result)) { + if ( + fetchBridgeQuote.rejected.match(result) && + !(fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id) + ) { const message = t( 'No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}', { @@ -275,7 +292,8 @@ const StateManagedBridge = () => { { id: bridgeQuote.id, originChainId: fromChainId, - destinationChainId: toChainId, + destinationChainId: + toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, inputAmount: debouncedFromValue, expectedReceivedAmount: bridgeQuote.outputAmountString, slippage: bridgeQuote.exchangeRate, @@ -294,7 +312,8 @@ const StateManagedBridge = () => { originChain: CHAINS_BY_ID[fromChainId], originToken: fromToken, originValue: debouncedFromValue, - destinationChain: CHAINS_BY_ID[toChainId], + destinationChain: + CHAINS_BY_ID[toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId], destinationToken: toToken, transactionHash: undefined, timestamp: undefined, @@ -319,7 +338,7 @@ const StateManagedBridge = () => { toAddress, bridgeQuote.routerAddress, fromChainId, - toChainId, + toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, fromToken?.addresses[fromChainId as keyof Token['addresses']], stringToBigInt(debouncedFromValue, fromToken?.decimals[fromChainId]), bridgeQuote.originQuery, @@ -356,7 +375,8 @@ const StateManagedBridge = () => { segmentAnalyticsEvent(`[Bridge] bridges successfully`, { id: bridgeQuote.id, originChainId: fromChainId, - destinationChainId: toChainId, + destinationChainId: + toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, inputAmount: debouncedFromValue, expectedReceivedAmount: bridgeQuote.outputAmountString, slippage: bridgeQuote.exchangeRate, @@ -411,6 +431,13 @@ const StateManagedBridge = () => { timeout: 60_000, }) + if (toChainId === HYPERLIQUID.id) { + dispatch(setFromChainId(ARBITRUM.id)) + dispatch(setFromToken(USDC)) + dispatch(setToChainId(HYPERLIQUID.id)) + switchChain(wagmiConfig, { chainId: ARBITRUM.id }) + } + /** Update Origin Chain token balances after resolved tx or timeout reached */ /** Assume tx has been actually resolved if above times out */ dispatch( @@ -489,17 +516,34 @@ const StateManagedBridge = () => { - + {!( + fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id + ) && } + {toChainId === HYPERLIQUID.id && ( + + )}
- + {fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id ? ( + + ) : ( + + )}