diff --git a/.env.sample b/.env.sample index 4d39ea8..814ab63 100644 --- a/.env.sample +++ b/.env.sample @@ -2,68 +2,124 @@ # DEPLOYMENT CONFIGURATION # ============================================================================= -# Private key for deployment to production networks (without 0x prefix) +# [REQUIRED] Private key for deployment to production networks (without 0x prefix) # SECURITY: Never commit this file with actual values. Use a hardware wallet or secure key management. DEPLOYER_PRIVATE_KEY= -# Block explorer API key for contract verification, needs Etherscan.io! Sign up at: https://etherscan.io/apidashboard +# [REQUIRED for --verify] Block explorer API key for contract verification, needs Etherscan.io! Sign up at: https://etherscan.io/apidashboard EXPLORER_KEY= +# [OPTIONAL] Base mainnet RPC URL. Used for deployments to the `base` network and +# for fork-based tests/coverage. Falls back to the public https://mainnet.base.org +# endpoint; a dedicated archive node is strongly recommended for reliable forking. +BASE_RPC_URL= + # ============================================================================= # ACCESS CONTROL ADDRESSES # ============================================================================= -# Admin address - receives ownership of deployed contracts and acts as: +# [REQUIRED] Admin address - receives ownership of deployed contracts and acts as: # - TimelockController proposer and executor # - ProtocolRegistry DEFAULT_ADMIN_ROLE holder # - Final owner of SafeDebtManager and LeveragedPosition after deployment # IMPORTANT: Use a multisig (e.g., Gnosis Safe) for production deployments ADMIN_ADDRESS= -# Operator address that can call executeDebtSwap() and other SafeDebtManager/LeveragedPosition functions +# [REQUIRED] Operator address that can call executeDebtSwap() and other SafeDebtManager/LeveragedPosition functions # This address is stored in ProtocolRegistry and can only be changed via TimelockController (2-day delay) SAFE_OPERATOR_ADDRESS= -# Pauser address that can pause/unpause SafeDebtManager and LeveragedPosition in emergencies +# [REQUIRED] Pauser address that can pause/unpause SafeDebtManager and LeveragedPosition in emergencies # Can be the same as ADMIN_ADDRESS, or a separate EOA for faster emergency response PAUSER_ADDRESS= +# ============================================================================= +# TIMELOCK DEPLOYMENT (shared by deploy:1_core and deploy:2_univ3_helper) +# ============================================================================= + +# [OPTIONAL] EOA/multisig granted BOTH proposer and executor roles on the deployed +# TimelockController. Falls back to ADMIN_ADDRESS if unset. +TIMELOCK_ADMIN= + +# [OPTIONAL] Minimum delay (seconds) before queued timelock ops can execute. +# Defaults to 172800 (2 days). Lower on testnets only. +TIMELOCK_DELAY= + +# ============================================================================= +# UNISWAP V3 HELPER DEPLOYMENT (deploy:2_univ3_helper) +# ============================================================================= + +# [REQUIRED] Treasury address that collects performance + collect fees. +# The deploy reverts without a valid treasury. +RHP_TREASURY= + +# [OPTIONAL] ProtocolRegistry to wire RHP to. If unset, falls back to +# PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts (auto-synced by deploy:1_core). +RHP_REGISTRY= + +# [OPTIONAL] DEFAULT_ADMIN_ROLE holder on RatehopperUniV3Positions. Falls back to ADMIN_ADDRESS. +RHP_INITIAL_ADMIN= + +# [OPTIONAL] Reuse an existing TimelockController instead of deploying a new one. +# When set, the shared TimelockControllerModule is skipped for this deploy. +RHP_TIMELOCK= + +# [OPTIONAL] Performance fee on net profit at closeLp (bps). Default 1000 (10%). +RHP_PERFORMANCE_FEE_BPS= + +# [OPTIONAL] Fee on harvested LP fees via collectLp/closeLp (bps). Default 250 (2.5%). +RHP_FEE_COLLECT_BPS= + +# [OPTIONAL] Hard upper bound on BOTH fees (bps). Default 2000 (20%). +RHP_MAX_FEE_BPS= + +# [OPTIONAL] Floor on NPM mint liquidity (dust-close hardening). Default 10000. Set 0 to disable. +RHP_MIN_POSITION_LIQUIDITY= + +# [OPTIONAL] Floor on pool.liquidity() for spot-price reads (manipulation hardening). +# Default 0 (disabled). See 2_DeployUniV3Helper.ts docs / RUNBOOK before raising. +RHP_MIN_POOL_LIQUIDITY= + # ============================================================================= # TESTING CONFIGURATION (for running tests and scripts) # ============================================================================= -# Private key for a Safe wallet owner (used for signing transactions, enabling modules, etc.) -# This should be a key that owns or is a signer on the test Safe wallet +# [REQUIRED for Safe tests] Private key for a Safe wallet owner (used for signing transactions, enabling modules, etc.) +# This should be a key that owns or is a signer on the test Safe wallet. +# Safe integration tests are skipped if this (or TESTING_SAFE_WALLET_ADDRESS) is unset. TESTING_SAFE_OWNER_KEY= -# Address of the Safe wallet owner (used in deployRolesProxy.ts as Roles module owner) -TESTING_SAFE_OWNER_ADDRESS= - -# Safe wallet address for integration tests +# [REQUIRED for Safe tests] Safe wallet address for integration tests TESTING_SAFE_WALLET_ADDRESS= +# [OPTIONAL] Address of the Safe wallet owner (used in deployRolesProxy.ts as Roles module owner) +TESTING_SAFE_OWNER_ADDRESS= + # ============================================================================= # TIMELOCK OPERATIONS (for post-deployment configuration changes) # ============================================================================= -# TimelockController contract address (set after deployment) +# [REQUIRED] TimelockController contract address (set after deployment) TIMELOCK_ADDRESS= -# ProtocolRegistry contract address (set after deployment) +# [REQUIRED] ProtocolRegistry contract address (set after deployment) PROTOCOL_REGISTRY_ADDRESS= -# New Paraswap V6 address (for timelock-update-paraswap.ts script) +# [REQUIRED] New Paraswap V6 address (for timelock-update-paraswap.ts script) NEW_PARASWAP_ADDRESS= -# Set to "true" to execute a scheduled timelock operation (default: schedule only) +# [OPTIONAL] Set to "true" to execute a scheduled timelock operation (default: schedule only) EXECUTE= -# Unique identifier for timelock operations (optional, auto-generated if not set) +# [OPTIONAL] Unique identifier for timelock operations (auto-generated if not set) OPERATION_ID= # ============================================================================= # OPTIONAL / DEVELOPMENT # ============================================================================= -# Enable gas reporting in tests (set to any value to enable) -# REPORT_GAS=true \ No newline at end of file +# [OPTIONAL] Enable gas reporting in tests (set to any value to enable) +# REPORT_GAS=true + +# [OPTIONAL] Override the chain id used by scripts/syncRegistryAddress.js. Default 8453 (Base). +# CHAIN_ID=8453 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1d7116 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint-test-coverage: + name: Lint & branch-coverage gate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable Corepack (Yarn 4) + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Lint Solidity (solhint) + run: yarn lint:sol + + - name: Check formatting (prettier) + run: yarn format:check + + - name: Compile + run: yarn compile + + # Coverage is scoped to the RatehopperUniV3Positions suite. The legacy + # debt-swap / protocol-handler suites are Base-fork tests that are not + # yet fully covered; running them here would fail the step before the + # gate. Expand the --testfiles glob (in package.json `coverage:rhp`) as + # coverage for those contracts is brought up. + # + # BASE_RPC_URL is recommended: an archive RPC secret gives reliable + # forking. Without it the config falls back to the public + # https://mainnet.base.org endpoint. + - name: Run coverage (RatehopperUniV3Positions) + env: + BASE_RPC_URL: ${{ secrets.BASE_RPC_URL }} + run: yarn coverage:rhp + + - name: Enforce branch-coverage gate + run: yarn coverage:check diff --git a/.prettierrc b/.prettierrc index 3dc2fe1..da110b7 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { + "plugins": ["prettier-plugin-solidity"], "printWidth": 120, "singleQuote": false, "tabWidth": 4, diff --git a/.solcover.js b/.solcover.js index 2152696..f6f9c15 100644 --- a/.solcover.js +++ b/.solcover.js @@ -1,4 +1,9 @@ module.exports = { + // Mocks are test-only scaffolding — exclude from instrumentation/report. + skipFiles: ["mocks/"], + // RatehopperUniV3Positions compiles with viaIR; let coverage configure the + // Yul optimizer so instrumentation doesn't trip "stack too deep". + configureYulOptimizer: true, mocha: { parallel: false, }, diff --git a/.solhint.json b/.solhint.json index d481f2f..482499f 100644 --- a/.solhint.json +++ b/.solhint.json @@ -1,10 +1,9 @@ { "extends": "solhint:recommended", - "plugins": ["prettier"], "rules": { - "prettier/prettier": "error", "no-console": "off", "avoid-low-level-calls": "off", + "func-visibility": "off", "custom-errors": "off" } } diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 0000000..b5b1afa --- /dev/null +++ b/.solhintignore @@ -0,0 +1,4 @@ +# Vendored / external code — third-party libraries and protocol ABIs that we +# do not control. Lint only first-party contracts. +contracts/dependencies/ +contracts/interfaces/ diff --git a/README.md b/README.md index da8450e..51e4187 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ RateHopper Contracts is a smart contract system that enables users to automatica - **Leveraged Positions**: Enables creation of leveraged positions across supported protocols. +- **Uniswap V3 LP Lifecycle**: `RatehopperUniV3Positions` is a Gnosis Safe module that opens, harvests fees from, and closes WETH/USDC LP positions atomically. Charges a configurable performance fee on profit at close, plus a separate fee on accrued LP fees. Critical setters are timelock-gated. + ## Architecture The system consists of several key components: @@ -56,7 +58,15 @@ The system consists of several key components: 5. **LeveragedPosition.sol**: Facilitates creation of leveraged positions across protocols. -6. **Morpho Libraries**: Supporting libraries for the Morpho protocol: +6. **RatehopperUniV3Positions.sol**: Standalone Gnosis Safe module for Uniswap V3 WETH/USDC LP lifecycle. Three external entry points: + + - `openLp()` — splits the Safe's USDC, swaps half to WETH via the pinned SwapRouter02, mints a Uniswap V3 LP NFT on the Safe. + - `closeLp()` — partial or full unwind (`exitBps`): harvests accrued fees, `decreaseLiquidity`, collects principal, optionally `burn`s, swaps the WETH leg back to USDC. + - `collectLp()` — mid-position fee harvest with no decrease/burn. + + Caller passes per-call `swapAmountOutMin` and `deadline` (audit fixes C-01 / H-02). The constructor rejects any non-WETH/USDC token pair (M-08). Performance fee is charged on net profit (`currentValueUsd6 - basisUsd6`); fee-collect is charged on accrued fees only. All fee setters are gated by `CRITICAL_ROLE` on a TimelockController (H-04); `rescueToken` and similar emergency ops are gated by `DEFAULT_ADMIN_ROLE`. + +7. **Morpho Libraries**: Supporting libraries for the Morpho protocol: - `MathLib.sol`: Provides fixed-point arithmetic operations for the Morpho protocol - `SharesMathLib.sol`: Handles share-to-asset conversion with virtual shares to protect against share price manipulations @@ -165,11 +175,25 @@ struct ParaswapParams { Create a `.env` file with the following required variables (use `.env.sample` as a template): ```env -ADMIN_ADDRESS=0x... # Initial admin and timelock proposer/executor +# Core deploy +ADMIN_ADDRESS=0x... # Initial admin and timelock proposer/executor (used by 1_DeployCore AND 2_DeployUniV3Helper) SAFE_OPERATOR_ADDRESS=0x... # Operator address for Safe interactions PAUSER_ADDRESS=0x... # Address that can pause contracts DEPLOYER_PRIVATE_KEY=... # Private key for deployment EXPLORER_KEY=... # Block explorer API key for verification + +# Optional — RatehopperUniV3Positions deploy (yarn deploy:2_univ3_helper) +RHP_REGISTRY=0x... # ProtocolRegistry address. Falls back to PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts +RHP_TREASURY=0x... # Treasury that collects feeCollectBps + performanceFeeBps cuts. REQUIRED. +RHP_INITIAL_ADMIN=0x... # DEFAULT_ADMIN_ROLE holder (rescueToken etc.). Falls back to ADMIN_ADDRESS +RHP_TIMELOCK=0x... # Reuse an existing TimelockController instead of deploying a new one +RHP_PERFORMANCE_FEE_BPS=1000 # Performance fee on profit at closeLp (bps). Default 1000 (10%) +RHP_FEE_COLLECT_BPS=250 # Fee on accrued LP fees harvested via collectLp/closeLp (bps). Default 250 (2.5%) +RHP_MAX_FEE_BPS=2000 # Hard upper bound on BOTH fees (bps). Default 2000 (20%) + +# Optional — TimelockController inside 2_DeployUniV3Helper +TIMELOCK_ADMIN=0x... # Proposer + executor on the new timelock. Falls back to ADMIN_ADDRESS +TIMELOCK_DELAY=172800 # Min delay before queued ops execute (seconds). Default 172800 (2 days) ``` ## Setup and Development @@ -230,16 +254,16 @@ Hardhat Ignition is a declarative deployment framework. Instead of writing imper - **Supports `--verify`** to automatically submit contracts to Etherscan after deployment - **Supports `--reset`** to wipe previous state and redeploy from scratch -All contracts are defined in a single module at `ignition/modules/DeployAll.ts`. Every step is chained sequentially via `after` dependencies to avoid nonce race conditions. +The core contracts are defined in a single module at `ignition/modules/1_DeployCore.ts`. Every step is chained sequentially via `after` dependencies to avoid nonce race conditions. ### Deploy All Contracts Deploy everything in one command: ```bash -yarn deploy +yarn deploy:1_core # or equivalently: -npx hardhat ignition deploy ignition/modules/DeployAll.ts --network base --verify --reset +npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify --reset ``` This deploys all contracts sequentially in a single transaction chain: @@ -253,6 +277,23 @@ This deploys all contracts sequentially in a single transaction chain: 7. **LeveragedPosition** → `transferOwnership` to `ADMIN_ADDRESS` 8. **SafeExecTransactionWrapper** +### Deploy RatehopperUniV3Positions (Standalone) + +`RatehopperUniV3Positions` is deployed separately because it sits on top of an existing `ProtocolRegistry` and needs its own TimelockController for fund-impacting setters. + +```bash +yarn deploy:2_univ3_helper +# or equivalently: +npx hardhat ignition deploy ignition/modules/2_DeployUniV3Helper.ts --network base --verify +``` + +This module by default deploys: + +1. **TimelockController** (proposer + executor = `TIMELOCK_ADMIN` ?? `ADMIN_ADDRESS`; delay = `TIMELOCK_DELAY` ?? 2 days) +2. **RatehopperUniV3Positions** (wired to the registry from `RHP_REGISTRY` ?? `PROTOCOL_REGISTRY_ADDRESS`; CRITICAL_ROLE granted to the timelock from step 1) + +To reuse an existing TimelockController instead of deploying a new one, set `RHP_TIMELOCK=0x...` — the module skips step 1 and points RHP at the supplied address. See `## Environment Variables` for the full list of optional knobs. + ### Deployment Output After deployment, Ignition saves state to: @@ -272,7 +313,7 @@ cat ignition/deployments/chain-8453/deployed_addresses.json ### Contract Verification -The `--verify` flag on `yarn deploy` may fail due to a known `hardhat-verify` v2.x bug with Etherscan's V2 API (the plugin's GET requests strip the `chainid` parameter). Use the standalone verification script instead: +The `--verify` flag on `yarn deploy:1_core` may fail due to a known `hardhat-verify` v2.x bug with Etherscan's V2 API (the plugin's GET requests strip the `chainid` parameter). Use the standalone verification script instead: ```bash yarn verify @@ -290,19 +331,56 @@ This script: ### Timelock Operations -For critical operations (updating Paraswap address): +Critical `ProtocolRegistry` setters (`setParaswapV6`, `setOperator`) carry `CRITICAL_ROLE` and revert unless `msg.sender` is the timelock, so they must be scheduled and executed through the `TimelockController` (2-day delay). Each script is a two-step flow: schedule, wait for the delay, then re-run with `EXECUTE=true` reusing the same `OPERATION_ID` printed during scheduling. + +#### Finding the TimelockController address + +`SafeDebtManager` does not store the timelock itself — it only keeps an immutable `registry` reference, and the `TimelockController` address lives on the `ProtocolRegistry` (`timelock()` getter). So given a deployed `SafeDebtManager`, resolve the timelock by hopping through the registry. + +**On-chain (authoritative — this is the address the contracts actually enforce):** + +```bash +# 1. Read the registry from the deployed SafeDebtManager +REGISTRY=$(cast call "registry()(address)" --rpc-url ) + +# 2. Read the timelock from that registry +cast call $REGISTRY "timelock()(address)" --rpc-url +``` + +`SafeDebtManager.registry()` and `ProtocolRegistry.timelock()` are both public getters, so any RPC reader (cast, ethers, a block explorer's "Read Contract" tab) works. + +**Off-chain (from this repo's Ignition deployment):** the address is recorded under the `TimelockControllerModule#TimelockController` key — the timelock is a shared sub-module, so it keeps that stable ID regardless of which top-level module deployed it. + +```bash +cat ignition/deployments/chain-8453/deployed_addresses.json +# → look for "TimelockControllerModule#TimelockController" +``` + +Use the resulting address as `TIMELOCK_ADDRESS` in the commands below. + +**Updating the Paraswap address:** ```bash # Schedule operation (requires proposer role) TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_PARASWAP_ADDRESS=0x... \ -yarn hardhat run scripts/timelock-update-paraswap.ts --network base +yarn hardhat run scripts/timelockUpdateParaswap.ts --network base # Wait 2 days, then execute -EXECUTE=true TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_PARASWAP_ADDRESS=0x... \ -yarn hardhat run scripts/timelock-update-paraswap.ts --network base +EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_PARASWAP_ADDRESS=0x... \ +yarn hardhat run scripts/timelockUpdateParaswap.ts --network base ``` -> **Note**: Updating the operator address (`setOperator`) also requires the timelock. Create a similar script based on `timelock-update-paraswap.ts` if needed. +**Updating the operator address:** + +```bash +# Schedule operation (requires proposer role) +TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \ +yarn hardhat run scripts/timelockUpdateOperator.ts --network base + +# Wait 2 days, then execute (reuse the OPERATION_ID printed during scheduling) +EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \ +yarn hardhat run scripts/timelockUpdateOperator.ts --network base +``` ## Security Features diff --git a/contractAddresses.ts b/contractAddresses.ts index 5da45d6..f68718a 100644 --- a/contractAddresses.ts +++ b/contractAddresses.ts @@ -23,6 +23,8 @@ export const GHO_ADDRESS = "0x6Bb7a212910682DCFdbd5BCBb3e28FB4E8da10Ee"; // Uniswap V3 export const UNISWAP_V3_FACTORY_ADDRESS = "0x33128a8fC17869897dcE68Ed026d694621f6FDfD"; +export const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; +export const UNISWAP_V3_SWAP_ROUTER_ADDRESS = "0x2626664c2603336E57B271c5C0b26F421741e481"; // Paraswap export const PARASWAP_V6_CONTRACT_ADDRESS = "0x6a000f20005980200259b80c5102003040001068"; @@ -43,6 +45,16 @@ export const COMPTROLLER_ADDRESS = "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c"; // Team owner wallet (for ownership transfer after deployment) export const ADMIN_ADDRESS = "0xc74fc973A0740Ca1ED6f8F31Ed56003A13D4F5F1"; +// RateHopper canonical Base deployments — referenced by downstream deploys +// (e.g. 2_DeployUniV3Helper reads PROTOCOL_REGISTRY_ADDRESS to wire RHP +// to the existing registry). +// +// AUTO-MANAGED: `yarn deploy:1_core` runs scripts/syncRegistryAddress.js as its +// final step, rewriting the address below to the freshly deployed +// ProtocolRegistry. Commit the resulting diff. Only edit by hand if pointing at +// a registry deployed outside this repo's Ignition flow. +export const PROTOCOL_REGISTRY_ADDRESS = "0x20b2003CEF180DBbcca9fAf33Ddb1D635fdD2F6c"; + // Protocol enum export enum Protocol { AAVE_V3, diff --git a/contracts/LeveragedPosition.sol b/contracts/LeveragedPosition.sol index 3d66d4a..e77cd04 100644 --- a/contracts/LeveragedPosition.sol +++ b/contracts/LeveragedPosition.sol @@ -29,8 +29,12 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { error InsufficientTokenBalanceAfterSwap(uint256 expected, uint256 actual); error InvalidOperationType(uint8 operationType); + error OnlyTimelock(); - enum OperationType { Create, Close } + enum OperationType { + Create, + Close + } modifier onlyOwnerOrOperator(address onBehalfOf) { require(onBehalfOf != address(0), "onBehalfOf cannot be zero address"); @@ -50,6 +54,12 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { _; } + modifier onlyTimelockCriticalRole() { + if (msg.sender != registry.timelock()) revert OnlyTimelock(); + require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); + _; + } + struct CreateCallbackData { address flashloanPool; Protocol protocol; @@ -103,7 +113,12 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { event ProtocolHandlerUpdated(Protocol indexed protocol, address indexed oldHandler, address indexed newHandler); - constructor(address _registry, Protocol[] memory protocols, address[] memory handlers, address _pauser) Ownable(msg.sender) { + constructor( + address _registry, + Protocol[] memory protocols, + address[] memory handlers, + address _pauser + ) Ownable(msg.sender) { require(protocols.length == handlers.length, "Protocols and handlers length mismatch"); require(_registry != address(0), "Registry cannot be zero address"); require(_pauser != address(0), "Pauser cannot be zero address"); @@ -140,9 +155,9 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { /// @notice Updates the handler address for a specific protocol /// @param _protocol The protocol to update the handler for /// @param _handler The new handler address - /// @dev Only callable by addresses with CRITICAL_ROLE in ProtocolRegistry. For production, should be behind a timelock. + /// @dev Only callable via the registry's immutable timelock and must also hold CRITICAL_ROLE. /// @dev Allows updating handlers if a bug is found or handler needs upgrade. - function setProtocolHandler(Protocol _protocol, address _handler) external onlyCriticalRole { + function setProtocolHandler(Protocol _protocol, address _handler) external onlyTimelockCriticalRole { require(_handler != address(0), "Invalid handler address"); address oldHandler = protocolHandlers[_protocol]; protocolHandlers[_protocol] = _handler; @@ -276,7 +291,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { /// @dev Routes to create or close handlers based on operation type. Only callable by valid Uniswap V3 pools. function uniswapV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external whenNotPaused { // Decode operation type first - (OperationType operationType) = abi.decode(data, (OperationType)); + OperationType operationType = abi.decode(data, (OperationType)); // Verify callback IUniswapV3Pool pool = IUniswapV3Pool(msg.sender); @@ -302,7 +317,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { // Calculate protocol fee based on borrowed amount uint256 borrowAmount = decoded.paraswapParams.srcAmount + 1; - + uint256 protocolFeeAmount = (borrowAmount * protocolFee) / 10000; uint256 amountInMax = borrowAmount + protocolFeeAmount; @@ -334,11 +349,11 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { uint256 amountToRepay = flashloanBorrowAmount + totalFee; swapByParaswap( - decoded.debtAsset, - decoded.collateralAsset, - amountInMax, - amountToRepay, - decoded.paraswapParams.swapData + decoded.debtAsset, + decoded.collateralAsset, + amountInMax, + amountToRepay, + decoded.paraswapParams.swapData ); // repay flashloan @@ -391,7 +406,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { ) ); require(successWithdraw, "Withdraw failed"); - + uint256 flashloanRepayAmount = decoded.debtAmount + totalFee; // Swap collateral to debt asset to repay flash loan @@ -438,7 +453,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { bytes memory _txParams ) internal { require(_txParams.length >= 4, "Invalid calldata"); - + // amount + 1 to avoid rounding errors IERC20(srcAsset).forceApprove(registry.paraswapV6(), amount + 1); diff --git a/contracts/ProtocolRegistry.sol b/contracts/ProtocolRegistry.sol index 9d814d3..c6dedc3 100644 --- a/contracts/ProtocolRegistry.sol +++ b/contracts/ProtocolRegistry.sol @@ -111,7 +111,10 @@ contract ProtocolRegistry is AccessControl { return tokenToCContract[token]; } - function batchSetTokenMContracts(address[] calldata tokens, address[] calldata mContracts) external onlyRole(DEFAULT_ADMIN_ROLE) { + function batchSetTokenMContracts( + address[] calldata tokens, + address[] calldata mContracts + ) external onlyRole(DEFAULT_ADMIN_ROLE) { if (tokens.length != mContracts.length) revert ArrayLengthMismatch(); for (uint256 i = 0; i < tokens.length; i++) { @@ -121,7 +124,10 @@ contract ProtocolRegistry is AccessControl { } } - function batchSetTokenCContracts(address[] calldata tokens, address[] calldata cContracts) external onlyRole(DEFAULT_ADMIN_ROLE) { + function batchSetTokenCContracts( + address[] calldata tokens, + address[] calldata cContracts + ) external onlyRole(DEFAULT_ADMIN_ROLE) { if (tokens.length != cContracts.length) revert ArrayLengthMismatch(); for (uint256 i = 0; i < tokens.length; i++) { diff --git a/contracts/RatehopperUniV3Positions.sol b/contracts/RatehopperUniV3Positions.sol new file mode 100644 index 0000000..5030275 --- /dev/null +++ b/contracts/RatehopperUniV3Positions.sol @@ -0,0 +1,1041 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {INonfungiblePositionManager} from "./interfaces/uniswapV3/INonfungiblePositionManager.sol"; +import {IUniswapV3Factory} from "./interfaces/uniswapV3/IUniswapV3Factory.sol"; +import {IUniswapV3Pool} from "./interfaces/uniswapV3/IUniswapV3Pool.sol"; +import {ISafe} from "./interfaces/safe/ISafe.sol"; + +import {IProtocolRegistry} from "./interfaces/IProtocolRegistry.sol"; + +/// @title RatehopperUniV3Positions +/// @notice Atomic Uniswap V3 WETH/USDC LP lifecycle helper for Gnosis Safes: +/// - `openLp()` splits the Safe's USDC, swaps half to WETH on the +/// pinned SwapRouter02, then mints a WETH/USDC LP NFT on the Safe. +/// - `closeLp()` pulls the LP NFT, decreaseLiquidity + collect + +/// burn, swaps the WETH leg back to USDC, and forwards realized +/// USDC to the Safe. +/// - `collectLp()` harvests accrued LP fees without exiting; a +/// `feeCollectBps` cut is sent to `treasury`, remainder to Safe. +/// All swap calldata is built on-chain to prevent caller injection; +/// the caller supplies `swapAmountOutMin` for slippage protection. +/// Supply/borrow on a lending protocol (e.g. Fluid) and debt repayment +/// are the user's responsibility via separate Safe transactions. +/// +/// WETH/USDC-ONLY by design — every NonfungiblePositionManager mint and runtime +/// token-pair check is hardcoded to the constructor-pinned `WETH` and +/// `USDC` immutables. The constructor only enforces `_weth < _usdc` +/// ordering (required by Uniswap V3's token0/token1 convention); +/// picking the right pair is a deploy-process responsibility. +/// The raw module-mediated `IERC20.approve` in `_safeApprove` relies +/// on the deployed pair accepting non-zero→non-zero approvals — which +/// is true for canonical WETH and USDC, but NOT for USDT-style +/// two-step tokens. Deploying with such a token will surface as a +/// revert at the first `_safeApprove` call. Adding support for any +/// non-WETH/USDC token requires switching `_safeApprove` to +/// `SafeERC20.forceApprove` via the Safe module. +/// +/// FEE-HARVEST CAVEAT (I-1 informational): LP NFTs are minted to the +/// Safe (`recipient = _onBehalfOf`), NOT held by this contract. The +/// protocol's `feeCollectBps` is only applied when fees are harvested +/// through `collectLp()` / `closeLp()` (which route `tokensOwed` +/// through this contract before forwarding the remainder to the Safe). +/// A Safe owner CAN call Uniswap V3 `NonfungiblePositionManager.collect()` +/// directly and receive accrued fees without paying `feeCollectBps`. +/// This is by design — user funds are never at risk and no third party +/// can steal fees — but protocol fee accrual depends on harvests going +/// through `collectLp()` / `closeLp()` rather than direct NPM calls. +contract RatehopperUniV3Positions is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + /// @notice Timelocked role for fund-impacting setters. Granted to + /// a `TimelockController` at construction; mutations require a + /// scheduled call through the timelock, giving users time to + /// react to malicious changes. Matches the `ProtocolRegistry` + /// CRITICAL_ROLE convention. + bytes32 public constant CRITICAL_ROLE = keccak256("CRITICAL_ROLE"); + + /// @dev Mirror of SwapRouter02's `ExactInputSingleParams`. Inlined so we + /// can build the swap calldata on-chain without trusting any + /// caller-supplied bytes blob. + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + INonfungiblePositionManager public immutable POSITION_MANAGER; + IProtocolRegistry public immutable REGISTRY; + IERC20 public immutable USDC; + IERC20 public immutable WETH; + address public immutable SWAP_ROUTER; + IUniswapV3Factory public immutable UNISWAP_V3_FACTORY; + uint16 public immutable MAX_FEE_BPS; + + /// @notice The immutable `TimelockController` address. Critical setters + /// (`setTreasury`, `setPerformanceFeeBps`, `setFeeCollectBps`) + /// require `msg.sender == timelock` so a `DEFAULT_ADMIN_ROLE` + /// holder cannot self-grant `CRITICAL_ROLE` and bypass the delay. + address public immutable timelock; + + address public treasury; + uint16 public performanceFeeBps; + uint16 public feeCollectBps; + /// @notice Hard ceiling on the caller-supplied `slippageBps` accepted by + /// `openLp` / `closeLp`. Defaults to + /// 300 (3%). Owner-mutable via `setMaxSlippageBps`, but capped at + /// `MAX_SETTABLE_SLIPPAGE_BPS = 1000` (10%) to bound the owner's + /// authority — even a compromised owner cannot disable slippage + /// protection entirely. + uint16 public maxSlippageBps = 300; + + /// @notice Absolute ceiling on what owner can set `maxSlippageBps` to. + /// Hard-coded user-protection guardrail. + uint16 public constant MAX_SETTABLE_SLIPPAGE_BPS = 1000; + + /// @notice Allow-list of acceptable Uniswap V3 fee tiers for both the LP + /// pool (mint side) and the swap pool. Constrains caller / operator + /// routing away from thin pools where `slot0` manipulation is + /// cheap and slippage extraction is large. Defaults at deploy: + /// {100, 500, 3000} = enabled; everything else (notably the + /// 10000-bps WETH/USDC tier on Base) = disabled. Mutable via + /// `setFeeTierAllowed`. + mapping(uint24 feeTier => bool) public allowedFeeTier; + + /// @notice Per-tokenId cost basis remaining to be drawn down by future + /// `closeLp` calls. Set at `openLp` to the freshly-minted LP's + /// USDC-equivalent value (immutable to the caller); decremented + /// by `basis * exitBps / 10_000` on each `closeLp`, deleted on a + /// full close. A value of 0 means "no active position recorded + /// under this tokenId" (never opened via this contract OR already + /// fully closed). This replaces the prior caller-attested + /// `initialValueUsd6` parameter — neither a Safe owner nor a + /// compromised operator can lie about the perf-fee basis anymore. + mapping(uint256 tokenId => uint128 residualBasisUsd6) public residualBasisUsd6Of; + + /// @notice Minimum `pool.liquidity()` required for any pool this contract + /// reads spot price from (LP pool in `_collectLp` for fee valuation; + /// LP + swap pools in `openLp`/`closeLp` pre-flight). Defense in + /// depth on top of the + /// fee-tier allow-list — protects against allow-listed pools that + /// drain in the future. Set at construction (`_minPoolLiquidity`); + /// 0 disables the check. Owner-mutable via `setMinPoolLiquidity`. + uint128 public minPoolLiquidity; + + /// @notice Floor on the `liquidity` returned by NPM `mint` inside + /// `_safeMintLp`. Dust-sized positions are the class of LP that + /// can suffer the `liquidityToRemove == 0` partial-close path + /// (basis decrements but principal does not move); enforcing a + /// floor at mint time keeps the protocol away from that regime. + /// Set at construction (`_minPositionLiquidity`); 0 disables the + /// check. Owner-mutable via `setMinPositionLiquidity`. + uint128 public minPositionLiquidity; + + // SwapRouter02 `exactInputSingle` selector. Verify with: + // cast sig "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" + // = 0x04e45aaf + bytes4 private constant EXACT_INPUT_SINGLE_SELECTOR = 0x04e45aaf; + + event PositionOpened( + address indexed onBehalfOf, + uint256 indexed tokenId, + uint256 usdcInput, + uint128 wethToLp, + uint128 usdcToLp, + uint128 currentValueUsd6 + ); + event PositionClosed( + address indexed onBehalfOf, + uint256 indexed tokenId, + uint128 basisUsd6, + uint128 currentValueUsd6, + uint128 feeUsd6, + uint16 exitBps + ); + event FeesCollected( + address indexed onBehalfOf, + uint256 indexed tokenId, + address token0, + uint256 collected0, + uint256 fee0, + address token1, + uint256 collected1, + uint256 fee1, + uint128 currentValueUsd6 + ); + event TreasuryUpdated(address indexed previousTreasury, address indexed newTreasury); + event PerformanceFeeBpsUpdated(uint16 previousPerformanceFeeBps, uint16 newPerformanceFeeBps); + event FeeCollectBpsUpdated(uint16 previousFeeCollectBps, uint16 newFeeCollectBps); + event MaxSlippageBpsUpdated(uint16 previousMaxSlippageBps, uint16 newMaxSlippageBps); + event FeeTierAllowedUpdated(uint24 indexed feeTier, bool previousAllowed, bool newAllowed); + event MinPoolLiquidityUpdated(uint128 previousMinPoolLiquidity, uint128 newMinPoolLiquidity); + event MinPositionLiquidityUpdated(uint128 previousMinPositionLiquidity, uint128 newMinPositionLiquidity); + event TokenRescued(address indexed token, address indexed recipient, uint256 amount); + event NftRescued(address indexed token, address indexed recipient, uint256 indexed tokenId); + event FeeTransferFailed(address indexed onBehalfOf, uint256 indexed tokenId, uint128 feeUsd6); + event CollectFeeTransferFailed( + address indexed onBehalfOf, + uint256 indexed tokenId, + address indexed token, + uint256 attemptedFee + ); + + error InvalidTreasury(); + error FeeAboveMax(); + error SwapFailed(); + error ZeroAddress(); + error InvalidUsdcAmount(); + error InvalidExitBps(); + error SlippageAboveMax(); + error FeeTierNotAllowed(); + error UnknownPosition(); + error WrongTokenOrder(); + error ModuleCallFailed(uint8 step); + error LpNotOnSafe(); + error WrongTokenPair(); + error NotAuthorized(); + error DeadlineExpired(); + error PoolDoesNotExist(); + error PoolNotInitialized(); + error PoolTooThin(); + error MinUsdcOutNotMet(); + error SlippageTooLow(); + error InvalidSwapAmountOutMin(); + error OnlyTimelock(); + error PositionLiquidityTooLow(); + error SwapMinBelowSlippageFloor(); + error InvalidExpectedSwapOut(); + + /// @notice Restricts a call to either the backend operator (the registry's + /// `safeOperator`) or the Safe itself. The operator drives closes + /// on the Safe's behalf; the Safe can always self-serve. + /// @dev `exit()` is invoked module-mediated (msg.sender == _onBehalfOf), so the + /// registry's `safeOperator` slot is free to be the backend EOA — + /// it is NOT this contract. + modifier onlyOperatorOrSafe(address _onBehalfOf) { + if (_onBehalfOf == address(0)) revert ZeroAddress(); + if (msg.sender != REGISTRY.safeOperator() && msg.sender != _onBehalfOf) { + revert NotAuthorized(); + } + _; + } + + constructor( + INonfungiblePositionManager _positionManager, + IProtocolRegistry _registry, + IERC20 _usdc, + IERC20 _weth, + address _swapRouter, + IUniswapV3Factory _uniswapV3Factory, + address _treasury, + uint16 _performanceFeeBps, + uint16 _feeCollectBps, + uint16 _maxFeeBps, + address _initialAdmin, + address _timelock, + uint128 _minPoolLiquidity, + uint128 _minPositionLiquidity + ) { + if (_initialAdmin == address(0)) revert ZeroAddress(); + if (_timelock == address(0)) revert ZeroAddress(); + if (address(_positionManager) == address(0)) revert ZeroAddress(); + if (address(_registry) == address(0)) revert ZeroAddress(); + if (address(_usdc) == address(0)) revert ZeroAddress(); + if (address(_weth) == address(0)) revert ZeroAddress(); + // Base WETH < Base USDC as addresses; the contract pins WETH=token0 + // / USDC=token1 in `_safeMintLp` and assumes token0=WETH in + // `_collectLp`. Assert at deploy so a wrong-chain deployment fails + // loud rather than silently inverting valuations later. + if (address(_weth) >= address(_usdc)) revert WrongTokenOrder(); + if (_swapRouter == address(0)) revert ZeroAddress(); + if (address(_uniswapV3Factory) == address(0)) revert ZeroAddress(); + if (_treasury == address(0)) revert InvalidTreasury(); + if (_performanceFeeBps > _maxFeeBps) revert FeeAboveMax(); + if (_feeCollectBps > _maxFeeBps) revert FeeAboveMax(); + + POSITION_MANAGER = _positionManager; + REGISTRY = _registry; + USDC = _usdc; + WETH = _weth; + SWAP_ROUTER = _swapRouter; + UNISWAP_V3_FACTORY = _uniswapV3Factory; + MAX_FEE_BPS = _maxFeeBps; + timelock = _timelock; + treasury = _treasury; + performanceFeeBps = _performanceFeeBps; + feeCollectBps = _feeCollectBps; + minPoolLiquidity = _minPoolLiquidity; + minPositionLiquidity = _minPositionLiquidity; + + // Default allow-list: the three liquid Base WETH/USDC tiers. Excludes + // 10000 deliberately — that pool is thin and is the cheap target for + // slot0 manipulation. Owner can flip individual tiers later via + // `setFeeTierAllowed`. + allowedFeeTier[100] = true; + allowedFeeTier[500] = true; + allowedFeeTier[3000] = true; + + _grantRole(DEFAULT_ADMIN_ROLE, _initialAdmin); + _grantRole(CRITICAL_ROLE, _timelock); + // Make CRITICAL_ROLE self-administered so DEFAULT_ADMIN_ROLE cannot + // grant itself CRITICAL_ROLE and bypass the timelock-only setters. + _setRoleAdmin(CRITICAL_ROLE, CRITICAL_ROLE); + + emit TreasuryUpdated(address(0), _treasury); + emit PerformanceFeeBpsUpdated(0, _performanceFeeBps); + emit FeeCollectBpsUpdated(0, _feeCollectBps); + emit MaxSlippageBpsUpdated(0, maxSlippageBps); + emit MinPoolLiquidityUpdated(0, _minPoolLiquidity); + emit MinPositionLiquidityUpdated(0, _minPositionLiquidity); + emit FeeTierAllowedUpdated(100, false, true); + emit FeeTierAllowedUpdated(500, false, true); + emit FeeTierAllowedUpdated(3000, false, true); + } + + // ───────────────────────────────────────────────────────────────────── + // openLp + // ───────────────────────────────────────────────────────────────────── + + /// @notice Atomic LP-mint helper. The Safe must already hold + /// `usdcAmount` USDC (supply ETH as collateral + borrow USDC are + /// performed by the user outside this function — typically a + /// separate Safe transaction). + /// openLp does just two things: + /// 1. Swap half of the held USDC to WETH via the pinned + /// SwapRouter02. The swap calldata is built on-chain so + /// the caller cannot inject a `multicall` / `sweepToken` / + /// alternative recipient. + /// 2. Mint a Uniswap V3 WETH/USDC LP position on the Safe + /// with the swap-output WETH + retained USDC. + /// @dev PRECONDITIONS: + /// (a) The Safe MUST have enabled this contract as a Safe + /// module. Every sub-step is executed via + /// `Safe.execTransactionFromModule`. If RHP is not a + /// module, the first sub-step reverts with + /// `ModuleCallFailed`. + /// (b) The Safe MUST hold at least `usdcAmount` of USDC. + /// (c) The Safe MUST implement `IERC721Receiver` (return + /// the `onERC721Received` selector). Standard Gnosis Safe + /// with `DefaultCallbackHandler` does; Safes without it + /// will revert during NonfungiblePositionManager's `_safeMint` with the inner + /// revert bubbled by. + /// @return tokenId The newly-minted LP NFT id (owned by the Safe). + /// @param expectedSwapOut Off-chain (quoter-derived) expected WETH output + /// for the USDC→WETH swap of `halfUsdc`. Binds + /// `slippageBps` to actual swap protection: the + /// contract requires + /// `swapAmountOutMin >= expectedSwapOut * (10_000 - slippageBps) / 10_000`. + /// Must be > 0. NPM `mintAmount{0,1}Min` are + /// independent caller-supplied guards (kept + /// configurable for one-sided ranges). + function openLp( + address _onBehalfOf, + uint256 usdcAmount, + int24 tickLower, + int24 tickUpper, + uint24 lpPoolFeeTier, + uint256 mintAmount0Min, + uint256 mintAmount1Min, + uint24 swapPoolFeeTier, + uint256 swapAmountOutMin, + uint256 expectedSwapOut, + uint16 slippageBps, + uint256 deadline + ) external nonReentrant onlyOperatorOrSafe(_onBehalfOf) returns (uint256 tokenId) { + if (block.timestamp > deadline) revert DeadlineExpired(); + if (usdcAmount == 0) revert InvalidUsdcAmount(); + if (slippageBps == 0) revert SlippageTooLow(); + if (slippageBps > maxSlippageBps) revert SlippageAboveMax(); + if (!allowedFeeTier[lpPoolFeeTier]) revert FeeTierNotAllowed(); + if (!allowedFeeTier[swapPoolFeeTier]) revert FeeTierNotAllowed(); + if (swapAmountOutMin == 0) revert InvalidSwapAmountOutMin(); + if (expectedSwapOut == 0) revert InvalidExpectedSwapOut(); + // Tie `slippageBps` to the swap min. Without this check `slippageBps` + // is only an entry guard — the swap itself uses `swapAmountOutMin` + // alone. Forcing the caller-supplied min to honor the quoter-derived + // floor means a tight `slippageBps` cannot coexist with a weak + // `swapAmountOutMin`. + if (swapAmountOutMin < (expectedSwapOut * (10_000 - slippageBps)) / 10_000) { + revert SwapMinBelowSlippageFloor(); + } + _validatePool(UNISWAP_V3_FACTORY.getPool(address(USDC), address(WETH), swapPoolFeeTier)); + _validatePool(UNISWAP_V3_FACTORY.getPool(address(WETH), address(USDC), lpPoolFeeTier)); + + uint256 halfUsdc = usdcAmount / 2; + uint256 retainedUsdc = usdcAmount - halfUsdc; + + // Snapshot Safe's WETH balance so we only consume what the swap + // produces (don't drain any pre-existing WETH the Safe held). + uint256 wethBefore = WETH.balanceOf(_onBehalfOf); + + // 1. Build the swap calldata in-contract — caller controls only the + // fee tier, not the selector / tokens / recipient. `amountOutMinimum` + // is caller-supplied (`swapAmountOutMin`, derived off-chain from a + // quote with a tolerance buffer); spot-price-based fallback is + // intentionally removed to prevent in-block manipulation. + bytes memory swapData = abi.encodeWithSelector( + EXACT_INPUT_SINGLE_SELECTOR, + ExactInputSingleParams({ + tokenIn: address(USDC), + tokenOut: address(WETH), + fee: swapPoolFeeTier, + recipient: _onBehalfOf, + amountIn: halfUsdc, + amountOutMinimum: swapAmountOutMin, + sqrtPriceLimitX96: 0 + }) + ); + + // 2. Approve SwapRouter for halfUsdc and run the swap. + _safeApprove(_onBehalfOf, address(USDC), SWAP_ROUTER, halfUsdc, 20); + _safeExec(_onBehalfOf, SWAP_ROUTER, 0, swapData, 3); + _safeApprove(_onBehalfOf, address(USDC), SWAP_ROUTER, 0, 21); // reset + + uint128 wethReceived = (WETH.balanceOf(_onBehalfOf) - wethBefore).toUint128(); + // post-swap zero-output guard. Even with caller-supplied + // `swapAmountOutMin`, an exotic router path could silently succeed + // with zero output; without WETH we'd mint a one-sided USDC LP at a + // tick we likely intended balanced for. + if (wethReceived == 0) revert SwapFailed(); + + // 3. Approve NonfungiblePositionManager and mint the LP. NFT lands on Safe. amount0Min / + // amount1Min come from the caller (typically derived off-chain + // from a quote with a tolerance buffer); set both to 0 to opt out + // of slippage protection (e.g. for one-sided ranges). + _safeApprove(_onBehalfOf, address(WETH), address(POSITION_MANAGER), uint256(wethReceived), 22); + _safeApprove(_onBehalfOf, address(USDC), address(POSITION_MANAGER), retainedUsdc, 23); + + uint128 usedWeth; + uint128 usedUsdc; + (tokenId, usedWeth, usedUsdc) = _safeMintLp( + _onBehalfOf, + lpPoolFeeTier, + tickLower, + tickUpper, + uint256(wethReceived), + retainedUsdc, + mintAmount0Min, + mintAmount1Min, + deadline + ); + + _safeApprove(_onBehalfOf, address(WETH), address(POSITION_MANAGER), 0, 24); + _safeApprove(_onBehalfOf, address(USDC), address(POSITION_MANAGER), 0, 25); + + // 4. Final sanity: NFT must be on the Safe. + if (POSITION_MANAGER.ownerOf(tokenId) != _onBehalfOf) revert LpNotOnSafe(); + + // USDC-equivalent value of the freshly-minted LP position. WETH leg + // is valued using the just-executed swap rate (halfUsdc / wethReceived) + // instead of a separate oracle / slot0 read — same data, no extra gas. + uint128 currentValueUsd6; + { + uint256 wethValueInUsdc = wethReceived > 0 + ? Math.mulDiv(uint256(usedWeth), halfUsdc, uint256(wethReceived)) + : 0; + currentValueUsd6 = (wethValueInUsdc + uint256(usedUsdc)).toUint128(); + } + + // Persist the open-time basis on-chain so `closeLp` can no longer + // accept a caller-attested value. The slot is decremented on each + // partial close and deleted on a full close. + residualBasisUsd6Of[tokenId] = currentValueUsd6; + + emit PositionOpened(_onBehalfOf, tokenId, usdcAmount, usedWeth, usedUsdc, currentValueUsd6); + } + + // ───────────────────────────────────────────────────────────────────── + // closeLp + // ───────────────────────────────────────────────────────────────────── + + /// @notice Atomic LP unwind: harvest fees → decreaseLiquidity (partial or + /// full) → collect principal → (full only) burn → swap WETH leg to + /// USDC. The NFT stays on the Safe throughout; every sub-step is + /// module-mediated. + /// @dev Debt repayment happens outside this function (the user runs + /// Fluid `exit()` via a separate Safe transaction once the USDC + /// lands on the Safe). PRECONDITION: the Safe MUST have enabled + /// this contract as a Safe module. Callable by the Safe itself + /// or the backend operator (`registry.safeOperator()`). + /// @param _onBehalfOf The Safe whose position is being closed. + /// @param tokenId Uniswap V3 LP NFT id (owned by `_onBehalfOf`). + /// @param swapPoolFeeTier Uniswap V3 pool fee tier used to swap the WETH + /// leg back to USDC. + /// @param slippageBps Slippage tolerance in bps applied to the swap's + /// spot-price quote. + /// @param exitBps Fraction of remaining liquidity to remove, in bps. + /// `10_000` = full close (NFT is burned, residual + /// basis deleted); any value in `(0, 10_000)` is a + /// partial close (NFT kept alive, accrued fees still + /// fully harvested up-front, residual basis + /// decremented by `residual * exitBps / 10_000`). + /// Must satisfy `0 < exitBps <= 10_000`. + /// @param minUsdcOut Caller's final-value guard. Reverts `MinUsdcOutNotMet` + /// if gross realized USDC < this. Set to 0 to disable. + /// Bounds the *total* swap output regardless of + /// `swapAmountOutMin` (which only bounds the WETH→USDC + /// swap leg in isolation) + /// @dev The performance-fee basis is read from `residualBasisUsd6Of` + /// (set at `openLp` to the freshly-minted LP's USDC-equivalent + /// value) and prorated by `exitBps`. Caller cannot lie about + /// basis. On full close the slot is deleted; on partial + /// close it is decremented so subsequent closes always price + /// against the correct residual without off-chain bookkeeping. + /// @param expectedSwapOut Off-chain (quoter-derived) expected USDC output + /// of the WETH→USDC unwind swap. Binds + /// `slippageBps` to actual swap protection: the + /// contract requires + /// `swapAmountOutMin >= expectedSwapOut * (10_000 - slippageBps) / 10_000`. + /// Must be > 0. `decreaseAmount{0,1}Min` and + /// `minUsdcOut` are independent caller-supplied + /// guards (separate behavior — see their docs). + function closeLp( + address _onBehalfOf, + uint256 tokenId, + uint24 swapPoolFeeTier, + uint256 swapAmountOutMin, + uint256 expectedSwapOut, + uint16 slippageBps, + uint16 exitBps, + uint256 decreaseAmount0Min, + uint256 decreaseAmount1Min, + uint256 deadline, + uint256 minUsdcOut + ) external nonReentrant onlyOperatorOrSafe(_onBehalfOf) { + if (block.timestamp > deadline) revert DeadlineExpired(); + if (exitBps == 0 || exitBps > 10_000) revert InvalidExitBps(); + if (slippageBps == 0) revert SlippageTooLow(); + if (slippageBps > maxSlippageBps) revert SlippageAboveMax(); + if (!allowedFeeTier[swapPoolFeeTier]) revert FeeTierNotAllowed(); + if (swapAmountOutMin == 0) revert InvalidSwapAmountOutMin(); + if (expectedSwapOut == 0) revert InvalidExpectedSwapOut(); + // Tie `slippageBps` to the swap min. See `openLp` for the rationale. + if (swapAmountOutMin < (expectedSwapOut * (10_000 - slippageBps)) / 10_000) { + revert SwapMinBelowSlippageFloor(); + } + _validatePool(UNISWAP_V3_FACTORY.getPool(address(WETH), address(USDC), swapPoolFeeTier)); + + // Read stored basis FIRST so unknown tokenIds revert with the precise + // `UnknownPosition` error instead of NonfungiblePositionManager's "Invalid token ID" string + // (which would happen if the token-pair check's `positions(tokenId)` + // call ran first). Zero means either never opened via this contract + // or already fully closed. The token-pair check is therefore + // defense-in-depth here (any tokenId in `residualBasisUsd6Of` was + // minted by this contract, so token0/token1 are guaranteed WETH/USDC) + // but the ownership half still adds real value if the Safe + // transferred the NFT out after opening. + uint128 residualBasis = residualBasisUsd6Of[tokenId]; + if (residualBasis == 0) revert UnknownPosition(); + + _requireWethUsdcPositionOwnedBy(_onBehalfOf, tokenId); + uint128 basisForExit = exitBps == 10_000 + ? residualBasis + : Math.mulDiv(uint256(residualBasis), uint256(exitBps), 10_000).toUint128(); + if (exitBps == 10_000) { + delete residualBasisUsd6Of[tokenId]; + } else { + residualBasisUsd6Of[tokenId] = residualBasis - basisForExit; + } + + // Snapshot Safe balances so we measure only what this closeLp adds. + uint256 wethBefore = WETH.balanceOf(_onBehalfOf); + uint256 usdcBefore = USDC.balanceOf(_onBehalfOf); + + // 1. Harvest accrued LP fees first. Before `decreaseLiquidity`, the + // position's `tokensOwed` contains ONLY the accrued fees, so + // `_collectLp` charges `feeCollectBps` on the fees alone (not on + // principal). Done on every close — partial or full — so the user + // always receives the full accrued-fee accounting. + _collectLp(_onBehalfOf, tokenId); + + // 2. Decrease liquidity (partial or full), module-mediated. Principal + // moves into `tokensOwed0`/`tokensOwed1`, ready to be collected. + // On full close (`exitBps == 10_000`), avoid the mulDiv round-down + // and remove exactly `liquidity` so `burn` succeeds. + (, , , , , , , uint128 liquidity, , , , ) = POSITION_MANAGER.positions(tokenId); + uint128 liquidityToRemove = exitBps == 10_000 + ? liquidity + : Math.mulDiv(uint256(liquidity), uint256(exitBps), 10_000).toUint128(); + + // L-4 guard: on partial close, refuse to advance the basis decrement + // without removing any liquidity. `Math.mulDiv` rounds down + // independently for the basis and liquidity legs, so for dust / + // pathologically small `exitBps` the basis can shrink while + // `liquidityToRemove` truncates to zero — letting a later close + // measure profit against an artificially low basis and over-charge + // `performanceFeeBps`. Reverts the basis mutation atomically. + if (exitBps != 10_000 && basisForExit > 0 && liquidityToRemove == 0) { + revert InvalidExitBps(); + } + + if (liquidityToRemove > 0) { + _safeExec( + _onBehalfOf, + address(POSITION_MANAGER), + 0, + abi.encodeCall( + INonfungiblePositionManager.decreaseLiquidity, + ( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidityToRemove, + amount0Min: decreaseAmount0Min, + amount1Min: decreaseAmount1Min, + deadline: deadline + }) + ) + ), + 7 + ); + + // 3. Collect the principal directly to the Safe (no fee — capital). + // Also needed for `burn` to succeed (NonfungiblePositionManager requires tokensOwed == 0). + _collectToRecipient(_onBehalfOf, tokenId, _onBehalfOf, 8); + } + + // 4. Burn the now-empty NFT only on a full close. Partial closes leave + // the position open so it can keep earning fees / be unwound later. + if (exitBps == 10_000) { + _safeExec( + _onBehalfOf, + address(POSITION_MANAGER), + 0, + abi.encodeCall(INonfungiblePositionManager.burn, (tokenId)), + 9 + ); + } + + // 5. Swap the WETH delta on the Safe → USDC, mirroring openLp's + // structure (module-mediated, on-chain calldata). `amountOutMinimum` + // is caller-supplied (`swapAmountOutMin`) — no spot-price-derived + // fallback to prevent in-block manipulation. + uint128 wethToSwap = (WETH.balanceOf(_onBehalfOf) - wethBefore).toUint128(); + if (wethToSwap > 0) { + bytes memory swapData = abi.encodeWithSelector( + EXACT_INPUT_SINGLE_SELECTOR, + ExactInputSingleParams({ + tokenIn: address(WETH), + tokenOut: address(USDC), + fee: swapPoolFeeTier, + recipient: _onBehalfOf, + amountIn: uint256(wethToSwap), + amountOutMinimum: swapAmountOutMin, + sqrtPriceLimitX96: 0 + }) + ); + _safeApprove(_onBehalfOf, address(WETH), SWAP_ROUTER, uint256(wethToSwap), 26); + _safeExec(_onBehalfOf, SWAP_ROUTER, 0, swapData, 10); + _safeApprove(_onBehalfOf, address(WETH), SWAP_ROUTER, 0, 27); + } + + uint128 currentValueUsd6 = (USDC.balanceOf(_onBehalfOf) - usdcBefore).toUint128(); + // caller's final-value guard on gross realized USDC. + // Independent of `swapAmountOutMin` (which only bounds the WETH→USDC + // swap leg) and the perf-fee rate (which can shift via setter). + if (uint256(currentValueUsd6) < minUsdcOut) revert MinUsdcOutNotMet(); + + // 6. Performance fee: charge `performanceFeeBps` on NET PROFIT only — + // realized USDC above the stored basis (already prorated by exitBps + // at the top of the function). No fee on break-even or losses. + // Pulled from the Safe to the treasury module-mediated. + uint128 feeUsd6 = 0; + if (currentValueUsd6 > basisForExit) { + uint256 profit = uint256(currentValueUsd6) - uint256(basisForExit); + feeUsd6 = ((profit * performanceFeeBps) / 10_000).toUint128(); + if (feeUsd6 > 0) { + // non-fatal fee transfer. If the treasury address is + // ever blacklisted (e.g. Circle USDC blacklist), users must + // still be able to exit their positions. Emit on failure for + // off-chain monitoring; zero out feeUsd6 so the event reflects + // what actually moved. + (bool ok, ) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + address(USDC), + 0, + abi.encodeCall(IERC20.transfer, (treasury, uint256(feeUsd6))), + ISafe.Operation.Call + ); + if (!ok) { + emit FeeTransferFailed(_onBehalfOf, tokenId, feeUsd6); + feeUsd6 = 0; + } + } + } + + emit PositionClosed(_onBehalfOf, tokenId, basisForExit, currentValueUsd6, feeUsd6, exitBps); + } + + // ───────────────────────────────────────────────────────────────────── + // collectLp + // ───────────────────────────────────────────────────────────────────── + + /// @notice Harvest the accrued Uniswap V3 fees of an open LP position + /// WITHOUT exiting it (no decreaseLiquidity, no burn). Charges + /// `feeCollectBps` (default 2.5%) in-kind on each collected token + /// to the treasury, and forwards the remainder to the Safe. + /// @dev PRECONDITION: the Safe MUST have enabled this contract as a + /// Safe module — the collect is executed module-mediated so the + /// Safe (NFT owner) authorizes it; fees are collected into this + /// contract so the protocol fee can be skimmed on-chain before + /// the remainder is returned. Callable by the Safe itself or the + /// backend operator (`registry.safeOperator()`). + /// @param _onBehalfOf The Safe that owns the LP position. + /// @param tokenId Uniswap V3 LP NFT id (owned by `_onBehalfOf`). + function collectLp(address _onBehalfOf, uint256 tokenId) external nonReentrant onlyOperatorOrSafe(_onBehalfOf) { + // Reject tokenIds the protocol does not manage: only positions opened + // via `openLp` ever populate `residualBasisUsd6Of`. Without this gate + // the Safe or `safeOperator` could route any Safe-owned WETH/USDC NPM + // NFT through this contract and pay `feeCollectBps` on its fees. + if (residualBasisUsd6Of[tokenId] == 0) revert UnknownPosition(); + // Defense in depth: any tokenId with a stored basis was minted by this + // contract (so the pair is already WETH/USDC), but the Safe could have + // transferred the NFT away afterwards — still verify ownership. + _requireWethUsdcPositionOwnedBy(_onBehalfOf, tokenId); + _collectLp(_onBehalfOf, tokenId); + } + + /// @dev Internal collect-and-charge-fee helper. Routes the position's + /// `tokensOwed` through this contract so `feeCollectBps` can be + /// skimmed before forwarding the remainder to the Safe. Used by + /// `collectLp` (mid-position fee harvest) and by `closeLp` (close- + /// time fee harvest, BEFORE decreaseLiquidity so principal isn't + /// taxed). `tokensOwed` is expected to contain ONLY accrued + /// fees because the protocol owns the position lifecycle — direct + /// external `decreaseLiquidity` on the NFT (which would move + /// principal into `tokensOwed`) is outside the supported flow. + function _collectLp(address _onBehalfOf, uint256 tokenId) internal { + (, , address token0, address token1, uint24 lpPoolFee, , , , , , , ) = POSITION_MANAGER.positions(tokenId); + + uint256 bal0Before = IERC20(token0).balanceOf(address(this)); + uint256 bal1Before = IERC20(token1).balanceOf(address(this)); + + _collectToRecipient(_onBehalfOf, tokenId, address(this), 6); + + uint256 collected0 = IERC20(token0).balanceOf(address(this)) - bal0Before; + uint256 collected1 = IERC20(token1).balanceOf(address(this)) - bal1Before; + + // USDC-equivalent gross value of the collected legs. token0 = WETH + // (since WETH < USDC on Base), so collected0 is valued at the LP + // pool's spot price; collected1 is already in USDC. + uint128 currentValueUsd6; + if (collected0 > 0) { + address pool = UNISWAP_V3_FACTORY.getPool(token0, token1, lpPoolFee); + uint160 sqrtPriceX96 = _validatePool(pool); + // Avoid materializing `sqrtPriceX96 * sqrtPriceX96` (would overflow + // uint256 at extreme prices). Compute `priceX96` via mulDiv then + // value the WETH leg with a second mulDiv. + uint256 priceX96 = Math.mulDiv(uint256(sqrtPriceX96), uint256(sqrtPriceX96), 1 << 96); + uint256 wethValueInUsdc = Math.mulDiv(collected0, priceX96, 1 << 96); + currentValueUsd6 = (wethValueInUsdc + collected1).toUint128(); + } else { + currentValueUsd6 = collected1.toUint128(); + } + + uint256 fee0 = _chargeCollectFee(token0, collected0, _onBehalfOf, tokenId); + uint256 fee1 = _chargeCollectFee(token1, collected1, _onBehalfOf, tokenId); + + emit FeesCollected(_onBehalfOf, tokenId, token0, collected0, fee0, token1, collected1, fee1, currentValueUsd6); + } + + /// @dev Module-mediated `POSITION_MANAGER.collect` for the full owed + /// balance, sent to `recipient`. No fee logic — pure plumbing. + function _collectToRecipient(address _onBehalfOf, uint256 tokenId, address recipient, uint8 step) internal { + _safeExec( + _onBehalfOf, + address(POSITION_MANAGER), + 0, + abi.encodeCall( + INonfungiblePositionManager.collect, + ( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: recipient, + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ) + ), + step + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Owner controls + // ───────────────────────────────────────────────────────────────────── + + function setTreasury(address newTreasury) external onlyRole(CRITICAL_ROLE) { + if (msg.sender != timelock) revert OnlyTimelock(); + if (newTreasury == address(0)) revert InvalidTreasury(); + emit TreasuryUpdated(treasury, newTreasury); + treasury = newTreasury; + } + + function setPerformanceFeeBps(uint16 newPerformanceFeeBps) external onlyRole(CRITICAL_ROLE) { + if (msg.sender != timelock) revert OnlyTimelock(); + if (newPerformanceFeeBps > MAX_FEE_BPS) revert FeeAboveMax(); + emit PerformanceFeeBpsUpdated(performanceFeeBps, newPerformanceFeeBps); + performanceFeeBps = newPerformanceFeeBps; + } + + function setFeeCollectBps(uint16 newFeeCollectBps) external onlyRole(CRITICAL_ROLE) { + if (msg.sender != timelock) revert OnlyTimelock(); + if (newFeeCollectBps > MAX_FEE_BPS) revert FeeAboveMax(); + emit FeeCollectBpsUpdated(feeCollectBps, newFeeCollectBps); + feeCollectBps = newFeeCollectBps; + } + + /// @notice Update the ceiling on caller-supplied `slippageBps` for + /// `openLp` / `closeLp`. Hard-capped at `MAX_SETTABLE_SLIPPAGE_BPS` + /// (1000 = 10%) so even a compromised owner cannot disable + /// slippage protection. + function setMaxSlippageBps(uint16 newMaxSlippageBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newMaxSlippageBps > MAX_SETTABLE_SLIPPAGE_BPS) revert SlippageAboveMax(); + emit MaxSlippageBpsUpdated(maxSlippageBps, newMaxSlippageBps); + maxSlippageBps = newMaxSlippageBps; + } + + /// @notice Enable or disable a Uniswap V3 fee tier for use as either the + /// LP pool or the swap pool in `openLp` / `closeLp`. Constrains + /// routing away from thin pools that are cheap to manipulate. + function setFeeTierAllowed(uint24 feeTier, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) { + bool previousAllowed = allowedFeeTier[feeTier]; + emit FeeTierAllowedUpdated(feeTier, previousAllowed, allowed); + allowedFeeTier[feeTier] = allowed; + } + + /// @notice Update the minimum `pool.liquidity()` floor enforced in + /// `_validatePool`. Set to 0 to disable the check. + function setMinPoolLiquidity(uint128 newMinPoolLiquidity) external onlyRole(DEFAULT_ADMIN_ROLE) { + emit MinPoolLiquidityUpdated(minPoolLiquidity, newMinPoolLiquidity); + minPoolLiquidity = newMinPoolLiquidity; + } + + /// @notice Update the floor on `liquidity` returned by NPM `mint` inside + /// `_safeMintLp`. Set to 0 to disable the check. + function setMinPositionLiquidity(uint128 newMinPositionLiquidity) external onlyRole(DEFAULT_ADMIN_ROLE) { + emit MinPositionLiquidityUpdated(minPositionLiquidity, newMinPositionLiquidity); + minPositionLiquidity = newMinPositionLiquidity; + } + + /// @notice Recover an ERC20 token accidentally sent to or stranded in + /// this contract (e.g. dust from rounding, direct transfers, + /// residue from a failed mid-position step). + /// @dev onlyOwner. Does NOT touch tokens on the Safe — only this + /// contract's own balance. Used as a transparent escape hatch: + /// every rescue emits `TokenRescued`. + function rescueToken(address token, address recipient, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) revert ZeroAddress(); + if (recipient == address(0)) revert ZeroAddress(); + IERC20(token).safeTransfer(recipient, amount); + emit TokenRescued(token, recipient, amount); + } + + /// @notice Recover an ERC721 token accidentally sent to or stranded in + /// this contract (e.g. an LP NFT misdirected here instead of to + /// a Safe). DOES NOT touch NFTs held by a Safe — only this + /// contract's own ownership.. + function rescueERC721(address token, uint256 tokenId, address recipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) revert ZeroAddress(); + if (recipient == address(0)) revert ZeroAddress(); + IERC721(token).safeTransferFrom(address(this), recipient, tokenId); + emit NftRescued(token, recipient, tokenId); + } + + // ───────────────────────────────────────────────────────────────────── + // Internal helpers + // ───────────────────────────────────────────────────────────────────── + + /// @dev Assert that `tokenId` is a WETH/USDC LP position currently owned + /// by `_onBehalfOf`. Called at the top of `closeLp` and `collectLp` + /// to fail fast before any module-mediated NonfungiblePositionManager call. Without this, + /// an operator could route a non-WETH/USDC position through these + /// functions and skim `feeCollectBps` of the non-USDC leg, or + /// process a position the Safe doesn't actually own. + function _requireWethUsdcPositionOwnedBy(address _onBehalfOf, uint256 tokenId) internal view { + (, , address token0, address token1, , , , , , , , ) = POSITION_MANAGER.positions(tokenId); + if (token0 != address(WETH) || token1 != address(USDC)) revert WrongTokenPair(); + if (POSITION_MANAGER.ownerOf(tokenId) != _onBehalfOf) revert LpNotOnSafe(); + } + + /// @notice Validate a Uniswap V3 pool address: must exist, hold the + /// constructor-pinned WETH/USDC pair, be initialized, and (if + /// `minPoolLiquidity > 0`) hold at least that much in-range + /// liquidity. Returns the pool's `sqrtPriceX96` so the caller + /// doesn't need a second SLOAD. + /// @dev The pair assertion (I-3) is defense-in-depth: every caller + /// resolves the pool via `UNISWAP_V3_FACTORY.getPool(WETH, USDC, + /// tier)`, which already returns the canonically-sorted WETH/USDC + /// pool, and `_collectLp` only forwards a position this contract + /// minted. Asserting `token0/token1` here keeps the spot-price + /// read (used to value the WETH leg) provably bound to WETH/USDC, + /// so a future caller cannot route valuation through an unrelated + /// pool. + function _validatePool(address pool) internal view returns (uint160 sqrtPriceX96) { + if (pool == address(0)) revert PoolDoesNotExist(); + if (IUniswapV3Pool(pool).token0() != address(WETH) || IUniswapV3Pool(pool).token1() != address(USDC)) { + revert WrongTokenPair(); + } + (sqrtPriceX96, , , , , , ) = IUniswapV3Pool(pool).slot0(); + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + uint128 floor = minPoolLiquidity; + if (floor > 0 && IUniswapV3Pool(pool).liquidity() < floor) revert PoolTooThin(); + } + + /// @notice Skim `feeCollectBps` of `amount` of `token` to the treasury and + /// forward the remainder to `_onBehalfOf`. Returns the fee actually + /// charged. + /// @dev The treasury leg is non-fatal — same shape as the perf-fee path + /// in `closeLp`. If the treasury hop reverts (e.g. USDC blacklist + /// on the configured treasury), the full `amount` is forwarded to + /// the Safe instead and `fee` is returned as 0 so the + /// `FeesCollected` event accurately reports what moved. The + /// `CollectFeeTransferFailed` event surfaces the failure so + /// off-chain monitoring can rotate the treasury via the + /// timelocked admin path. Without this property, a blacklisted + /// treasury would brick every `closeLp` / `collectLp` with a + /// non-zero USDC fee leg until the timelock unlocked a setter. + function _chargeCollectFee( + address token, + uint256 amount, + address _onBehalfOf, + uint256 tokenId + ) internal returns (uint256 fee) { + if (amount == 0) return 0; + fee = (amount * feeCollectBps) / 10_000; + uint256 toSafe = amount; + if (fee > 0) { + try IERC20(token).transfer(treasury, fee) returns (bool ok) { + if (ok) { + toSafe = amount - fee; + } else { + emit CollectFeeTransferFailed(_onBehalfOf, tokenId, token, fee); + fee = 0; + } + } catch { + emit CollectFeeTransferFailed(_onBehalfOf, tokenId, token, fee); + fee = 0; + } + } + if (toSafe > 0) IERC20(token).safeTransfer(_onBehalfOf, toSafe); + } + + // ───────────────────────────────────────────────────────────────────── + // openLp helpers + // ───────────────────────────────────────────────────────────────────── + + /// @notice Module-mediated `IERC20.approve(spender, amount)` from the Safe. + /// @dev Uses raw `IERC20.approve` rather than `SafeERC20.forceApprove`. + /// This is safe ONLY for tokens that accept non-zero→non-zero + /// approvals — true for canonical WETH and USDC, but NOT for + /// USDT-style two-step tokens. The contract is shape-locked to + /// the constructor-pinned `WETH` / `USDC` immutables; the deploy + /// process is responsible for picking canonical addresses. + /// Deploying with USDT or similar will revert at the first + /// non-zero→non-zero approve. To support such tokens, switch to + /// `SafeERC20.forceApprove` via the Safe module. + function _safeApprove(address _onBehalfOf, address token, address spender, uint256 amount, uint8 step) internal { + bytes memory approveCall = abi.encodeCall(IERC20.approve, (spender, amount)); + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + token, + 0, + approveCall, + ISafe.Operation.Call + ); + if (!ok) { + if (ret.length > 0) { + assembly ("memory-safe") { + revert(add(ret, 0x20), mload(ret)) + } + } + revert ModuleCallFailed(step); + } + } + + /// @notice Generic module-mediated `target.call(value, data)` from the Safe. + /// @dev Uses `execTransactionFromModuleReturnData` and bubbles + /// the inner revert via assembly when present, so production + /// debug surfaces the NonfungiblePositionManager/SwapRouter reason instead of an opaque + /// `ModuleCallFailed(step)`. Falls back to the typed step error + /// if the inner call returned no revert data. + function _safeExec(address _onBehalfOf, address target, uint256 value, bytes memory data, uint8 step) internal { + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + target, + value, + data, + ISafe.Operation.Call + ); + if (!ok) { + if (ret.length > 0) { + assembly ("memory-safe") { + revert(add(ret, 0x20), mload(ret)) + } + } + revert ModuleCallFailed(step); + } + } + + /// @notice Module-mediated NonfungiblePositionManager.mint from the Safe; decodes the return + /// data to surface the new tokenId + amounts consumed. + /// @dev `amount0Min`/`amount1Min` are caller-supplied (derive off-chain + /// from a quote with a tolerance buffer; pass 0 to opt out — e.g. + /// for one-sided ranges). `deadline` is caller-supplied and + /// validated at the public `openLp` entry — passed straight + /// through here. + function _safeMintLp( + address _onBehalfOf, + uint24 lpPoolFeeTier, + int24 tickLower, + int24 tickUpper, + uint256 wethDesired, + uint256 usdcDesired, + uint256 amount0Min, + uint256 amount1Min, + uint256 deadline + ) internal returns (uint256 tokenId, uint128 amount0Used, uint128 amount1Used) { + INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ + token0: address(WETH), + token1: address(USDC), + fee: lpPoolFeeTier, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: wethDesired, + amount1Desired: usdcDesired, + amount0Min: amount0Min, + amount1Min: amount1Min, + recipient: _onBehalfOf, + deadline: deadline + }); + + bytes memory mintCall = abi.encodeCall(INonfungiblePositionManager.mint, params); + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + address(POSITION_MANAGER), + 0, + mintCall, + ISafe.Operation.Call + ); + if (!ok) revert ModuleCallFailed(4); + + uint256 amount0Out; + uint256 amount1Out; + uint128 liquidityMinted; + (tokenId, liquidityMinted, amount0Out, amount1Out) = abi.decode(ret, (uint256, uint128, uint256, uint256)); + // Enforce the minted-liquidity floor. Dust-sized positions are the + // class of LP that can hit the L-4 desync (partial close decrements + // basis but `liquidityToRemove` truncates to zero); keeping mint above + // a configured floor avoids that regime entirely. + if (liquidityMinted < minPositionLiquidity) revert PositionLiquidityTooLow(); + amount0Used = amount0Out.toUint128(); + amount1Used = amount1Out.toUint128(); + } +} diff --git a/contracts/SafeDebtManager.sol b/contracts/SafeDebtManager.sol index b2522f1..8ad0303 100644 --- a/contracts/SafeDebtManager.sol +++ b/contracts/SafeDebtManager.sol @@ -31,6 +31,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { mapping(Protocol => bool) public protocolEnabledForSwitchTo; error InsufficientTokenBalanceAfterSwap(uint256 expected, uint256 actual); + error OnlyTimelock(); struct FlashCallbackData { Protocol fromProtocol; @@ -93,6 +94,12 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { _; } + modifier onlyTimelockCriticalRole() { + if (msg.sender != registry.timelock()) revert OnlyTimelock(); + require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); + _; + } + constructor( address _registry, Protocol[] memory protocols, @@ -156,9 +163,9 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { /// @notice Updates the handler address for a specific protocol /// @param _protocol The protocol to update the handler for /// @param _handler The new handler address - /// @dev Only callable by addresses with CRITICAL_ROLE in ProtocolRegistry. For production, should be behind a timelock. + /// @dev Only callable via the registry's immutable timelock and must also hold CRITICAL_ROLE. /// @dev Allows updating handlers if a bug is found or handler needs upgrade. - function setProtocolHandler(Protocol _protocol, address _handler) external onlyCriticalRole { + function setProtocolHandler(Protocol _protocol, address _handler) external onlyTimelockCriticalRole { require(_handler != address(0), "Invalid handler address"); address oldHandler = protocolHandlers[_protocol]; protocolHandlers[_protocol] = _handler; @@ -287,7 +294,9 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { uint256 numerator = amountInToAssetDecimals * protocolFee; protocolFeeAmount = (numerator + 9999) / 10000; // Ceiling division } - uint256 amountInMax = decoded.paraswapParams.srcAmount == 0 ? amountInToAssetDecimals : decoded.paraswapParams.srcAmount; + uint256 amountInMax = decoded.paraswapParams.srcAmount == 0 + ? amountInToAssetDecimals + : decoded.paraswapParams.srcAmount; uint256 amountTotal = amountInMax + flashloanFee + protocolFeeAmount; address fromHandler = protocolHandlers[decoded.fromProtocol]; @@ -386,7 +395,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { bytes memory _txParams ) internal { require(_txParams.length >= 4, "Invalid calldata"); - + IERC20(srcAsset).forceApprove(registry.paraswapV6(), amount); (bool success, ) = registry.paraswapV6().call(_txParams); require(success, "Token swap by paraSwap failed"); @@ -400,7 +409,6 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { IERC20(srcAsset).forceApprove(registry.paraswapV6(), 0); } - /** * @notice Exits a position by repaying debt and optionally withdrawing collateral * @dev Repays the debt first, then optionally withdraws the collateral assets @@ -423,7 +431,10 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { ) external nonReentrant onlyOwnerOrOperator(_onBehalfOf) whenNotPaused { require(_debtAsset != address(0), "Invalid debt asset address"); // Allow _debtAmount == 0 only when withdrawing collateral (collateral-only withdrawal) - require(_debtAmount >= 10000 || (_debtAmount == 0 && _withdrawCollateral), "Debt amount below minimum threshold"); + require( + _debtAmount >= 10000 || (_debtAmount == 0 && _withdrawCollateral), + "Debt amount below minimum threshold" + ); if (_withdrawCollateral) { require(_collateralAssets.length > 0, "Must withdraw at least one collateral asset"); } @@ -451,10 +462,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { require(transferSuccess, "Transfer debt tokens to contract failed"); (bool repaySuccess, ) = handler.delegatecall( - abi.encodeCall( - IProtocolHandler.repay, - (_debtAsset, repayAmount, _onBehalfOf, _extraData) - ) + abi.encodeCall(IProtocolHandler.repay, (_debtAsset, repayAmount, _onBehalfOf, _extraData)) ); require(repaySuccess, "Repay failed"); diff --git a/contracts/SafeExecTransactionWrapper.sol b/contracts/SafeExecTransactionWrapper.sol index 9980342..6d53fb8 100644 --- a/contracts/SafeExecTransactionWrapper.sol +++ b/contracts/SafeExecTransactionWrapper.sol @@ -12,13 +12,9 @@ import "./interfaces/safe/ISafe.sol"; contract SafeExecTransactionWrapper { error TransactionExecutionFailed(); - event RateHopperSuccess( - bytes metadata - ); + event RateHopperSuccess(bytes metadata); - event RateHopperFailure( - bytes metadata - ); + event RateHopperFailure(bytes metadata); /** * @notice Executes a transaction on a Safe wallet and reverts if it fails diff --git a/contracts/Types.sol b/contracts/Types.sol index 831a24f..158e54e 100644 --- a/contracts/Types.sol +++ b/contracts/Types.sol @@ -20,4 +20,4 @@ struct CollateralAsset { struct ParaswapParams { uint256 srcAmount; bytes swapData; -} \ No newline at end of file +} diff --git a/contracts/dependencies/IERC20.sol b/contracts/dependencies/IERC20.sol index 96b93d0..321dc35 100644 --- a/contracts/dependencies/IERC20.sol +++ b/contracts/dependencies/IERC20.sol @@ -22,10 +22,7 @@ interface IERC20 { * * Emits a {Transfer} event. */ - function transfer( - address recipient, - uint256 amount - ) external returns (bool); + function transfer(address recipient, uint256 amount) external returns (bool); /** * @dev Returns the remaining number of tokens that `spender` will be @@ -34,10 +31,7 @@ interface IERC20 { * * This value changes when {approve} or {transferFrom} are called. */ - function allowance( - address owner, - address spender - ) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); /** * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. @@ -64,11 +58,7 @@ interface IERC20 { * * Emits a {Transfer} event. */ - function transferFrom( - address sender, - address recipient, - uint256 amount - ) external returns (bool); + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); function decimals() external view returns (uint8); @@ -84,9 +74,5 @@ interface IERC20 { * @dev Emitted when the allowance of a `spender` for an `owner` is set by * a call to {approve}. `value` is the new allowance. */ - event Approval( - address indexed owner, - address indexed spender, - uint256 value - ); + event Approval(address indexed owner, address indexed spender, uint256 value); } diff --git a/contracts/dependencies/TransferHelper.sol b/contracts/dependencies/TransferHelper.sol index eb2a2e4..891c107 100644 --- a/contracts/dependencies/TransferHelper.sol +++ b/contracts/dependencies/TransferHelper.sol @@ -1,9 +1,8 @@ - // https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/TransferHelper.sol // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.6.0; -import './IERC20.sol'; +import "./IERC20.sol"; library TransferHelper { /// @notice Transfers tokens from the targeted address to the given destination @@ -12,15 +11,11 @@ library TransferHelper { /// @param from The originating address from which the tokens will be transferred /// @param to The destination address of the transfer /// @param value The amount to be transferred - function safeTransferFrom( - address token, - address from, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = - token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'STF'); + function safeTransferFrom(address token, address from, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call( + abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value) + ); + require(success && (data.length == 0 || abi.decode(data, (bool))), "STF"); } /// @notice Transfers tokens from msg.sender to a recipient @@ -28,13 +23,9 @@ library TransferHelper { /// @param token The contract address of the token which will be transferred /// @param to The recipient of the transfer /// @param value The value of the transfer - function safeTransfer( - address token, - address to, - uint256 value - ) internal { + function safeTransfer(address token, address to, uint256 value) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'ST'); + require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); } /// @notice Approves the stipulated contract to spend the given allowance in the given token @@ -42,13 +33,9 @@ library TransferHelper { /// @param token The contract address of the token to be approved /// @param to The target of the approval /// @param value The amount of the given token the target will be allowed to spend - function safeApprove( - address token, - address to, - uint256 value - ) internal { + function safeApprove(address token, address to, uint256 value) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'SA'); + require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); } /// @notice Transfers ETH to the recipient address @@ -57,6 +44,6 @@ library TransferHelper { /// @param value The value to be transferred function safeTransferETH(address to, uint256 value) internal { (bool success, ) = to.call{value: value}(new bytes(0)); - require(success, 'STE'); + require(success, "STE"); } } diff --git a/contracts/dependencies/morpho/MarketParamsLib.sol b/contracts/dependencies/morpho/MarketParamsLib.sol index 81a22c7..d7a7ac8 100644 --- a/contracts/dependencies/morpho/MarketParamsLib.sol +++ b/contracts/dependencies/morpho/MarketParamsLib.sol @@ -13,14 +13,9 @@ library MarketParamsLib { uint256 internal constant MARKET_PARAMS_BYTES_LENGTH = 5 * 32; /// @notice Returns the id of the market `marketParams`. - function id( - MarketParams memory marketParams - ) internal pure returns (Id marketParamsId) { + function id(MarketParams memory marketParams) internal pure returns (Id marketParamsId) { assembly ("memory-safe") { - marketParamsId := keccak256( - marketParams, - MARKET_PARAMS_BYTES_LENGTH - ) + marketParamsId := keccak256(marketParams, MARKET_PARAMS_BYTES_LENGTH) } } } diff --git a/contracts/dependencies/uniswapV3/PoolAddress.sol b/contracts/dependencies/uniswapV3/PoolAddress.sol index 34c8404..469355a 100644 --- a/contracts/dependencies/uniswapV3/PoolAddress.sol +++ b/contracts/dependencies/uniswapV3/PoolAddress.sol @@ -17,11 +17,7 @@ library PoolAddress { /// @param tokenB The second token of a pool, unsorted /// @param fee The fee level of the pool /// @return Poolkey The pool details with ordered token0 and token1 assignments - function getPoolKey( - address tokenA, - address tokenB, - uint24 fee - ) internal pure returns (PoolKey memory) { + function getPoolKey(address tokenA, address tokenB, uint24 fee) internal pure returns (PoolKey memory) { if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); return PoolKey({token0: tokenA, token1: tokenB, fee: fee}); } @@ -32,20 +28,21 @@ library PoolAddress { /// @return pool The contract address of the V3 pool function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) { require(key.token0 < key.token1); - + // NOTE: added uint160 cast to avoid error due to solidity version mismatch - pool = address(uint160( - uint256( - keccak256( - abi.encodePacked( - hex'ff', - factory, - keccak256(abi.encode(key.token0, key.token1, key.fee)), - POOL_INIT_CODE_HASH + pool = address( + uint160( + uint256( + keccak256( + abi.encodePacked( + hex"ff", + factory, + keccak256(abi.encode(key.token0, key.token1, key.fee)), + POOL_INIT_CODE_HASH + ) ) ) ) - ) ); } } diff --git a/contracts/interfaces/IProtocolRegistry.sol b/contracts/interfaces/IProtocolRegistry.sol new file mode 100644 index 0000000..d98a671 --- /dev/null +++ b/contracts/interfaces/IProtocolRegistry.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @notice Minimal interface exposing the ProtocolRegistry surface that +/// RatehopperUniV3Positions needs — only the `safeOperator` getter used to +/// authorize callers of closeLp(). +interface IProtocolRegistry { + function safeOperator() external view returns (address); +} diff --git a/contracts/interfaces/IWETH9.sol b/contracts/interfaces/IWETH9.sol index a38a4df..db6dba3 100644 --- a/contracts/interfaces/IWETH9.sol +++ b/contracts/interfaces/IWETH9.sol @@ -4,4 +4,4 @@ pragma solidity ^0.8.28; interface IWETH9 { function deposit() external payable; function withdraw(uint256 amount) external; -} \ No newline at end of file +} diff --git a/contracts/interfaces/fluid/IFluidVaultResolver.sol b/contracts/interfaces/fluid/IFluidVaultResolver.sol index 56d71df..62e43c3 100644 --- a/contracts/interfaces/fluid/IFluidVaultResolver.sol +++ b/contracts/interfaces/fluid/IFluidVaultResolver.sol @@ -11,5 +11,7 @@ interface IFluidVaultResolver { view returns (Structs.UserPosition[] memory userPositions_, Structs.VaultEntireData[] memory vaultsData_); - function positionByNftId(uint256 nftId) external view returns (Structs.UserPosition memory userPosition_, Structs.VaultEntireData memory vaultData_); + function positionByNftId( + uint256 nftId + ) external view returns (Structs.UserPosition memory userPosition_, Structs.VaultEntireData memory vaultData_); } diff --git a/contracts/interfaces/morpho/IMorpho.sol b/contracts/interfaces/morpho/IMorpho.sol index ec96f59..fa36bde 100644 --- a/contracts/interfaces/morpho/IMorpho.sol +++ b/contracts/interfaces/morpho/IMorpho.sol @@ -72,10 +72,7 @@ interface IMorphoBase { /// @notice Whether `authorized` is authorized to modify `authorizer`'s position on all markets. /// @dev Anyone is authorized to modify their own positions, regardless of this variable. - function isAuthorized( - address authorizer, - address authorized - ) external view returns (bool); + function isAuthorized(address authorizer, address authorized) external view returns (bool); /// @notice The `authorizer`'s current nonce. Used to prevent replay attacks with EIP-712 signatures. function nonce(address authorizer) external view returns (uint256); @@ -280,19 +277,12 @@ interface IMorphoBase { /// @param token The token to flash loan. /// @param assets The amount of assets to flash loan. /// @param data Arbitrary data to pass to the `onMorphoFlashLoan` callback. - function flashLoan( - address token, - uint256 assets, - bytes calldata data - ) external; + function flashLoan(address token, uint256 assets, bytes calldata data) external; /// @notice Sets the authorization for `authorized` to manage `msg.sender`'s positions. /// @param authorized The authorized address. /// @param newIsAuthorized The new authorization status. - function setAuthorization( - address authorized, - bool newIsAuthorized - ) external; + function setAuthorization(address authorized, bool newIsAuthorized) external; /// @notice Sets the authorization for `authorization.authorized` to manage `authorization.authorizer`'s positions. /// @dev Warning: Reverts if the signature has already been submitted. @@ -300,18 +290,13 @@ interface IMorphoBase { /// @dev The nonce is passed as argument to be able to revert with a different error message. /// @param authorization The `Authorization` struct. /// @param signature The signature. - function setAuthorizationWithSig( - Authorization calldata authorization, - Signature calldata signature - ) external; + function setAuthorizationWithSig(Authorization calldata authorization, Signature calldata signature) external; /// @notice Accrues interest for the given market `marketParams`. function accrueInterest(MarketParams memory marketParams) external; /// @notice Returns the data stored on the different `slots`. - function extSloads( - bytes32[] memory slots - ) external view returns (bytes32[] memory); + function extSloads(bytes32[] memory slots) external view returns (bytes32[] memory); } /// @dev This interface is inherited by Morpho so that function signatures are checked by the compiler. @@ -323,14 +308,7 @@ interface IMorphoStaticTyping is IMorphoBase { function position( Id id, address user - ) - external - view - returns ( - uint256 supplyShares, - uint128 borrowShares, - uint128 collateral - ); + ) external view returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral); /// @notice The state of the market corresponding to `id`. /// @dev Warning: `totalSupplyAssets` does not contain the accrued interest since the last interest accrual. @@ -356,16 +334,7 @@ interface IMorphoStaticTyping is IMorphoBase { /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. function idToMarketParams( Id id - ) - external - view - returns ( - address loanToken, - address collateralToken, - address oracle, - address irm, - uint256 lltv - ); + ) external view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv); } /// @title IMorpho @@ -376,10 +345,7 @@ interface IMorpho is IMorphoBase { /// @notice The state of the position of `user` on the market corresponding to `id`. /// @dev Warning: For `feeRecipient`, `p.supplyShares` does not contain the accrued shares since the last interest /// accrual. - function position( - Id id, - address user - ) external view returns (Position memory p); + function position(Id id, address user) external view returns (Position memory p); /// @notice The state of the market corresponding to `id`. /// @dev Warning: `m.totalSupplyAssets` does not contain the accrued interest since the last interest accrual. @@ -391,7 +357,5 @@ interface IMorpho is IMorphoBase { /// @notice The market params corresponding to `id`. /// @dev This mapping is not used in Morpho. It is there to enable reducing the cost associated to calldata on layer /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. - function idToMarketParams( - Id id - ) external view returns (MarketParams memory); + function idToMarketParams(Id id) external view returns (MarketParams memory); } diff --git a/contracts/interfaces/uniswapV3/IApproveAndCall.sol b/contracts/interfaces/uniswapV3/IApproveAndCall.sol index fedf2dd..2a5bcaf 100644 --- a/contracts/interfaces/uniswapV3/IApproveAndCall.sol +++ b/contracts/interfaces/uniswapV3/IApproveAndCall.sol @@ -3,7 +3,13 @@ pragma solidity >=0.7.6; pragma abicoder v2; interface IApproveAndCall { - enum ApprovalType {NOT_REQUIRED, MAX, MAX_MINUS_ONE, ZERO_THEN_MAX, ZERO_THEN_MAX_MINUS_ONE} + enum ApprovalType { + NOT_REQUIRED, + MAX, + MAX_MINUS_ONE, + ZERO_THEN_MAX, + ZERO_THEN_MAX_MINUS_ONE + } /// @dev Lens to be called off-chain to determine which (if any) of the relevant approval functions should be called /// @param token The token to approve diff --git a/contracts/interfaces/uniswapV3/IMulticallExtended.sol b/contracts/interfaces/uniswapV3/IMulticallExtended.sol index 1e1a2c4..28f18c9 100644 --- a/contracts/interfaces/uniswapV3/IMulticallExtended.sol +++ b/contracts/interfaces/uniswapV3/IMulticallExtended.sol @@ -2,7 +2,7 @@ pragma solidity >=0.7.5; pragma abicoder v2; -import '@uniswap/v3-periphery/contracts/interfaces/IMulticall.sol'; +import "@uniswap/v3-periphery/contracts/interfaces/IMulticall.sol"; /// @title MulticallExtended interface /// @notice Enables calling multiple methods in a single call to the contract with optional validation @@ -19,8 +19,8 @@ interface IMulticallExtended is IMulticall { /// @param previousBlockhash The expected parent blockHash /// @param data The encoded function data for each of the calls to make to this contract /// @return results The results from each of the calls passed in via data - function multicall(bytes32 previousBlockhash, bytes[] calldata data) - external - payable - returns (bytes[] memory results); + function multicall( + bytes32 previousBlockhash, + bytes[] calldata data + ) external payable returns (bytes[] memory results); } diff --git a/contracts/interfaces/uniswapV3/INonfungiblePositionManager.sol b/contracts/interfaces/uniswapV3/INonfungiblePositionManager.sol new file mode 100644 index 0000000..a81e931 --- /dev/null +++ b/contracts/interfaces/uniswapV3/INonfungiblePositionManager.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.28; + +// Minimal vendored interface for Uniswap V3's Nonfungible Position Manager. +// Only the surface RatehopperUniV3Positions calls is declared, to sidestep the +// upstream v3-periphery package's dependency on the OZ v4 ERC721 file layout. +// Canonical implementation: +// https://github.com/Uniswap/v3-periphery/blob/main/contracts/interfaces/INonfungiblePositionManager.sol +interface INonfungiblePositionManager { + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + function mint( + MintParams calldata params + ) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + + function positions( + uint256 tokenId + ) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); + + function decreaseLiquidity( + DecreaseLiquidityParams calldata params + ) external payable returns (uint256 amount0, uint256 amount1); + + function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); + + function burn(uint256 tokenId) external payable; + + function ownerOf(uint256 tokenId) external view returns (address); + + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + function transferFrom(address from, address to, uint256 tokenId) external; +} diff --git a/contracts/interfaces/uniswapV3/ISwapRouter02.sol b/contracts/interfaces/uniswapV3/ISwapRouter02.sol index 688fc3d..31cc5b2 100644 --- a/contracts/interfaces/uniswapV3/ISwapRouter02.sol +++ b/contracts/interfaces/uniswapV3/ISwapRouter02.sol @@ -2,14 +2,12 @@ pragma solidity >=0.7.5; pragma abicoder v2; -import '@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.sol'; +import "@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.sol"; -import './IV2SwapRouter.sol'; -import './IV3SwapRouter.sol'; -import './IApproveAndCall.sol'; -import './IMulticallExtended.sol'; +import "./IV2SwapRouter.sol"; +import "./IV3SwapRouter.sol"; +import "./IApproveAndCall.sol"; +import "./IMulticallExtended.sol"; /// @title Router token swapping functionality -interface ISwapRouter02 is IV2SwapRouter, IV3SwapRouter, IApproveAndCall, IMulticallExtended, ISelfPermit { - -} +interface ISwapRouter02 is IV2SwapRouter, IV3SwapRouter, IApproveAndCall, IMulticallExtended, ISelfPermit {} diff --git a/contracts/interfaces/uniswapV3/IUniswapV3Factory.sol b/contracts/interfaces/uniswapV3/IUniswapV3Factory.sol new file mode 100644 index 0000000..f68e8dc --- /dev/null +++ b/contracts/interfaces/uniswapV3/IUniswapV3Factory.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/** + * @dev Simplified Uniswap V3 factory interface — only the pool-lookup surface + * this repo uses (e.g. RatehopperUniV3Positions reads the swap pool's spot + * price for on-chain slippage computation). + */ +interface IUniswapV3Factory { + /// @notice Returns the pool address for a given pair of tokens and a fee, or address(0) if it doesn't exist. + /// @dev tokenA and tokenB may be passed in either token0/token1 order. + /// @param tokenA The contract address of either token0 or token1. + /// @param tokenB The contract address of the other token. + /// @param fee The fee tier of the pool (in hundredths of a basis point — e.g. 500 = 0.05%). + /// @return pool The pool address. + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); +} diff --git a/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol b/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol index a1b7213..d80798f 100644 --- a/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol +++ b/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol @@ -2,7 +2,7 @@ pragma solidity >=0.5.0; /** - * @dev Simplified IUniswapV3Pool interface with only the flash action. + * @dev Simplified IUniswapV3Pool interface — only the surface this repo uses. */ interface IUniswapV3Pool { /// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback @@ -13,15 +13,37 @@ interface IUniswapV3Pool { /// @param amount0 The amount of token0 to send /// @param amount1 The amount of token1 to send /// @param data Any data to be passed through to the callback - function flash( - address recipient, - uint256 amount0, - uint256 amount1, - bytes calldata data - ) external; + function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external; /// @notice Returns the contract address of token0 /// @return The address of token0 function token0() external view returns (address); + /// @notice Returns the contract address of token1 + /// @return The address of token1 + function token1() external view returns (address); + + /// @notice The current price + tick of the pool, packed for gas efficiency. + /// @return sqrtPriceX96 The current price of the pool as a Q64.96 sqrt(token1/token0). + /// @return tick The current tick of the pool. + /// @return observationIndex The index of the last observation. + /// @return observationCardinality The current maximum number of observations stored. + /// @return observationCardinalityNext The next maximum number of observations to be written. + /// @return feeProtocol The protocol fee for both tokens of the pool (packed). + /// @return unlocked Whether the pool is currently locked to reentrancy. + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); + + /// @notice The currently in-range liquidity available to the pool. + function liquidity() external view returns (uint128); } diff --git a/contracts/interfaces/uniswapV3/IV3SwapRouter.sol b/contracts/interfaces/uniswapV3/IV3SwapRouter.sol index f5d6459..fc5dc66 100644 --- a/contracts/interfaces/uniswapV3/IV3SwapRouter.sol +++ b/contracts/interfaces/uniswapV3/IV3SwapRouter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.7.5; pragma abicoder v2; -import '@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol'; +import "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol"; /// @title Router token swapping functionality /// @notice Functions for swapping tokens via Uniswap V3 diff --git a/contracts/mock/MaliciousContract.sol b/contracts/mock/MaliciousContract.sol index b5a7014..1fff719 100644 --- a/contracts/mock/MaliciousContract.sol +++ b/contracts/mock/MaliciousContract.sol @@ -7,21 +7,20 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract MaliciousContract is IProtocolHandler { address public maliciousAddress; - + constructor(address _maliciousAddress) { maliciousAddress = _maliciousAddress; } - - function getDebtAmount(address /* asset */, address /* user */, bytes calldata /* extraData */) external pure returns (uint256) { - return type(uint256).max; - } - function supply( - address asset, - uint256 amount, - address /* onBehalfOf */, + function getDebtAmount( + address /* asset */, + address /* user */, bytes calldata /* extraData */ - ) external { + ) external pure returns (uint256) { + return type(uint256).max; + } + + function supply(address asset, uint256 amount, address /* onBehalfOf */, bytes calldata /* extraData */) external { // Try to transfer tokens to malicious address instead of supplying IERC20(asset).transfer(maliciousAddress, amount); } @@ -76,12 +75,7 @@ contract MaliciousContract is IProtocolHandler { IERC20(toAsset).transfer(maliciousAddress, amountTotal); } - function repay( - address asset, - uint256 amount, - address /* onBehalfOf */, - bytes calldata /* extraData */ - ) external { + function repay(address asset, uint256 amount, address /* onBehalfOf */, bytes calldata /* extraData */) external { // Try to transfer tokens to malicious address IERC20(asset).transfer(maliciousAddress, amount); } @@ -98,4 +92,4 @@ contract MaliciousContract is IProtocolHandler { IERC20(asset).transfer(maliciousAddress, balance); } } -} \ No newline at end of file +} diff --git a/contracts/mock/MimicUniswapV3Contract.sol b/contracts/mock/MimicUniswapV3Contract.sol index 0c936c5..444fecc 100644 --- a/contracts/mock/MimicUniswapV3Contract.sol +++ b/contracts/mock/MimicUniswapV3Contract.sol @@ -14,29 +14,25 @@ contract MaliciousUniswapV3Pool { address public immutable token0; address public immutable token1; uint24 public immutable fee; - + // Target handler to attack address public targetHandler; - + constructor(address _token0, address _token1, uint24 _fee, address _targetHandler) { token0 = _token0; token1 = _token1; fee = _fee; targetHandler = _targetHandler; } - + /** * @dev Attempt to call the handler directly, mimicking a legitimate Uniswap callback */ - function attemptMaliciousBorrow( - address asset, - uint256 amount, - address onBehalfOf - ) external { + function attemptMaliciousBorrow(address asset, uint256 amount, address onBehalfOf) external { // This call should fail because this contract is not deployed by the Uniswap factory IProtocolHandler(targetHandler).borrow(asset, amount, onBehalfOf, "0x"); } - + /** * @dev Attempt to drain funds by calling switchFrom without proper authorization */ @@ -48,15 +44,11 @@ contract MaliciousUniswapV3Pool { ) external { IProtocolHandler(targetHandler).switchFrom(fromAsset, amount, onBehalfOf, collateralAssets, "0x"); } - + /** * @dev Attempt to manipulate supply/borrow without proper validation */ - function attemptMaliciousSupply( - address asset, - uint256 amount, - address onBehalfOf - ) external { + function attemptMaliciousSupply(address asset, uint256 amount, address onBehalfOf) external { IProtocolHandler(targetHandler).supply(asset, amount, onBehalfOf, "0x"); } -} \ No newline at end of file +} diff --git a/contracts/mocks/RatehopperMocks.sol b/contracts/mocks/RatehopperMocks.sol new file mode 100644 index 0000000..740835d --- /dev/null +++ b/contracts/mocks/RatehopperMocks.sol @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {INonfungiblePositionManager} from "../interfaces/uniswapV3/INonfungiblePositionManager.sol"; + +// ───────────────────────────────────────────────────────────────────────── +// Mocks for RatehopperUniV3Positions unit/branch-coverage tests. +// +// These let the full openLp / closeLp / collectLp lifecycle — and every +// defensive revert branch — run deterministically on a plain Hardhat network +// WITHOUT a forked Base mainnet, a real Gnosis Safe, or live Uniswap V3 +// contracts. They are TEST-ONLY and never deployed to production. +// ───────────────────────────────────────────────────────────────────────── + +/// @notice Minimal mintable ERC20. `falseTransferTo` makes `transfer` return +/// `false` (without reverting) for a single recipient — used to drive +/// the non-reverting `transfer-returns-false` branch in +/// `_chargeCollectFee`. +contract MockERC20 is IERC20 { + string public name; + string public symbol; + uint8 public immutable decimals; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + address public falseTransferTo; + address public revertTransferTo; + + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + function setFalseTransferTo(address account) external { + falseTransferTo = account; + } + + function setRevertTransferTo(address account) external { + revertTransferTo = account; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external override returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external override returns (bool) { + // Simulate a token that reverts for a blacklisted destination (e.g. + // Circle USDC) — drives the revert/catch fee branches. + require(to != revertTransferTo, "blacklisted"); + // Simulate a non-compliant token that silently returns false for a + // blacklisted destination instead of reverting. + if (to == falseTransferTo) return false; + _move(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= amount, "ERC20: allowance"); + allowance[from][msg.sender] = allowed - amount; + } + _move(from, to, amount); + return true; + } + + function _move(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "ERC20: balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} + +/// @notice Stand-in for `IProtocolRegistry` exposing only `safeOperator`. +contract MockRegistry { + address public safeOperator; + + function setOperator(address operator) external { + safeOperator = operator; + } +} + +/// @notice Minimal Safe module executor. Mirrors +/// `execTransactionFromModuleReturnData` by performing the inner call +/// and bubbling up `(success, returndata)`. Per-target failure modes +/// let tests exercise the `ModuleCallFailed` (typed) and revert-bubble +/// branches of `_safeApprove` / `_safeExec` / `_safeMintLp`. +contract MockSafeHarness { + // 0 = execute normally, 1 = fail with empty returndata, 2 = fail with `failData`. + mapping(address => uint8) public failMode; + bytes public failData; + + receive() external payable {} + + function setFail(address target, uint8 mode) external { + failMode[target] = mode; + } + + function setFailData(bytes calldata data) external { + failData = data; + } + + function execTransactionFromModuleReturnData( + address to, + uint256 value, + bytes memory data, + uint8 /* operation */ + ) external returns (bool success, bytes memory returnData) { + uint8 mode = failMode[to]; + if (mode == 1) return (false, ""); + if (mode == 2) return (false, failData); + (success, returnData) = to.call{value: value}(data); + } +} + +/// @notice Mirrors SwapRouter02's `exactInputSingle` selector (0x04e45aaf). +/// Pulls `amountIn` of `tokenIn` from the caller (the Safe) and pays a +/// configurable `output` of `tokenOut` to the recipient. `output == 0` +/// drives the `SwapFailed` branch. +contract MockSwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + uint256 public output; + bool public pullInput = true; + + function setOutput(uint256 newOutput) external { + output = newOutput; + } + + function setPullInput(bool enabled) external { + pullInput = enabled; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut) { + if (pullInput && params.amountIn > 0) { + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + } + amountOut = output; + if (amountOut > 0) { + IERC20(params.tokenOut).transfer(params.recipient, amountOut); + } + } +} + +/// @notice Configurable Uniswap V3 pool stub for `_validatePool`. +contract MockUniswapV3Pool { + address public token0; + address public token1; + uint160 public sqrtPriceX96; + uint128 public liquidity; + + constructor(address _token0, address _token1, uint160 _sqrtPriceX96, uint128 _liquidity) { + token0 = _token0; + token1 = _token1; + sqrtPriceX96 = _sqrtPriceX96; + liquidity = _liquidity; + } + + function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool) { + return (sqrtPriceX96, 0, 0, 0, 0, 0, true); + } +} + +/// @notice Factory stub returning a single configurable pool for any lookup. +contract MockUniswapV3Factory { + address public pool; + + function setPool(address newPool) external { + pool = newPool; + } + + function getPool(address, address, uint24) external view returns (address) { + return pool; + } +} + +/// @notice Faithful-enough Nonfungible Position Manager: tracks per-tokenId +/// owner / pair / liquidity / principal / owed, and implements +/// mint / positions / ownerOf / collect / decreaseLiquidity / burn so +/// the full LP lifecycle can be driven on a plain network. +contract MockNonfungiblePositionManager { + struct Position { + address owner; + address token0; + address token1; + uint24 fee; + uint128 liquidity; + uint128 owed0; + uint128 owed1; + uint128 principal0; + uint128 principal1; + bool exists; + } + + mapping(uint256 => Position) public positionsData; + uint256 public nextId = 1; + + // Config applied to the next `mint`. + uint128 public mintLiquidity = 1_000_000; + address public mintOwnerOverride; + bool public pullOnMint = true; + + function setMintLiquidity(uint128 value) external { + mintLiquidity = value; + } + + function setMintOwnerOverride(address account) external { + mintOwnerOverride = account; + } + + function setPullOnMint(bool enabled) external { + pullOnMint = enabled; + } + + /// @dev Seed a position directly (for collectLp/closeLp tests that bypass + /// openLp via a storage-overridden basis). + function seedPosition( + uint256 tokenId, + address owner, + address token0, + address token1, + uint24 fee, + uint128 liquidity, + uint128 principal0, + uint128 principal1 + ) external { + positionsData[tokenId] = Position(owner, token0, token1, fee, liquidity, 0, 0, principal0, principal1, true); + } + + function setOwed(uint256 tokenId, uint128 owed0, uint128 owed1) external { + positionsData[tokenId].owed0 = owed0; + positionsData[tokenId].owed1 = owed1; + } + + function setTokens(uint256 tokenId, address token0, address token1) external { + positionsData[tokenId].token0 = token0; + positionsData[tokenId].token1 = token1; + } + + function setOwner(uint256 tokenId, address owner) external { + positionsData[tokenId].owner = owner; + } + + function mint( + INonfungiblePositionManager.MintParams calldata params + ) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) { + tokenId = nextId++; + amount0 = params.amount0Desired; + amount1 = params.amount1Desired; + if (pullOnMint) { + if (amount0 > 0) IERC20(params.token0).transferFrom(msg.sender, address(this), amount0); + if (amount1 > 0) IERC20(params.token1).transferFrom(msg.sender, address(this), amount1); + } + liquidity = mintLiquidity; + address owner = mintOwnerOverride == address(0) ? params.recipient : mintOwnerOverride; + positionsData[tokenId] = Position( + owner, + params.token0, + params.token1, + params.fee, + liquidity, + 0, + 0, + uint128(amount0), + uint128(amount1), + true + ); + } + + function positions( + uint256 tokenId + ) + external + view + returns (uint96, address, address, address, uint24, int24, int24, uint128, uint256, uint256, uint128, uint128) + { + Position memory p = positionsData[tokenId]; + return (0, address(0), p.token0, p.token1, p.fee, int24(0), int24(0), p.liquidity, 0, 0, p.owed0, p.owed1); + } + + function ownerOf(uint256 tokenId) external view returns (address) { + return positionsData[tokenId].owner; + } + + function collect( + INonfungiblePositionManager.CollectParams calldata params + ) external payable returns (uint256 amount0, uint256 amount1) { + Position storage p = positionsData[params.tokenId]; + amount0 = p.owed0; + amount1 = p.owed1; + p.owed0 = 0; + p.owed1 = 0; + if (amount0 > 0) IERC20(p.token0).transfer(params.recipient, amount0); + if (amount1 > 0) IERC20(p.token1).transfer(params.recipient, amount1); + } + + function decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams calldata params + ) external payable returns (uint256 amount0, uint256 amount1) { + Position storage p = positionsData[params.tokenId]; + require(params.liquidity <= p.liquidity, "liquidity"); + if (p.liquidity > 0) { + amount0 = (uint256(p.principal0) * params.liquidity) / p.liquidity; + amount1 = (uint256(p.principal1) * params.liquidity) / p.liquidity; + } + p.principal0 -= uint128(amount0); + p.principal1 -= uint128(amount1); + p.owed0 += uint128(amount0); + p.owed1 += uint128(amount1); + p.liquidity -= params.liquidity; + } + + function burn(uint256 tokenId) external payable { + Position storage p = positionsData[tokenId]; + require(p.liquidity == 0, "not empty"); + delete positionsData[tokenId]; + } +} + +/// @notice Minimal ERC721 for the `rescueERC721` happy path. +contract MockERC721 { + mapping(uint256 => address) public ownerOf; + + function mint(address to, uint256 tokenId) external { + ownerOf[tokenId] = to; + } + + function safeTransferFrom(address from, address to, uint256 tokenId) external { + require(ownerOf[tokenId] == from, "not owner"); + ownerOf[tokenId] = to; + } +} diff --git a/contracts/protocols/AaveV3Handler.sol b/contracts/protocols/AaveV3Handler.sol index 4ba224f..119e806 100644 --- a/contracts/protocols/AaveV3Handler.sol +++ b/contracts/protocols/AaveV3Handler.sol @@ -37,8 +37,6 @@ contract AaveV3Handler is BaseProtocolHandler { return currentVariableDebt; } - - function switchIn( address fromAsset, address toAsset, @@ -119,7 +117,12 @@ contract AaveV3Handler is BaseProtocolHandler { aaveV3Pool.borrow(toAsset, amount, 2, 0, onBehalfOf); } - function supply(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external override onlyAuthorizedCaller(onBehalfOf) { + function supply( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); IERC20(asset).forceApprove(address(aaveV3Pool), amount); @@ -127,13 +130,23 @@ contract AaveV3Handler is BaseProtocolHandler { IERC20(asset).forceApprove(address(aaveV3Pool), 0); } - function borrow(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external override onlyAuthorizedCaller(onBehalfOf) { + function borrow( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); aaveV3Pool.borrow(asset, amount, 2, 0, onBehalfOf); } - function repay(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) public onlyAuthorizedCaller(onBehalfOf) { + function repay( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) public onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); // Skip repayment if amount is 1 wei or less to prevent Aave v3 InvalidBurnAmount error @@ -146,7 +159,12 @@ contract AaveV3Handler is BaseProtocolHandler { IERC20(asset).forceApprove(address(aaveV3Pool), 0); } - function withdraw(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external override onlyAuthorizedCaller(onBehalfOf) { + function withdraw( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); // Get aToken address for the asset @@ -174,13 +192,8 @@ contract AaveV3Handler is BaseProtocolHandler { address user, DataTypes.ReserveData memory reserveData ) internal view returns (uint256) { - ( - uint256 totalCollateralBase, - uint256 totalDebtBase, - , - uint256 currentLiquidationThreshold, - , - ) = aaveV3Pool.getUserAccountData(user); + (uint256 totalCollateralBase, uint256 totalDebtBase, , uint256 currentLiquidationThreshold, , ) = aaveV3Pool + .getUserAccountData(user); uint256 userATokenBalance = IERC20(reserveData.aTokenAddress).balanceOf(user); uint256 availableLiquidity = IERC20(asset).balanceOf(reserveData.aTokenAddress); @@ -194,7 +207,8 @@ contract AaveV3Handler is BaseProtocolHandler { (, , uint256 assetLT, , , , , , , ) = dataProvider.getReserveConfigurationData(asset); if (assetLT == 0) return 0; - uint256 currentWeightedCollateralBase = (totalCollateralBase * currentLiquidationThreshold) / LIQ_THRESHOLD_PRECISION; + uint256 currentWeightedCollateralBase = (totalCollateralBase * currentLiquidationThreshold) / + LIQ_THRESHOLD_PRECISION; if (currentWeightedCollateralBase <= totalDebtBase) return 0; diff --git a/contracts/protocols/BaseProtocolHandler.sol b/contracts/protocols/BaseProtocolHandler.sol index 7b31d69..738dee6 100644 --- a/contracts/protocols/BaseProtocolHandler.sol +++ b/contracts/protocols/BaseProtocolHandler.sol @@ -13,7 +13,6 @@ import "../ProtocolRegistry.sol"; * @notice This contract provides common functionality and security modifiers for all protocol handlers */ abstract contract BaseProtocolHandler is IProtocolHandler { - /// @notice The Uniswap V3 factory address used for pool validation address public immutable uniswapV3Factory; @@ -61,7 +60,7 @@ abstract contract BaseProtocolHandler is IProtocolHandler { uniswapV3Factory = _uniswapV3Factory; registry = ProtocolRegistry(_registry); } - + /** * @dev Internal function to validate collateral assets array * @param collateralAssets Array of collateral assets to validate @@ -69,10 +68,10 @@ abstract contract BaseProtocolHandler is IProtocolHandler { function _validateCollateralAssets(CollateralAsset[] memory collateralAssets) internal pure { require(collateralAssets.length > 0, "No collateral assets provided"); require(collateralAssets.length <= 20, "Too many collateral assets"); - + for (uint256 i = 0; i < collateralAssets.length; i++) { require(collateralAssets[i].asset != address(0), "Invalid collateral asset address"); require(collateralAssets[i].amount > 0, "Invalid collateral amount"); } } -} \ No newline at end of file +} diff --git a/contracts/protocols/CompoundHandler.sol b/contracts/protocols/CompoundHandler.sol index de8afe9..f82cac9 100644 --- a/contracts/protocols/CompoundHandler.sol +++ b/contracts/protocols/CompoundHandler.sol @@ -11,8 +11,7 @@ import "./BaseProtocolHandler.sol"; contract CompoundHandler is BaseProtocolHandler { using SafeERC20 for IERC20; - constructor(address _registry, address _uniswapV3Factory) BaseProtocolHandler(_uniswapV3Factory, _registry) { - } + constructor(address _registry, address _uniswapV3Factory) BaseProtocolHandler(_uniswapV3Factory, _registry) {} function getCContract(address token) internal view returns (address) { return registry.getCContract(token); @@ -50,9 +49,9 @@ contract CompoundHandler is BaseProtocolHandler { address onBehalfOf, CollateralAsset[] memory collateralAssets, bytes calldata /* extraData */ - ) public override onlyAuthorizedCaller(onBehalfOf) { + ) public override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(fromAsset), "From asset is not whitelisted"); - + address cContract = getCContract(fromAsset); require(cContract != address(0), "Token not registered"); @@ -82,14 +81,14 @@ contract CompoundHandler is BaseProtocolHandler { address onBehalfOf, CollateralAsset[] memory collateralAssets, bytes calldata /* extraData */ - ) public override onlyAuthorizedCaller(onBehalfOf) { + ) public override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(toAsset), "To asset is not whitelisted"); - + address cContract = getCContract(toAsset); require(cContract != address(0), "Token not registered"); IComet toComet = IComet(cContract); - + _validateCollateralAssets(collateralAssets); for (uint256 i = 0; i < collateralAssets.length; i++) { require(registry.isWhitelisted(collateralAssets[i].asset), "Collateral asset is not whitelisted"); @@ -130,7 +129,7 @@ contract CompoundHandler is BaseProtocolHandler { bytes calldata /* extraData */ ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); - + address cContract = getCContract(asset); require(cContract != address(0), "Token not registered"); @@ -200,7 +199,9 @@ contract CompoundHandler is BaseProtocolHandler { uint128 bal = comet.collateralBalanceOf(user, info.asset); if (bal > 0) { uint256 price = comet.getPrice(info.priceFeed); - totalCollateralValueBase += (uint256(bal) * price * uint256(info.borrowCollateralFactor)) / (uint256(info.scale) * 1e18); + totalCollateralValueBase += + (uint256(bal) * price * uint256(info.borrowCollateralFactor)) / + (uint256(info.scale) * 1e18); } } @@ -220,7 +221,8 @@ contract CompoundHandler is BaseProtocolHandler { if (collateralPrice == 0 || assetInfo.borrowCollateralFactor == 0) return 0; - uint256 maxWithdraw = (surplusValueBase * uint256(assetInfo.scale) * 1e18) / (collateralPrice * uint256(assetInfo.borrowCollateralFactor)); + uint256 maxWithdraw = (surplusValueBase * uint256(assetInfo.scale) * 1e18) / + (collateralPrice * uint256(assetInfo.borrowCollateralFactor)); if (maxWithdraw > uint256(collateralBalance)) { maxWithdraw = uint256(collateralBalance); diff --git a/contracts/protocols/MorphoHandler.sol b/contracts/protocols/MorphoHandler.sol index 447d69e..cbfd956 100644 --- a/contracts/protocols/MorphoHandler.sol +++ b/contracts/protocols/MorphoHandler.sol @@ -18,7 +18,11 @@ contract MorphoHandler is BaseProtocolHandler { IMorpho public immutable morpho; - constructor(address _MORPHO_ADDRESS, address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { + constructor( + address _MORPHO_ADDRESS, + address _UNISWAP_V3_FACTORY, + address _REGISTRY_ADDRESS + ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { morpho = IMorpho(_MORPHO_ADDRESS); } @@ -106,7 +110,12 @@ contract MorphoHandler is BaseProtocolHandler { IERC20(marketParams.collateralToken).forceApprove(address(morpho), 0); } - function supply(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) external override onlyAuthorizedCaller(onBehalfOf) { + function supply( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (MarketParams memory marketParams, ) = abi.decode(extraData, (MarketParams, uint256)); @@ -116,7 +125,12 @@ contract MorphoHandler is BaseProtocolHandler { IERC20(asset).forceApprove(address(morpho), 0); } - function borrow(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) external override onlyAuthorizedCaller(onBehalfOf) { + function borrow( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (MarketParams memory marketParams, ) = abi.decode(extraData, (MarketParams, uint256)); @@ -124,7 +138,12 @@ contract MorphoHandler is BaseProtocolHandler { morpho.borrow(marketParams, amount, 0, onBehalfOf, address(this)); } - function repay(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) public onlyAuthorizedCaller(onBehalfOf) { + function repay( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) public onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (MarketParams memory marketParams, uint256 borrowShares) = abi.decode(extraData, (MarketParams, uint256)); @@ -135,7 +154,7 @@ contract MorphoHandler is BaseProtocolHandler { // https://docs.morpho.org/build/borrow/concepts/market-mechanics#full-repayment-shares-first Id marketId = marketParams.id(); Market memory m = morpho.market(marketId); - approvalAmount = borrowShares.toAssetsUp(m.totalBorrowAssets, m.totalBorrowShares) * 120 / 100; + approvalAmount = (borrowShares.toAssetsUp(m.totalBorrowAssets, m.totalBorrowShares) * 120) / 100; } else { approvalAmount = amount; } @@ -152,7 +171,12 @@ contract MorphoHandler is BaseProtocolHandler { IERC20(asset).forceApprove(address(morpho), 0); } - function withdraw(address /* asset */, uint256 amount, address onBehalfOf, bytes calldata extraData) external override onlyAuthorizedCaller(onBehalfOf) { + function withdraw( + address /* asset */, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) external override onlyAuthorizedCaller(onBehalfOf) { // Decode market parameters from extraData (MarketParams memory marketParams, ) = abi.decode(extraData, (MarketParams, uint256)); require(registry.isWhitelisted(marketParams.collateralToken), "Asset is not whitelisted"); diff --git a/contracts/protocolsSafe/FluidSafeHandler.sol b/contracts/protocolsSafe/FluidSafeHandler.sol index 1c14b13..5cc03a9 100644 --- a/contracts/protocolsSafe/FluidSafeHandler.sol +++ b/contracts/protocolsSafe/FluidSafeHandler.sol @@ -22,8 +22,10 @@ contract FluidSafeHandler is BaseProtocolHandler { // This design allows the resolver to be updated without redeploying handlers // and works correctly with delegatecall since registry is immutable - constructor(address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { - } + constructor( + address _UNISWAP_V3_FACTORY, + address _REGISTRY_ADDRESS + ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) {} function getDebtAmount( address /* asset */, @@ -56,8 +58,8 @@ contract FluidSafeHandler is BaseProtocolHandler { } // Add tiny amount buffer to avoid repay amount slightly increasing and causing revert - uint256 bufferAmount = (debtAmount * 10005) / 10000; // 0.05% buffer - uint256 fixedBuffer = 1000; // Fixed 1000 wei buffer + uint256 bufferAmount = (debtAmount * 10005) / 10000; // 0.05% buffer + uint256 fixedBuffer = 1000; // Fixed 1000 wei buffer return bufferAmount + fixedBuffer; } @@ -132,7 +134,13 @@ contract FluidSafeHandler is BaseProtocolHandler { // use balanceOf() because collateral amount is slightly decreased when switching from Fluid uint256 currentBalance = IERC20(collateralAssets[0].asset).balanceOf(address(this)); - bytes memory returnData = _supplyCollateral(vaultAddress, nftId, collateralAssets[0].asset, currentBalance, onBehalfOf); + bytes memory returnData = _supplyCollateral( + vaultAddress, + nftId, + collateralAssets[0].asset, + currentBalance, + onBehalfOf + ); // If nftId is 0, extract new ID from return data, otherwise use the provided ID uint256 positionNftId; @@ -152,7 +160,12 @@ contract FluidSafeHandler is BaseProtocolHandler { require(successBorrow, "Fluid borrow failed"); } - function repay(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) public override onlyAuthorizedCaller(onBehalfOf) { + function repay( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) public override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (address vaultAddress, , bool isFullRepay) = abi.decode(extraData, (address, uint256, bool)); @@ -168,7 +181,7 @@ contract FluidSafeHandler is BaseProtocolHandler { repayAmount = type(int).min; // For full repayment, approve a bit more to account for accrued interest - approvalAmount = (amount * 10005) / 10000; // 0.05% buffer + approvalAmount = (amount * 10005) / 10000; // 0.05% buffer } else { repayAmount = -int256(amount); @@ -218,7 +231,13 @@ contract FluidSafeHandler is BaseProtocolHandler { require(successRepay, "Repay failed"); } - function _supplyCollateral(address vaultAddress, uint256 nftId, address asset, uint256 amount, address onBehalfOf) internal returns (bytes memory) { + function _supplyCollateral( + address vaultAddress, + uint256 nftId, + address asset, + uint256 amount, + address onBehalfOf + ) internal returns (bytes memory) { bytes memory returnData; // Check if asset is WETH if (asset == registry.WETH_ADDRESS()) { @@ -268,7 +287,12 @@ contract FluidSafeHandler is BaseProtocolHandler { return returnData; } - function supply(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) external onlyAuthorizedCaller(onBehalfOf) { + function supply( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) external onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (address vaultAddress, uint256 nftId, ) = abi.decode(extraData, (address, uint256, bool)); @@ -276,7 +300,12 @@ contract FluidSafeHandler is BaseProtocolHandler { _supplyCollateral(vaultAddress, nftId, asset, amount, onBehalfOf); } - function borrow(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) external onlyAuthorizedCaller(onBehalfOf) { + function borrow( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) external onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); (address vaultAddress, uint256 nftIdFromExtra, ) = abi.decode(extraData, (address, uint256, bool)); @@ -305,7 +334,12 @@ contract FluidSafeHandler is BaseProtocolHandler { require(successBorrow, "Fluid borrow failed"); } - function withdraw(address asset, uint256 amount, address onBehalfOf, bytes calldata extraData) public override onlyAuthorizedCaller(onBehalfOf) { + function withdraw( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata extraData + ) public override onlyAuthorizedCaller(onBehalfOf) { _withdraw(asset, amount, onBehalfOf, extraData); } diff --git a/contracts/protocolsSafe/MoonwellHandler.sol b/contracts/protocolsSafe/MoonwellHandler.sol index 4ebbad5..162b64c 100644 --- a/contracts/protocolsSafe/MoonwellHandler.sol +++ b/contracts/protocolsSafe/MoonwellHandler.sol @@ -16,7 +16,11 @@ contract MoonwellHandler is BaseProtocolHandler { address public immutable COMPTROLLER; - constructor(address _comptroller, address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { + constructor( + address _comptroller, + address _UNISWAP_V3_FACTORY, + address _REGISTRY_ADDRESS + ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { COMPTROLLER = _comptroller; } @@ -157,7 +161,7 @@ contract MoonwellHandler is BaseProtocolHandler { ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(fromAsset), "From asset is not whitelisted"); _validateCollateralAssets(collateralAssets); - + address fromContract = getMContract(fromAsset); if (fromContract == address(0)) revert TokenNotRegistered(); @@ -238,7 +242,7 @@ contract MoonwellHandler is BaseProtocolHandler { for (uint256 i = 0; i < collateralAssets.length; i++) { require(registry.isWhitelisted(collateralAssets[i].asset), "Collateral asset is not whitelisted"); - + address collateralContract = getMContract(collateralAssets[i].asset); // use balanceOf() because collateral amount is slightly decreased when switching from Fluid uint256 currentBalance = IERC20(collateralAssets[i].asset).balanceOf(address(this)); @@ -289,9 +293,14 @@ contract MoonwellHandler is BaseProtocolHandler { require(successTransfer, "Transfer transaction failed"); } - function supply(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external onlyAuthorizedCaller(onBehalfOf) { + function supply( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); - + address mContract = getMContract(asset); if (mContract == address(0)) revert TokenNotRegistered(); @@ -323,7 +332,12 @@ contract MoonwellHandler is BaseProtocolHandler { ); } - function borrow(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external onlyAuthorizedCaller(onBehalfOf) { + function borrow( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); address mContract = getMContract(asset); @@ -346,7 +360,12 @@ contract MoonwellHandler is BaseProtocolHandler { require(successTransfer, "Transfer transaction failed"); } - function repay(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) public override onlyAuthorizedCaller(onBehalfOf) { + function repay( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) public override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); address mContract = getMContract(asset); @@ -365,7 +384,12 @@ contract MoonwellHandler is BaseProtocolHandler { IERC20(asset).forceApprove(address(mContract), 0); } - function withdraw(address asset, uint256 amount, address onBehalfOf, bytes calldata /* extraData */) external override onlyAuthorizedCaller(onBehalfOf) { + function withdraw( + address asset, + uint256 amount, + address onBehalfOf, + bytes calldata /* extraData */ + ) external override onlyAuthorizedCaller(onBehalfOf) { require(registry.isWhitelisted(asset), "Asset is not whitelisted"); address mTokenAddress = getMContract(asset); diff --git a/hardhat.config.ts b/hardhat.config.ts index 6d96f2a..2ddac45 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -9,6 +9,10 @@ require("@openzeppelin/hardhat-upgrades"); const baseUrl = process.env.BASE_RPC_URL || "https://mainnet.base.org"; +// Only configure signing accounts when a deployer key is present. Hardhat rejects +// `[undefined]`, which breaks `compile`/`coverage` in CI where no key is set. +const deployerAccounts = process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : []; + const config: HardhatUserConfig = { solidity: { compilers: [ @@ -23,6 +27,7 @@ const config: HardhatUserConfig = { runs: 200, }, viaIR: true, + evmVersion: "cancun", }, }, ], @@ -47,19 +52,19 @@ const config: HardhatUserConfig = { url: baseUrl, chainId: 8453, timeout: 10_000_000, - accounts: [process.env.DEPLOYER_PRIVATE_KEY!], + accounts: deployerAccounts, gasPrice: "auto", gasMultiplier: 1.2, }, baseSepolia: { url: "https://sepolia.base.org", chainId: 84532, - accounts: [process.env.DEPLOYER_PRIVATE_KEY!], + accounts: deployerAccounts, }, sepolia: { url: "https://eth-sepolia.public.blastapi.io", chainId: 11155111, - accounts: [process.env.DEPLOYER_PRIVATE_KEY!], + accounts: deployerAccounts, }, localhost: { url: "http://localhost:8545", diff --git a/ignition/modules/DeployAll.ts b/ignition/modules/1_DeployCore.ts similarity index 92% rename from ignition/modules/DeployAll.ts rename to ignition/modules/1_DeployCore.ts index a872f5e..a0735a8 100644 --- a/ignition/modules/DeployAll.ts +++ b/ignition/modules/1_DeployCore.ts @@ -1,4 +1,5 @@ import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; +import TimelockControllerModule from "./TimelockControllerModule"; import { getCTokenMappingArrays, getMTokenMappingArrays, @@ -55,9 +56,9 @@ import { * - DEPLOYER_PRIVATE_KEY: Deployer private key (in hardhat.config.ts) * * Usage: - * npx hardhat ignition deploy ignition/modules/DeployAll.ts --network base --verify --reset + * npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify --reset */ -export default buildModule("DeployAll", (m) => { +export default buildModule("DeployCore", (m) => { // ── Validate env vars ────────────────────────────────────────────── const adminAddress = process.env.ADMIN_ADDRESS; if (!adminAddress) { @@ -76,14 +77,12 @@ export default buildModule("DeployAll", (m) => { const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000"; - // ── 1. TimelockController ────────────────────────────────────────── - const MIN_DELAY = 2 * 24 * 60 * 60; // 2 days - const timelock = m.contract("TimelockController", [ - MIN_DELAY, - [adminAddress], // proposers - [adminAddress], // executors - ZERO_ADDRESS, // no admin - ]); + // ── 1. TimelockController (shared sub-module — Ignition deduplicates + // the future across runs, so `yarn deploy:2_univ3_helper` later reuses this + // same address). Params come from TIMELOCK_ADMIN / TIMELOCK_DELAY + // env vars in TimelockControllerModule, both with fallbacks to + // ADMIN_ADDRESS / 2 days. + const { timelock } = m.useModule(TimelockControllerModule); // ── 2. ProtocolRegistry ──────────────────────────────────────────── const registry = m.contract( diff --git a/ignition/modules/2_DeployUniV3Helper.ts b/ignition/modules/2_DeployUniV3Helper.ts new file mode 100644 index 0000000..594c1bd --- /dev/null +++ b/ignition/modules/2_DeployUniV3Helper.ts @@ -0,0 +1,156 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; +import TimelockControllerModule from "./TimelockControllerModule"; +import { + PROTOCOL_REGISTRY_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, +} from "../../contractAddresses"; + +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +// Fail fast with a clear, named message instead of a cryptic ethers ABI-encode +// error deep in execution when a required constructor address is blank/missing. +function requireAddress(label: string, value: string): void { + if (!ADDRESS_RE.test(value)) { + throw new Error( + `DeployUniV3Helper: ${label} must be a valid address but got "${value}". ` + + "Set the corresponding env var in your .env before deploying.", + ); + } +} + +/** + * Combined deployment module for TimelockController + RatehopperUniV3Positions. + * + * The TimelockController is delegated to the shared `TimelockControllerModule` + * sub-module, which is ALSO consumed by `DeployCore`. Ignition deduplicates the + * `TimelockControllerModule#TimelockController` future across runs via the + * journal, so whichever deploy command runs first owns the deployment and the + * later command(s) reuse that same address automatically. To bypass the shared + * sub-module entirely and point RHP at a pre-existing (or externally deployed) + * timelock, set `RHP_TIMELOCK` — when present, this module skips the + * sub-module path and uses the literal address directly. + * + * Environment variables: + * - RHP_REGISTRY: ProtocolRegistry address. If unset, falls back + * to `PROTOCOL_REGISTRY_ADDRESS` in + * `contractAddresses.ts` (canonical Base registry). + * - RHP_TREASURY: Treasury address that collects fees. Required. + * - RHP_INITIAL_ADMIN: DEFAULT_ADMIN_ROLE holder on RHP + * (operational setters: rescueToken, etc.). + * Falls back to ADMIN_ADDRESS. Required. + * - RHP_TIMELOCK: Pre-deployed TimelockController to reuse. + * When set, SKIPS the shared sub-module entirely. + * - TIMELOCK_ADMIN: (consumed by TimelockControllerModule) EOA / + * multisig set as BOTH proposer AND executor. + * Falls back to ADMIN_ADDRESS. Required when + * RHP_TIMELOCK is unset. + * - TIMELOCK_DELAY: (consumed by TimelockControllerModule) Minimum + * delay in seconds. Defaults to 172800 (2 days). + * - RHP_PERFORMANCE_FEE_BPS: Performance fee on net profit at closeLp in + * bps. Defaults to 1000 (10%). + * - RHP_FEE_COLLECT_BPS: Fee on harvested LP fees in bps. Defaults to + * 250 (2.5%). + * - RHP_MAX_FEE_BPS: Hard upper bound on BOTH fees. Defaults to + * 2000 (20%). + * - RHP_MIN_POSITION_LIQUIDITY: Floor on NPM `mint` liquidity (L-2 / L-4 + * hardening). Defaults to 10000 — the exact + * threshold below which a 1-bps partial close + * can decrement basis while removing zero + * liquidity. Real WETH/USDC positions carry L + * orders of magnitude larger, so this default + * never rejects a legitimate position while + * fully closing the L-4 dust regime. Set 0 to + * disable. + * - RHP_MIN_POOL_LIQUIDITY: Floor on `pool.liquidity()` for any pool a + * spot price is read from (I-3 / manipulation + * hardening). Defaults to 0 (disabled) because + * the safe value is pool- and market-dependent: + * an over-aggressive floor bricks normal + * operation. RUNBOOK: measure the target + * WETH/USDC pool's in-range `liquidity()` and + * set this to a conservative fraction (e.g. + * ~25-50%) via this env var or post-deploy + * `setMinPoolLiquidity` (DEFAULT_ADMIN_ROLE). + * - DEPLOYER_PRIVATE_KEY: Deployer key (set in hardhat.config.ts). + * + * Usage: + * npx hardhat ignition deploy ignition/modules/2_DeployUniV3Helper.ts \ + * --network base --verify + */ +export default buildModule("DeployUniV3Helper", (m) => { + // ── Timelock ─────────────────────────────────────────────────────────── + // Three modes: + // 1. `RHP_TIMELOCK` set → reuse the supplied address (no deploy here). + // 2. `RHP_TIMELOCK` unset → consume the shared TimelockControllerModule. + // Because Ignition keys futures by `#`, the + // sub-module's `TimelockControllerModule#TimelockController` is the + // same future the `DeployCore` module references. The journal at + // `ignition/deployments/chain-/` deduplicates across runs, + // so if `yarn deploy:1_core` already deployed it, `yarn deploy:2_univ3_helper` reuses + // that exact address. + // 3. First-ever run → sub-module deploys it; params (TIMELOCK_ADMIN, + // TIMELOCK_DELAY) come from env vars inside TimelockControllerModule. + const reuseTimelockAddr = process.env.RHP_TIMELOCK ?? ""; + + const timelock = reuseTimelockAddr ? undefined : m.useModule(TimelockControllerModule).timelock; + + // Concrete address used for the RHP constructor arg: either the + // shared-sub-module future (Ignition resolves the address) or the literal + // `RHP_TIMELOCK` pin. + const timelockArg: any = timelock ?? reuseTimelockAddr; + + // ── RHP ──────────────────────────────────────────────────────────────── + const registryAddr = process.env.RHP_REGISTRY ?? PROTOCOL_REGISTRY_ADDRESS; + const treasuryAddr = process.env.RHP_TREASURY ?? ""; + const initialAdminAddr = process.env.RHP_INITIAL_ADMIN ?? process.env.ADMIN_ADDRESS ?? ""; + + requireAddress("registry (RHP_REGISTRY / PROTOCOL_REGISTRY_ADDRESS)", registryAddr); + requireAddress("treasury (RHP_TREASURY)", treasuryAddr); + requireAddress("initialAdmin (RHP_INITIAL_ADMIN / ADMIN_ADDRESS)", initialAdminAddr); + if (reuseTimelockAddr) requireAddress("timelock (RHP_TIMELOCK)", reuseTimelockAddr); + + const registry = m.getParameter("registry", registryAddr); + const treasury = m.getParameter("treasury", treasuryAddr); + const initialAdmin = m.getParameter("initialAdmin", initialAdminAddr); + const performanceFeeBps = m.getParameter( + "performanceFeeBps", + Number(process.env.RHP_PERFORMANCE_FEE_BPS ?? 1000), + ); + const feeCollectBps = m.getParameter("feeCollectBps", Number(process.env.RHP_FEE_COLLECT_BPS ?? 250)); + const maxFeeBps = m.getParameter("maxFeeBps", Number(process.env.RHP_MAX_FEE_BPS ?? 2000)); + const minPositionLiquidity = m.getParameter( + "minPositionLiquidity", + BigInt(process.env.RHP_MIN_POSITION_LIQUIDITY ?? 10_000), + ); + const minPoolLiquidity = m.getParameter( + "minPoolLiquidity", + BigInt(process.env.RHP_MIN_POOL_LIQUIDITY ?? 0), + ); + + const ratehopperUniV3Positions = m.contract( + "RatehopperUniV3Positions", + [ + UNISWAP_V3_NPM_ADDRESS, + registry, + USDC_ADDRESS, + WETH_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + treasury, + performanceFeeBps, + feeCollectBps, + maxFeeBps, + initialAdmin, + timelockArg, + minPoolLiquidity, + minPositionLiquidity, + ], + timelock ? { after: [timelock] } : undefined, + ); + + return { ratehopperUniV3Positions, ...(timelock ? { timelock } : {}) }; +}); diff --git a/ignition/modules/TimelockControllerModule.ts b/ignition/modules/TimelockControllerModule.ts new file mode 100644 index 0000000..09dd60b --- /dev/null +++ b/ignition/modules/TimelockControllerModule.ts @@ -0,0 +1,47 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; + +/** + * Shared TimelockController deployment. + * + * Both `DeployCore` and `DeployUniV3Helper` import this via + * `m.useModule(TimelockControllerModule)`. Because Ignition derives futures' + * IDs from `#`, the timelock here always has the + * stable ID `TimelockControllerModule#TimelockController` — regardless of + * which top-level module called it. The journal at + * `ignition/deployments/chain-/` deduplicates by ID across deploy + * runs, so calling `yarn deploy:1_core` first and `yarn deploy:2_univ3_helper` later reuses + * the same timelock address. + * + * IMPORTANT: parameters are baked in by the FIRST deploy run that executes + * the sub-module. Subsequent runs reuse the existing address regardless of + * what env vars they pass. Keep `TIMELOCK_ADMIN` / `TIMELOCK_DELAY` aligned + * across all parent modules to avoid surprise. + * + * Environment variables: + * - TIMELOCK_ADMIN: EOA / multisig granted BOTH proposer and executor + * roles. Falls back to ADMIN_ADDRESS. REQUIRED. + * - TIMELOCK_DELAY: Minimum delay (seconds) before queued ops can execute. + * Defaults to 172800 (2 days). Lower on testnets only. + */ +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const TWO_DAYS = 2 * 24 * 60 * 60; + +export default buildModule("TimelockControllerModule", (m) => { + const admin = m.getParameter( + "admin", + process.env.TIMELOCK_ADMIN ?? process.env.ADMIN_ADDRESS ?? "", + ); + const delay = m.getParameter( + "delay", + Number(process.env.TIMELOCK_DELAY ?? TWO_DAYS), + ); + + const timelock = m.contract("TimelockController", [ + delay, + [admin], // proposers + [admin], // executors + ZERO_ADDRESS, // no separate admin — timelock holds DEFAULT_ADMIN_ROLE on itself + ]); + + return { timelock }; +}); diff --git a/package.json b/package.json index 1795163..09220f4 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,14 @@ "compile": "npx hardhat compile", "size": "npx hardhat size-contracts", "coverage": "node --max-old-space-size=12288 ./node_modules/.bin/hardhat coverage", + "coverage:rhp": "node --max-old-space-size=12288 ./node_modules/.bin/hardhat coverage --testfiles \"test/ratehopperUniV3Positions*.ts\"", + "coverage:check": "node scripts/checkCoverage.js", + "lint:sol": "npx solhint 'contracts/**/*.sol'", + "format": "npx prettier --write 'contracts/**/*.sol'", + "format:check": "npx prettier --check 'contracts/**/*.sol'", "clean": "npx hardhat clean", - "deploy": "rm -rf ignition/deployments/chain-8453 && npx hardhat ignition deploy ignition/modules/DeployAll.ts --network base --verify --reset", + "deploy:1_core": "rm -rf ignition/deployments/chain-8453 && npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify --reset && node scripts/syncRegistryAddress.js", + "deploy:2_univ3_helper": "npx hardhat ignition deploy ignition/modules/2_DeployUniV3Helper.ts --network base --verify", "verify": "npx hardhat run scripts/verifyAll.ts --network base", "wipe": "npx hardhat ignition wipe chain-8453" }, diff --git a/scripts/checkCoverage.js b/scripts/checkCoverage.js new file mode 100644 index 0000000..5cf14b7 --- /dev/null +++ b/scripts/checkCoverage.js @@ -0,0 +1,80 @@ +// Coverage gate: fails (exit 1) if branch coverage on any gated production +// contract is below THRESHOLD. +// +// Reads ./coverage.json produced by `yarn coverage*` (solidity-coverage / +// Istanbul format: per-file `b` maps a branchId to an array of per-outcome hit +// counts). Run AFTER a coverage run that exercises the gated contracts. +// +// Scope: only RatehopperUniV3Positions.sol is gated for now. The legacy +// debt-swap contracts and protocol handlers depend on Base-fork suites that +// are not yet fully covered; add their paths to GATED once their coverage is +// brought up. + +const fs = require("fs"); +const path = require("path"); + +const COVERAGE_FILE = path.join(__dirname, "..", "coverage.json"); +const THRESHOLD = 95; + +// Production contracts subject to the gate (suffix match on the coverage key). +const GATED = ["contracts/RatehopperUniV3Positions.sol"]; + +function isGated(key) { + const k = key.replace(/\\/g, "/"); + return GATED.some((g) => k.endsWith(g)); +} + +function main() { + if (!fs.existsSync(COVERAGE_FILE)) { + console.error(`Coverage file not found at ${COVERAGE_FILE}. Run a coverage command first.`); + process.exit(1); + } + + const cov = JSON.parse(fs.readFileSync(COVERAGE_FILE, "utf8")); + const rows = []; + let totalBranches = 0; + let coveredBranches = 0; + + for (const [key, data] of Object.entries(cov)) { + if (!isGated(key)) continue; + + const branches = data.b || {}; + let fileTotal = 0; + let fileCovered = 0; + for (const hits of Object.values(branches)) { + for (const h of hits) { + fileTotal += 1; + if (h > 0) fileCovered += 1; + } + } + + totalBranches += fileTotal; + coveredBranches += fileCovered; + const pct = fileTotal === 0 ? 100 : (fileCovered / fileTotal) * 100; + rows.push({ key, fileCovered, fileTotal, pct }); + } + + if (rows.length === 0) { + console.error("No gated contracts found in coverage.json — was the coverage run scoped correctly?"); + process.exit(1); + } + + rows.sort((a, b) => a.pct - b.pct); + console.log(`Branch coverage for gated contracts (threshold ${THRESHOLD}%):\n`); + for (const r of rows) { + const flag = r.pct < THRESHOLD ? "FAIL" : " ok "; + console.log(` [${flag}] ${r.key}: ${r.fileCovered}/${r.fileTotal} branches (${r.pct.toFixed(2)}%)`); + } + + const overall = totalBranches === 0 ? 100 : (coveredBranches / totalBranches) * 100; + console.log(`\nOverall: ${coveredBranches}/${totalBranches} branches (${overall.toFixed(2)}%) — threshold ${THRESHOLD}%`); + + const failing = rows.filter((r) => r.pct < THRESHOLD); + if (failing.length > 0) { + console.error(`\nBranch coverage below ${THRESHOLD}% in ${failing.length} file(s).`); + process.exit(1); + } + console.log(`\nAll gated contracts meet the ${THRESHOLD}% branch coverage gate.`); +} + +main(); diff --git a/scripts/syncRegistryAddress.js b/scripts/syncRegistryAddress.js new file mode 100644 index 0000000..3268c67 --- /dev/null +++ b/scripts/syncRegistryAddress.js @@ -0,0 +1,68 @@ +// Post-deploy sync: rewrites PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts +// to the ProtocolRegistry address just deployed by 1_DeployCore.ts. +// +// Runs automatically as the last step of `yarn deploy:1_core` so the +// `yarn deploy:2_univ3_helper` fallback (which reads PROTOCOL_REGISTRY_ADDRESS) +// always points at the freshly deployed registry — a dev can't forget to bump +// it manually. +// +// Reads ignition/deployments/chain-/deployed_addresses.json (Ignition +// keys futures as `#`, so the registry is +// `DeployCore#ProtocolRegistry`). + +const fs = require("fs"); +const path = require("path"); + +const CHAIN_ID = process.env.CHAIN_ID || "8453"; +const REGISTRY_KEY = "DeployCore#ProtocolRegistry"; + +const ADDRESSES_FILE = path.join( + __dirname, + "..", + "ignition", + "deployments", + `chain-${CHAIN_ID}`, + "deployed_addresses.json", +); +const CONTRACT_ADDRESSES_FILE = path.join(__dirname, "..", "contractAddresses.ts"); +const CONSTANT_RE = /(export const PROTOCOL_REGISTRY_ADDRESS = ")(0x[a-fA-F0-9]{40})(";)/; + +function main() { + if (!fs.existsSync(ADDRESSES_FILE)) { + throw new Error(`Deployment file not found at ${ADDRESSES_FILE}. Did 1_DeployCore run?`); + } + + const deployed = JSON.parse(fs.readFileSync(ADDRESSES_FILE, "utf8")); + const newAddress = deployed[REGISTRY_KEY]; + if (!newAddress) { + const keys = Object.keys(deployed); + const nearMatch = keys.find((k) => k.endsWith("#ProtocolRegistry")); + const hint = nearMatch + ? `\nDid the module/future ID change? Found "${nearMatch}" — update REGISTRY_KEY in scripts/syncRegistryAddress.js to match.` + : ""; + throw new Error( + `Registry key "${REGISTRY_KEY}" not found in ${ADDRESSES_FILE}.` + + `\nAvailable keys: ${keys.join(", ")}` + + hint, + ); + } + + const source = fs.readFileSync(CONTRACT_ADDRESSES_FILE, "utf8"); + const match = source.match(CONSTANT_RE); + if (!match) { + throw new Error(`Could not find PROTOCOL_REGISTRY_ADDRESS declaration in ${CONTRACT_ADDRESSES_FILE}.`); + } + + const oldAddress = match[2]; + if (oldAddress === newAddress) { + console.log(`PROTOCOL_REGISTRY_ADDRESS already up to date (${newAddress}).`); + return; + } + + const updated = source.replace(CONSTANT_RE, `$1${newAddress}$3`); + fs.writeFileSync(CONTRACT_ADDRESSES_FILE, updated); + console.log(`Updated PROTOCOL_REGISTRY_ADDRESS: ${oldAddress} -> ${newAddress}`); + console.log("Remember to commit the change to contractAddresses.ts."); +} + +main(); diff --git a/scripts/timelockUpdateOperator.ts b/scripts/timelockUpdateOperator.ts new file mode 100644 index 0000000..2bd7247 --- /dev/null +++ b/scripts/timelockUpdateOperator.ts @@ -0,0 +1,148 @@ +import { ethers } from "hardhat"; + +/** + * Script showing how to update the operator address in ProtocolRegistry through TimelockController + * + * `setOperator` is a CRITICAL_ROLE function that can ONLY be called by the timelock, + * so it must go through the two-step process: + * 1. Schedule the operation (requires PROPOSER_ROLE) + * 2. Wait for the timelock delay (2 days) + * 3. Execute the operation (requires EXECUTOR_ROLE) + * + * Usage: + * STEP 1 - Schedule: + * TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \ + * npx hardhat run scripts/timelockUpdateOperator.ts --network base + * + * STEP 2 - Execute (run after 2 days, reuse the SAME OPERATION_ID printed in step 1): + * EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \ + * npx hardhat run scripts/timelockUpdateOperator.ts --network base + */ + +async function main() { + const [signer] = await ethers.getSigners(); + + // Configuration from environment + const TIMELOCK_ADDRESS = process.env.TIMELOCK_ADDRESS || ""; + const PROTOCOL_REGISTRY_ADDRESS = process.env.PROTOCOL_REGISTRY_ADDRESS || ""; + const NEW_OPERATOR_ADDRESS = process.env.NEW_OPERATOR_ADDRESS || ""; + const EXECUTE = process.env.EXECUTE === "true"; + const OPERATION_ID = process.env.OPERATION_ID || "operator-update-" + Date.now(); + + if (!TIMELOCK_ADDRESS || !PROTOCOL_REGISTRY_ADDRESS || !NEW_OPERATOR_ADDRESS) { + throw new Error( + "Please set TIMELOCK_ADDRESS, PROTOCOL_REGISTRY_ADDRESS, and NEW_OPERATOR_ADDRESS" + ); + } + + if (!ethers.isAddress(NEW_OPERATOR_ADDRESS)) { + throw new Error(`NEW_OPERATOR_ADDRESS is not a valid address: ${NEW_OPERATOR_ADDRESS}`); + } + + console.log("Configuration:"); + console.log("- Timelock:", TIMELOCK_ADDRESS); + console.log("- ProtocolRegistry:", PROTOCOL_REGISTRY_ADDRESS); + console.log("- New Operator:", NEW_OPERATOR_ADDRESS); + console.log("- Operation ID:", OPERATION_ID); + console.log("- Mode:", EXECUTE ? "EXECUTE" : "SCHEDULE"); + console.log("- Signer:", signer.address); + + // Get contracts + const timelock = await ethers.getContractAt("TimelockController", TIMELOCK_ADDRESS); + const protocolRegistry = await ethers.getContractAt("ProtocolRegistry", PROTOCOL_REGISTRY_ADDRESS); + + const currentOperator = await protocolRegistry.safeOperator(); + console.log("- Current Operator:", currentOperator); + + // Prepare operation parameters + const target = PROTOCOL_REGISTRY_ADDRESS; + const value = 0; + const data = protocolRegistry.interface.encodeFunctionData("setOperator", [NEW_OPERATOR_ADDRESS]); + const predecessor = ethers.ZeroHash; // No dependency on other operations + const salt = ethers.id(OPERATION_ID); // Unique identifier + const delay = await timelock.getMinDelay(); + + // Calculate operation ID + const operationId = await timelock.hashOperation(target, value, data, predecessor, salt); + + if (!EXECUTE) { + // STEP 1: Schedule the operation + console.log("\n=== SCHEDULING OPERATION ===\n"); + + // Check if already scheduled + const isScheduled = await timelock.isOperationPending(operationId); + if (isScheduled) { + console.log("⚠️ Operation already scheduled!"); + console.log("Operation ID:", operationId); + + const timestamp = await timelock.getTimestamp(operationId); + const readyAt = new Date(Number(timestamp) * 1000); + console.log("Ready for execution at:", readyAt.toISOString()); + return; + } + + // Schedule the operation + console.log("Scheduling operation..."); + const tx = await timelock.schedule(target, value, data, predecessor, salt, delay); + const receipt = await tx.wait(); + + console.log("✓ Operation scheduled successfully!"); + console.log("Transaction hash:", receipt?.hash); + console.log("Operation ID:", operationId); + + const timestamp = await timelock.getTimestamp(operationId); + const readyAt = new Date(Number(timestamp) * 1000); + console.log("\nReady for execution at:", readyAt.toISOString()); + console.log(`(${delay} seconds from now)`); + + console.log("\n=== NEXT STEPS ==="); + console.log("1. Wait until:", readyAt.toISOString()); + console.log("2. Run this script again with EXECUTE=true (reuse the SAME OPERATION_ID):"); + console.log(` EXECUTE=true TIMELOCK_ADDRESS=${TIMELOCK_ADDRESS} \\`); + console.log(` PROTOCOL_REGISTRY_ADDRESS=${PROTOCOL_REGISTRY_ADDRESS} \\`); + console.log(` NEW_OPERATOR_ADDRESS=${NEW_OPERATOR_ADDRESS} \\`); + console.log(` OPERATION_ID="${OPERATION_ID}" \\`); + console.log(" npx hardhat run scripts/timelockUpdateOperator.ts --network base"); + + } else { + // STEP 2: Execute the operation + console.log("\n=== EXECUTING OPERATION ===\n"); + + // Check if operation is ready + const isReady = await timelock.isOperationReady(operationId); + if (!isReady) { + const isPending = await timelock.isOperationPending(operationId); + if (isPending) { + const timestamp = await timelock.getTimestamp(operationId); + const readyAt = new Date(Number(timestamp) * 1000); + throw new Error( + `Operation is not ready yet. Please wait until ${readyAt.toISOString()}` + ); + } else { + throw new Error("Operation not found. Please schedule it first (run without EXECUTE=true)"); + } + } + + // Execute the operation + console.log("Executing operation..."); + const tx = await timelock.execute(target, value, data, predecessor, salt); + const receipt = await tx.wait(); + + console.log("✓ Operation executed successfully!"); + console.log("Transaction hash:", receipt?.hash); + + // Verify the update + const updatedOperator = await protocolRegistry.safeOperator(); + console.log("\n=== VERIFICATION ==="); + console.log("Current operator address:", updatedOperator); + console.log("Expected address:", NEW_OPERATOR_ADDRESS); + console.log("Update successful:", updatedOperator.toLowerCase() === NEW_OPERATOR_ADDRESS.toLowerCase()); + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/verifyAll.ts b/scripts/verifyAll.ts index 344852f..920212e 100644 --- a/scripts/verifyAll.ts +++ b/scripts/verifyAll.ts @@ -88,7 +88,7 @@ async function main() { const journalFile = path.join(deploymentDir, "journal.jsonl"); if (!fs.existsSync(addressesFile)) { - throw new Error(`No deployment found at ${addressesFile}. Deploy first with: yarn deploy`); + throw new Error(`No deployment found at ${addressesFile}. Deploy first with: yarn deploy:1_core`); } const addresses: Record = JSON.parse(fs.readFileSync(addressesFile, "utf-8")); diff --git a/test/SafeExecTransactionWrapper.ts b/test/SafeExecTransactionWrapper.ts index d49d1a2..2e85523 100644 --- a/test/SafeExecTransactionWrapper.ts +++ b/test/SafeExecTransactionWrapper.ts @@ -19,7 +19,7 @@ import { expect } from "chai"; import { getGasOptions, deployLeveragedPositionContractFixture } from "./deployUtils"; import { MaxUint256 } from "ethers"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; -import { safeAddress } from "./debtSwapBySafe"; +import { safeAddress } from "./safeTestContext"; describe("SafeExecTransactionWrapper", function () { // Increase timeout for memory-intensive operations diff --git a/test/createLeveragedPositionBySafe.ts b/test/createLeveragedPositionBySafe.ts index 8cdeea5..a62fa8a 100644 --- a/test/createLeveragedPositionBySafe.ts +++ b/test/createLeveragedPositionBySafe.ts @@ -23,7 +23,7 @@ import { import { MaxUint256 } from "ethers"; import { deployLeveragedPositionContractFixture } from "./deployUtils"; import { mContractAddressMap, MoonwellHelper, COMPTROLLER_ADDRESS } from "./protocols/moonwell"; -import { safeAddress } from "./debtSwapBySafe"; +import { safeAddress } from "./safeTestContext"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; import { fluidVaultMap, FluidHelper } from "./protocols/fluid"; diff --git a/test/debtSwapBySafe.ts b/test/debtSwapBySafe.ts index 1819bb3..2f0cb5b 100644 --- a/test/debtSwapBySafe.ts +++ b/test/debtSwapBySafe.ts @@ -56,7 +56,7 @@ import { expect } from "chai"; import { deploySafeContractFixture } from "./deployUtils"; import { zeroAddress } from "viem"; -export const safeAddress = process.env.TESTING_SAFE_WALLET_ADDRESS!; +import { safeAddress } from "./safeTestContext"; // Export helper functions for reuse in other test files export function createSafeTestHelpers(context: { signer: ethers.Wallet; safeWallet: any; safeModuleAddress: string }) { diff --git a/test/exitBySafe.ts b/test/exitBySafe.ts index 67f89f3..0c4f0c3 100644 --- a/test/exitBySafe.ts +++ b/test/exitBySafe.ts @@ -15,7 +15,8 @@ import { MoonwellHelper } from "./protocols/moonwell"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { expect } from "chai"; import { deploySafeContractFixture } from "./deployUtils"; -import { safeAddress, createSafeTestHelpers } from "./debtSwapBySafe"; +import { safeAddress } from "./safeTestContext"; +import { createSafeTestHelpers } from "./debtSwapBySafe"; describe("Safe wallet exit function tests", function () { this.timeout(300000); diff --git a/test/protocolRegistryTimelock.ts b/test/protocolRegistryTimelock.ts index 5648bae..3f862f8 100644 --- a/test/protocolRegistryTimelock.ts +++ b/test/protocolRegistryTimelock.ts @@ -38,7 +38,7 @@ describe("ProtocolRegistry - Timelock Integration Tests", function () { const newMockParaswapAddress = "0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7"; // Note: CRITICAL_ROLE is automatically granted to timelock in constructor - const CRITICAL_ROLE = await protocolRegistry.CRITICAL_ROLE(); + const CRITICAL_ROLE = await protocolRegistry.CRITICAL_ROLE_PUBLIC(); // Grant DEFAULT_ADMIN_ROLE to admin for routine operations const DEFAULT_ADMIN_ROLE = await protocolRegistry.DEFAULT_ADMIN_ROLE(); @@ -300,4 +300,105 @@ describe("ProtocolRegistry - Timelock Integration Tests", function () { ); }); }); + + describe("Handler Update Timelock Bypass Regression", function () { + async function deployHandlerFixture() { + const base = await loadFixture(deployTimelockFixture); + const { protocolRegistry, deployer, admin } = base; + + const SafeDebtManager = await ethers.getContractFactory("SafeDebtManager"); + const safeDebtManager = await SafeDebtManager.deploy( + await protocolRegistry.getAddress(), + [], + [], + deployer.address, + ); + await safeDebtManager.waitForDeployment(); + + const LeveragedPosition = await ethers.getContractFactory("LeveragedPosition"); + const leveragedPosition = await LeveragedPosition.deploy( + await protocolRegistry.getAddress(), + [], + [], + deployer.address, + ); + await leveragedPosition.waitForDeployment(); + + return { ...base, safeDebtManager, leveragedPosition }; + } + + const AAVE_V3 = 0; + const SOME_HANDLER = "0x1111111111111111111111111111111111111111"; + + it("DEFAULT_ADMIN_ROLE cannot directly call SafeDebtManager.setProtocolHandler", async function () { + const { safeDebtManager, admin } = await deployHandlerFixture(); + await expect( + safeDebtManager.connect(admin).setProtocolHandler(AAVE_V3, SOME_HANDLER), + ).to.be.revertedWithCustomError(safeDebtManager, "OnlyTimelock"); + }); + + it("DEFAULT_ADMIN_ROLE granting itself CRITICAL_ROLE cannot bypass SafeDebtManager handler timelock", async function () { + const { protocolRegistry, safeDebtManager, deployer, CRITICAL_ROLE } = await deployHandlerFixture(); + + // Maliciously grant CRITICAL_ROLE to deployer (who has DEFAULT_ADMIN_ROLE) + await protocolRegistry.grantRole(CRITICAL_ROLE, deployer.address); + expect(await protocolRegistry.hasRole(CRITICAL_ROLE, deployer.address)).to.be.true; + + // Even with CRITICAL_ROLE, deployer is not the timelock so must revert + await expect( + safeDebtManager.connect(deployer).setProtocolHandler(AAVE_V3, SOME_HANDLER), + ).to.be.revertedWithCustomError(safeDebtManager, "OnlyTimelock"); + }); + + it("Timelock-scheduled execution can update SafeDebtManager handler", async function () { + const { protocolRegistry, safeDebtManager, timelock, CRITICAL_ROLE } = await deployHandlerFixture(); + + const target = await safeDebtManager.getAddress(); + const data = safeDebtManager.interface.encodeFunctionData("setProtocolHandler", [AAVE_V3, SOME_HANDLER]); + const predecessor = ethers.ZeroHash; + const salt = ethers.id("test-sdm-handler-update"); + + // Timelock already holds CRITICAL_ROLE (granted in registry constructor) + expect(await protocolRegistry.hasRole(CRITICAL_ROLE, await timelock.getAddress())).to.be.true; + + await timelock.schedule(target, 0, data, predecessor, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(target, 0, data, predecessor, salt); + + expect(await safeDebtManager.protocolHandlers(AAVE_V3)).to.equal(SOME_HANDLER); + }); + + it("DEFAULT_ADMIN_ROLE cannot directly call LeveragedPosition.setProtocolHandler", async function () { + const { leveragedPosition, admin } = await deployHandlerFixture(); + await expect( + leveragedPosition.connect(admin).setProtocolHandler(AAVE_V3, SOME_HANDLER), + ).to.be.revertedWithCustomError(leveragedPosition, "OnlyTimelock"); + }); + + it("DEFAULT_ADMIN_ROLE granting itself CRITICAL_ROLE cannot bypass LeveragedPosition handler timelock", async function () { + const { protocolRegistry, leveragedPosition, deployer, CRITICAL_ROLE } = await deployHandlerFixture(); + + await protocolRegistry.grantRole(CRITICAL_ROLE, deployer.address); + expect(await protocolRegistry.hasRole(CRITICAL_ROLE, deployer.address)).to.be.true; + + await expect( + leveragedPosition.connect(deployer).setProtocolHandler(AAVE_V3, SOME_HANDLER), + ).to.be.revertedWithCustomError(leveragedPosition, "OnlyTimelock"); + }); + + it("Timelock-scheduled execution can update LeveragedPosition handler", async function () { + const { leveragedPosition, timelock } = await deployHandlerFixture(); + + const target = await leveragedPosition.getAddress(); + const data = leveragedPosition.interface.encodeFunctionData("setProtocolHandler", [AAVE_V3, SOME_HANDLER]); + const predecessor = ethers.ZeroHash; + const salt = ethers.id("test-lp-handler-update"); + + await timelock.schedule(target, 0, data, predecessor, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(target, 0, data, predecessor, salt); + + expect(await leveragedPosition.protocolHandlers(AAVE_V3)).to.equal(SOME_HANDLER); + }); + }); }); diff --git a/test/protocols/aaveV3.ts b/test/protocols/aaveV3.ts index 69fe84f..e1f7a98 100644 --- a/test/protocols/aaveV3.ts +++ b/test/protocols/aaveV3.ts @@ -9,7 +9,7 @@ import { approve, defaultProvider, formatAmount } from "../utils"; import aaveDebtTokenJson from "../../externalAbi/aaveV3/aaveDebtToken.json"; import aaveV3PoolJson from "../../externalAbi/aaveV3/aaveV3Pool.json"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; -import { safeAddress } from "../debtSwapBySafe"; +import { safeAddress } from "../safeTestContext"; export class AaveV3Helper { private protocolDataProvider; diff --git a/test/protocols/morpho.ts b/test/protocols/morpho.ts index 76cc6fa..386cb22 100644 --- a/test/protocols/morpho.ts +++ b/test/protocols/morpho.ts @@ -18,7 +18,7 @@ import chainAgnosticBundlerV2Abi from "../../externalAbi/morpho/chainAgnosticBun import morphoAbi from "../../externalAbi/morpho/morpho.json"; import { BundlerAction } from "@morpho-org/bundler-sdk-ethers"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; -import { safeAddress } from "../debtSwapBySafe"; +import { safeAddress } from "../safeTestContext"; export const bundlerAddress = "0x23055618898e202386e6c13955a58d3c68200bfb"; export const MORPHO_ADDRESS = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; diff --git a/test/ratehopperUniV3Positions.ts b/test/ratehopperUniV3Positions.ts new file mode 100644 index 0000000..15cbd04 --- /dev/null +++ b/test/ratehopperUniV3Positions.ts @@ -0,0 +1,2501 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import dotenv from "dotenv"; +dotenv.config(); +import Safe from "@safe-global/protocol-kit"; +import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; + +import { + PARASWAP_V6_CONTRACT_ADDRESS, + Protocols, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, +} from "./constants"; +import { FLUID_VAULT_RESOLVER, FLUID_WETH_USDC_VAULT, FluidHelper } from "./protocols/fluid"; +import { eip1193Provider, fundSignerWithETH } from "./utils"; +import FluidVaultAbi from "../externalAbi/fluid/fluidVaultT1.json"; + +// ───────────────────────────────────────────────────────────────────────── +// Real Base mainnet contract addresses +// ───────────────────────────────────────────────────────────────────────── + +const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; +const WETH_USDC_500_POOL = "0xd0b53D9277642d899DF5C87A3966A349A798F224"; + +const safeAddress = process.env.TESTING_SAFE_WALLET_ADDRESS!; + +// Fee config +const MAX_FEE_BPS = 2000; +const PERFORMANCE_FEE_BPS = 1000; // 10% — performance fee on net profit at closeLp +const COLLECT_FEE_BPS = 250; // 2.5% on collected LP fees +const SLIPPAGE_BPS = 100; // 1% — per-call slippage tolerance for openLp/closeLp swaps + +// Sentinel for the L-5 expectedSwapOut argument. Tests pass +// `swapAmountOutMin = 1n`, and with `expectedSwapOut = 1n` the relationship +// `1 >= (1 * (10_000 - SLIPPAGE_BPS)) / 10_000` is satisfied for any +// `SLIPPAGE_BPS > 0`. Production callers must supply a real quoter-derived +// value here. +const EXPECTED_SWAP_OUT = 1n; + +// Deadline passed to openLp / closeLp in tests. Uses MaxUint256 so the +// staleness check never fires unintentionally. +const FAR_DEADLINE = ethers.MaxUint256; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +// ───────────────────────────────────────────────────────────────────────── +// Minimal ABIs (only what we need) +// ───────────────────────────────────────────────────────────────────────── + +const NPM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function balanceOf(address) view returns (uint256)", + "function approve(address,uint256)", + "function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)", + "function mint((address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)", + "function decreaseLiquidity((uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)", + "function transferFrom(address from, address to, uint256 tokenId)", +]; + +const WETH_ABI = [ + "function deposit() payable", + "function withdraw(uint256)", + "function approve(address,uint256) returns (bool)", + "function balanceOf(address) view returns (uint256)", +]; + +const ERC20_VIEW_ABI = ["function balanceOf(address) view returns (uint256)"]; + +// Base USDC is a FiatTokenV2_2 with a blacklister role. We impersonate it to +// blacklist the treasury, which makes any USDC `transfer(treasury, …)` revert — +// the exact condition the non-fatal fee paths (L-3 collect fee + perf fee) +// must tolerate. +const USDC_BLACKLIST_ABI = [ + "function blacklister() view returns (address)", + "function blacklist(address)", + "function isBlacklisted(address) view returns (bool)", +]; + +const UNISWAP_V3_FACTORY_ABI = ["function getPool(address,address,uint24) view returns (address)"]; + +const UNISWAP_V3_POOL_ABI = [ + "function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8, bool)", +]; + +// ───────────────────────────────────────────────────────────────────────── +// Fixture — deploys ProtocolRegistry + FluidSafeHandler + SafeDebtManager + +// RatehopperUniV3Positions. RHP gates openLp/closeLp/collectLp on +// `onlyOperatorOrSafe` (Safe self-call OR the registry's `safeOperator`). +// All NPM/SwapRouter calls inside RHP are module-mediated via the Safe, so +// RHP itself does NOT need to be the registry's `safeOperator` — that slot +// stays free for the backend EOA driving closes on the Safe's behalf. +// ───────────────────────────────────────────────────────────────────────── + +async function deployFixture() { + const [deployer, _signer1, _signer2, pauser, treasury] = await ethers.getSigners(); + + // Use deployer as both initialAdmin AND timelock so we get CRITICAL_ROLE + // immediately (no TimelockController indirection) and can call + // setOperator() to register RHP. + const ProtocolRegistry = await ethers.getContractFactory("ProtocolRegistry"); + const protocolRegistry = await ProtocolRegistry.deploy( + WETH_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + deployer.address, + deployer.address, + deployer.address, + PARASWAP_V6_CONTRACT_ADDRESS, + ); + await protocolRegistry.waitForDeployment(); + + await (await protocolRegistry.addToWhitelistBatch([WETH_ADDRESS, USDC_ADDRESS])).wait(); + await (await protocolRegistry.setFluidVaultResolver(FLUID_VAULT_RESOLVER)).wait(); + + const FluidSafeHandler = await ethers.getContractFactory("FluidSafeHandler"); + const fluidHandler = await FluidSafeHandler.deploy(UNISWAP_V3_FACTORY_ADDRESS, await protocolRegistry.getAddress()); + await fluidHandler.waitForDeployment(); + + const SafeDebtManager = await ethers.getContractFactory("SafeDebtManager"); + const safeDebtManager = await SafeDebtManager.deploy( + await protocolRegistry.getAddress(), + [Protocols.FLUID], + [await fluidHandler.getAddress()], + pauser.address, + ); + await safeDebtManager.waitForDeployment(); + + const RHP = await ethers.getContractFactory("RatehopperUniV3Positions"); + const rhp = await RHP.deploy( + UNISWAP_V3_NPM_ADDRESS, + await protocolRegistry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + treasury.address, + PERFORMANCE_FEE_BPS, + COLLECT_FEE_BPS, + MAX_FEE_BPS, + deployer.address, // _initialAdmin + deployer.address, // _timelock (for tests, deployer holds both roles) + 0, // _minPoolLiquidity (disabled by default in tests) + 0, // _minPositionLiquidity (disabled by default in tests) + ); + await rhp.waitForDeployment(); + + return { deployer, pauser, treasury, protocolRegistry, fluidHandler, safeDebtManager, rhp }; +} + +// Inlined Fluid supply+borrow helper. We don't import from debtSwapBySafe.ts +// because that file is both a library and a test file — importing it would +// register all its describe/it blocks with mocha and run them every time +// this file is executed. +async function supplyAndBorrowOnFluid( + signer: ethers.Wallet, + safeWallet: any, + vaultAddress: string, + supplyAmount: bigint, + borrowAmount: bigint, +) { + // 1. Send ETH directly to the Safe; Fluid accepts native ETH for the + // WETH/USDC vault and wraps internally. + const seedTx = await signer.sendTransaction({ to: safeAddress, value: supplyAmount }); + await seedTx.wait(); + + const fluidVault = new ethers.Contract(vaultAddress, FluidVaultAbi, signer); + + // 2. Supply: operate(nftId=0, newCol=+supplyAmount, newDebt=0, to=Safe) + const supplyTx: MetaTransactionData = { + to: vaultAddress, + value: supplyAmount.toString(), + data: fluidVault.interface.encodeFunctionData("operate", [0, supplyAmount, 0, safeAddress]), + operation: OperationType.Call, + }; + await safeWallet.executeTransaction(await safeWallet.createTransaction({ transactions: [supplyTx] })); + + // 3. Query the Fluid vault for the just-created NFT id. + const fluidHelper = new FluidHelper(signer); + const nftId = await fluidHelper.getNftId(vaultAddress, safeAddress); + + // 4. Borrow: operate(nftId, newCol=0, newDebt=+borrowAmount, to=Safe). + // Borrowed USDC stays on the Safe. + const borrowTx: MetaTransactionData = { + to: vaultAddress, + value: "0", + data: fluidVault.interface.encodeFunctionData("operate", [nftId, 0, borrowAmount, safeAddress]), + operation: OperationType.Call, + }; + await safeWallet.executeTransaction(await safeWallet.createTransaction({ transactions: [borrowTx] })); +} + +// Reads the WETH/USDC 500-bps pool's current tick and returns a wide, +// spacing-aligned range centred on it — so an LP minted here holds both legs +// (in range) rather than being one-sided. +async function wideTicksAroundSpot(): Promise<{ tickLower: number; tickUpper: number }> { + const pool = new ethers.Contract(WETH_USDC_500_POOL, UNISWAP_V3_POOL_ABI, ethers.provider); + const currentTick = Number((await pool.slot0()).tick); + const spacing = 10; + return { + tickLower: Math.floor((currentTick - 5_000) / spacing) * spacing, + tickUpper: Math.ceil((currentTick + 5_000) / spacing) * spacing, + }; +} + +// Returns a spacing-aligned range entirely BELOW the current tick. With +// token0 = WETH and token1 = USDC, a range below spot holds only token1, so +// an LP minted here is USDC-only — the contract's `wethToSwap == 0` (closeLp) +// and `collected0 == 0` (_collectLp) branches. +async function ticksBelowSpot(): Promise<{ tickLower: number; tickUpper: number }> { + const pool = new ethers.Contract(WETH_USDC_500_POOL, UNISWAP_V3_POOL_ABI, ethers.provider); + const currentTick = Number((await pool.slot0()).tick); + const spacing = 10; + const tickUpper = Math.floor((currentTick - 2_000) / spacing) * spacing; + return { tickLower: tickUpper - 4_000, tickUpper }; +} + +// Impersonates the Base USDC blacklister and blacklists `target`, so every +// subsequent USDC `transfer(target, …)` reverts. Used to drive the non-fatal +// fee branches (L-3 collect fee, perf fee) that must not brick user exits. +async function blacklistUsdc(target: string) { + const usdc = new ethers.Contract(USDC_ADDRESS, USDC_BLACKLIST_ABI, ethers.provider); + const blacklister: string = await usdc.blacklister(); + await fundSignerWithETH(blacklister, "1"); + await network.provider.request({ method: "hardhat_impersonateAccount", params: [blacklister] }); + const blSigner = await ethers.getSigner(blacklister); + await ( + await blSigner.sendTransaction({ + to: USDC_ADDRESS, + data: usdc.interface.encodeFunctionData("blacklist", [target]), + }) + ).wait(); + await network.provider.request({ method: "hardhat_stopImpersonatingAccount", params: [blacklister] }); + expect(await usdc.isBlacklisted(target)).to.equal(true); +} + +// Enable RHP as a module, run a Fluid supply+borrow, then open a balanced +// in-range LP via openLp. Returns the minted tokenId. Shared by the +// performance-fee tests. +async function openBalancedPosition( + rhp: any, + safeWallet: any, + signer: ethers.Wallet, + supplyEth: bigint, + borrowUsdc: bigint, +): Promise { + const rhpAddress = await rhp.getAddress(); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + await supplyAndBorrowOnFluid(signer, safeWallet, FLUID_WETH_USDC_VAULT, supplyEth, borrowUsdc); + + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const usdcAmount = await usdc.balanceOf(safeAddress); + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("openLp", [ + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const opened = await rhp.queryFilter(rhp.filters.PositionOpened(safeAddress), -10); + return opened[opened.length - 1].args.tokenId as bigint; +} + +// Run closeLp via the Safe with the given baseline (performanceFeeBps already set on the +// contract). Returns the PositionClosed event values + treasury/Safe USDC +// deltas across the call. Since the position is opened and closed in the same +// test with no external pool volume, accrued trading fees are ~0 — so the +// treasury USDC delta isolates the performance fee. +async function closeAndMeasure( + rhp: any, + safeWallet: any, + treasuryAddr: string, + tokenId: bigint, + exitBps: number = 10_000, +): Promise<{ + basisUsd6: bigint; + currentValueUsd6: bigint; + feeUsd6: bigint; + treasuryDelta: bigint; + safeDelta: bigint; + exitBpsEmitted: number; +}> { + const rhpAddress = await rhp.getAddress(); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const tBefore: bigint = await usdc.balanceOf(treasuryAddr); + const sBefore: bigint = await usdc.balanceOf(safeAddress); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("closeLp", [ + safeAddress, + tokenId, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + exitBps, + 0, + 0, + FAR_DEADLINE, + 0, // minUsdcOut — disabled + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const ev = (await rhp.queryFilter(rhp.filters.PositionClosed(safeAddress, tokenId), -10)).slice(-1)[0].args; + return { + basisUsd6: ev.basisUsd6 as bigint, + currentValueUsd6: ev.currentValueUsd6 as bigint, + feeUsd6: ev.feeUsd6 as bigint, + treasuryDelta: ((await usdc.balanceOf(treasuryAddr)) as bigint) - tBefore, + safeDelta: ((await usdc.balanceOf(safeAddress)) as bigint) - sBefore, + exitBpsEmitted: Number(ev.exitBps), + }; +} + +// ───────────────────────────────────────────────────────────────────────── +// Unit tests — pure contract logic, no fork dependencies on Uniswap/Fluid +// ───────────────────────────────────────────────────────────────────────── + +describe("RatehopperUniV3Positions - constructor", function () { + it("stores the immutables and initial mutables", async function () { + const { rhp, deployer, treasury, protocolRegistry } = await deployFixture(); + expect(await rhp.POSITION_MANAGER()).to.equal(UNISWAP_V3_NPM_ADDRESS); + expect(await rhp.REGISTRY()).to.equal(await protocolRegistry.getAddress()); + expect(await rhp.USDC()).to.equal(USDC_ADDRESS); + expect(await rhp.SWAP_ROUTER()).to.equal(UNISWAP_V3_SWAP_ROUTER_ADDRESS); + expect(await rhp.UNISWAP_V3_FACTORY()).to.equal(UNISWAP_V3_FACTORY_ADDRESS); + expect(await rhp.MAX_FEE_BPS()).to.equal(MAX_FEE_BPS); + expect(await rhp.timelock()).to.equal(deployer.address); + expect(await rhp.treasury()).to.equal(treasury.address); + expect(await rhp.performanceFeeBps()).to.equal(PERFORMANCE_FEE_BPS); + expect(await rhp.feeCollectBps()).to.equal(COLLECT_FEE_BPS); + expect(await rhp.maxSlippageBps()).to.equal(300); + expect(await rhp.MAX_SETTABLE_SLIPPAGE_BPS()).to.equal(1000); + expect(await rhp.allowedFeeTier(100)).to.equal(true); + expect(await rhp.allowedFeeTier(500)).to.equal(true); + expect(await rhp.allowedFeeTier(3000)).to.equal(true); + expect(await rhp.allowedFeeTier(10000)).to.equal(false); + expect(await rhp.minPoolLiquidity()).to.equal(0n); // H-01 default: disabled + expect(await rhp.minPositionLiquidity()).to.equal(0n); // L-2 default: disabled + // CRITICAL_ROLE is self-administered to prevent DEFAULT_ADMIN_ROLE bypass. + const CRITICAL_ROLE = await rhp.CRITICAL_ROLE(); + expect(await rhp.getRoleAdmin(CRITICAL_ROLE)).to.equal(CRITICAL_ROLE); + }); + + it("reverts on zero addresses and on performanceFeeBps / feeCollectBps > maxFeeBps", async function () { + const { treasury, protocolRegistry } = await deployFixture(); + const F = await ethers.getContractFactory("RatehopperUniV3Positions"); + const registryAddr = await protocolRegistry.getAddress(); + const [deployer] = await ethers.getSigners(); + + // Build constructor args with one override at a time so each + // zero-address slot is exercised independently. + const defaults = { + positionManager: UNISWAP_V3_NPM_ADDRESS as string, + registry: registryAddr, + usdc: USDC_ADDRESS as string, + weth: WETH_ADDRESS as string, + swapRouter: UNISWAP_V3_SWAP_ROUTER_ADDRESS as string, + uniswapV3Factory: UNISWAP_V3_FACTORY_ADDRESS as string, + treasury: treasury.address, + performanceFeeBps: PERFORMANCE_FEE_BPS as number, + feeCollectBps: COLLECT_FEE_BPS as number, + maxFeeBps: MAX_FEE_BPS as number, + initialAdmin: deployer.address, + timelock: deployer.address, + minPoolLiquidity: 0n as bigint, + minPositionLiquidity: 0n as bigint, + }; + const build = (o: Partial = {}): any[] => { + const m = { ...defaults, ...o }; + return [ + m.positionManager, + m.registry, + m.usdc, + m.weth, + m.swapRouter, + m.uniswapV3Factory, + m.treasury, + m.performanceFeeBps, + m.feeCollectBps, + m.maxFeeBps, + m.initialAdmin, + m.timelock, + m.minPoolLiquidity, + m.minPositionLiquidity, + ]; + }; + + await expect(F.deploy(...build({ positionManager: ZERO_ADDRESS }))).to.be.revertedWithCustomError( + F, + "ZeroAddress", + ); + await expect(F.deploy(...build({ registry: ZERO_ADDRESS }))).to.be.revertedWithCustomError(F, "ZeroAddress"); + await expect(F.deploy(...build({ usdc: ZERO_ADDRESS }))).to.be.revertedWithCustomError(F, "ZeroAddress"); + await expect(F.deploy(...build({ weth: ZERO_ADDRESS }))).to.be.revertedWithCustomError(F, "ZeroAddress"); + await expect(F.deploy(...build({ swapRouter: ZERO_ADDRESS }))).to.be.revertedWithCustomError(F, "ZeroAddress"); + await expect(F.deploy(...build({ uniswapV3Factory: ZERO_ADDRESS }))).to.be.revertedWithCustomError( + F, + "ZeroAddress", + ); + await expect(F.deploy(...build({ treasury: ZERO_ADDRESS }))).to.be.revertedWithCustomError( + F, + "InvalidTreasury", + ); + await expect(F.deploy(...build({ performanceFeeBps: MAX_FEE_BPS + 1 }))).to.be.revertedWithCustomError( + F, + "FeeAboveMax", + ); + await expect(F.deploy(...build({ feeCollectBps: MAX_FEE_BPS + 1 }))).to.be.revertedWithCustomError( + F, + "FeeAboveMax", + ); + await expect(F.deploy(...build({ initialAdmin: ZERO_ADDRESS }))).to.be.revertedWithCustomError( + F, + "ZeroAddress", + ); + await expect(F.deploy(...build({ timelock: ZERO_ADDRESS }))).to.be.revertedWithCustomError(F, "ZeroAddress"); + + // M-03: swap WETH/USDC slots so weth address > usdc address, must revert. + await expect(F.deploy(...build({ usdc: WETH_ADDRESS, weth: USDC_ADDRESS }))).to.be.revertedWithCustomError( + F, + "WrongTokenOrder", + ); + }); + + it("stores constructor-supplied liquidity floors and emits their initial values", async function () { + const { treasury, protocolRegistry } = await deployFixture(); + const F = await ethers.getContractFactory("RatehopperUniV3Positions"); + const [deployer] = await ethers.getSigners(); + const minPool = 12_345n; + const minPosition = 67_890n; + + const rhp = await F.deploy( + UNISWAP_V3_NPM_ADDRESS, + await protocolRegistry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + treasury.address, + PERFORMANCE_FEE_BPS, + COLLECT_FEE_BPS, + MAX_FEE_BPS, + deployer.address, + deployer.address, + minPool, + minPosition, + ); + await rhp.waitForDeployment(); + + expect(await rhp.minPoolLiquidity()).to.equal(minPool); + expect(await rhp.minPositionLiquidity()).to.equal(minPosition); + + const deployTx = rhp.deploymentTransaction()!; + await expect(deployTx).to.emit(rhp, "MinPoolLiquidityUpdated").withArgs(0, minPool); + await expect(deployTx).to.emit(rhp, "MinPositionLiquidityUpdated").withArgs(0, minPosition); + }); +}); + +describe("RatehopperUniV3Positions - owner setters", function () { + it("setTreasury emits, validates, and rejects non-owner", async function () { + const { rhp, deployer, treasury } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + await expect(rhp.connect(deployer).setTreasury(other.address)) + .to.emit(rhp, "TreasuryUpdated") + .withArgs(treasury.address, other.address); + expect(await rhp.treasury()).to.equal(other.address); + + await expect(rhp.connect(deployer).setTreasury(ZERO_ADDRESS)).to.be.revertedWithCustomError( + rhp, + "InvalidTreasury", + ); + await expect(rhp.connect(other).setTreasury(other.address)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setPerformanceFeeBps emits, validates the cap, and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + await expect(rhp.connect(deployer).setPerformanceFeeBps(500)) + .to.emit(rhp, "PerformanceFeeBpsUpdated") + .withArgs(PERFORMANCE_FEE_BPS, 500); + expect(await rhp.performanceFeeBps()).to.equal(500); + + await expect(rhp.connect(deployer).setPerformanceFeeBps(MAX_FEE_BPS + 1)).to.be.revertedWithCustomError( + rhp, + "FeeAboveMax", + ); + await expect(rhp.connect(other).setPerformanceFeeBps(100)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setFeeCollectBps emits, validates the cap, and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + await expect(rhp.connect(deployer).setFeeCollectBps(500)) + .to.emit(rhp, "FeeCollectBpsUpdated") + .withArgs(COLLECT_FEE_BPS, 500); + expect(await rhp.feeCollectBps()).to.equal(500); + + await expect(rhp.connect(deployer).setFeeCollectBps(MAX_FEE_BPS + 1)).to.be.revertedWithCustomError( + rhp, + "FeeAboveMax", + ); + await expect(rhp.connect(other).setFeeCollectBps(100)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setMaxSlippageBps emits, validates the ceiling, and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + await expect(rhp.connect(deployer).setMaxSlippageBps(500)) + .to.emit(rhp, "MaxSlippageBpsUpdated") + .withArgs(300, 500); + expect(await rhp.maxSlippageBps()).to.equal(500); + + const ceiling = Number(await rhp.MAX_SETTABLE_SLIPPAGE_BPS()); + await expect(rhp.connect(deployer).setMaxSlippageBps(ceiling + 1)).to.be.revertedWithCustomError( + rhp, + "SlippageAboveMax", + ); + await expect(rhp.connect(other).setMaxSlippageBps(100)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setFeeTierAllowed emits, flips state both directions, and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + expect(await rhp.allowedFeeTier(10000)).to.equal(false); + await expect(rhp.connect(deployer).setFeeTierAllowed(10000, true)) + .to.emit(rhp, "FeeTierAllowedUpdated") + .withArgs(10000, false, true); + expect(await rhp.allowedFeeTier(10000)).to.equal(true); + + await expect(rhp.connect(deployer).setFeeTierAllowed(500, false)) + .to.emit(rhp, "FeeTierAllowedUpdated") + .withArgs(500, true, false); + expect(await rhp.allowedFeeTier(500)).to.equal(false); + + await expect(rhp.connect(other).setFeeTierAllowed(100, false)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setMinPoolLiquidity emits, accepts 0 (disabled), and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + const newFloor = 1_000_000n; + await expect(rhp.connect(deployer).setMinPoolLiquidity(newFloor)) + .to.emit(rhp, "MinPoolLiquidityUpdated") + .withArgs(0n, newFloor); + expect(await rhp.minPoolLiquidity()).to.equal(newFloor); + + await expect(rhp.connect(deployer).setMinPoolLiquidity(0n)) + .to.emit(rhp, "MinPoolLiquidityUpdated") + .withArgs(newFloor, 0n); + expect(await rhp.minPoolLiquidity()).to.equal(0n); + + await expect(rhp.connect(other).setMinPoolLiquidity(123n)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setMinPositionLiquidity emits, accepts 0 (disabled), and rejects non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other] = await ethers.getSigners(); + + const newFloor = 1_000n; + await expect(rhp.connect(deployer).setMinPositionLiquidity(newFloor)) + .to.emit(rhp, "MinPositionLiquidityUpdated") + .withArgs(0n, newFloor); + expect(await rhp.minPositionLiquidity()).to.equal(newFloor); + + await expect(rhp.connect(deployer).setMinPositionLiquidity(0n)) + .to.emit(rhp, "MinPositionLiquidityUpdated") + .withArgs(newFloor, 0n); + expect(await rhp.minPositionLiquidity()).to.equal(0n); + + await expect(rhp.connect(other).setMinPositionLiquidity(123n)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("rescueToken transfers a real balance, emits, and rejects zero-address / non-owner", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other, recipient] = await ethers.getSigners(); + const rhpAddress = await rhp.getAddress(); + + // Stage a stuck WETH balance on RHP: signer wraps native ETH to WETH + // then transfers some to the contract. This simulates a user + // accidentally sending tokens directly to RHP (the exact case + // rescueToken is built for). + const weth = new ethers.Contract( + WETH_ADDRESS, + [ + "function deposit() payable", + "function transfer(address,uint256) returns (bool)", + "function balanceOf(address) view returns (uint256)", + ], + deployer, + ); + const stuckAmount = ethers.parseEther("0.001"); + await (await weth.deposit({ value: stuckAmount })).wait(); + await (await weth.transfer(rhpAddress, stuckAmount)).wait(); + expect(await weth.balanceOf(rhpAddress)).to.equal(stuckAmount); + + const recipBalBefore: bigint = await weth.balanceOf(recipient.address); + + // Happy path: emits TokenRescued, moves the full balance to recipient. + await expect(rhp.connect(deployer).rescueToken(WETH_ADDRESS, recipient.address, stuckAmount)) + .to.emit(rhp, "TokenRescued") + .withArgs(WETH_ADDRESS, recipient.address, stuckAmount); + expect(await weth.balanceOf(rhpAddress)).to.equal(0); + expect(await weth.balanceOf(recipient.address)).to.equal(recipBalBefore + stuckAmount); + + // Reverts on zero token address. + await expect( + rhp.connect(deployer).rescueToken(ZERO_ADDRESS, recipient.address, 0), + ).to.be.revertedWithCustomError(rhp, "ZeroAddress"); + + // Reverts on zero recipient address. + await expect(rhp.connect(deployer).rescueToken(WETH_ADDRESS, ZERO_ADDRESS, 0)).to.be.revertedWithCustomError( + rhp, + "ZeroAddress", + ); + + // Reverts when called by non-owner. + await expect(rhp.connect(other).rescueToken(WETH_ADDRESS, recipient.address, 0)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + + it("setPerformanceFeeBps via TimelockController: rejects direct admin call, accepts scheduled+executed timelock call", async function () { + const [deployer, proposerExecutor, otherAdmin] = await ethers.getSigners(); + + // 1. Reuse the main fixture's registry — saves rebuilding ProtocolRegistry + + // handlers just to satisfy the RHP constructor's non-zero registry check. + const { protocolRegistry } = await deployFixture(); + const registryAddr = await protocolRegistry.getAddress(); + + // 2. Deploy a real TimelockController (2-day delay). `proposerExecutor` + // EOA is both proposer and executor; no separate admin (the timelock + // itself holds DEFAULT_ADMIN_ROLE on itself). + const MIN_DELAY = 2 * 24 * 60 * 60; + const Timelock = await ethers.getContractFactory("TimelockController"); + const timelock = await Timelock.deploy( + MIN_DELAY, + [proposerExecutor.address], + [proposerExecutor.address], + ZERO_ADDRESS, + ); + await timelock.waitForDeployment(); + const timelockAddr = await timelock.getAddress(); + + // 3. Deploy a fresh RHP with `_initialAdmin = otherAdmin` and + // `_timelock = timelockAddr`. otherAdmin gets DEFAULT_ADMIN_ROLE + // (rescueToken etc.), the timelock holds CRITICAL_ROLE (setters). + const RHP = await ethers.getContractFactory("RatehopperUniV3Positions"); + const rhpStandalone = await RHP.deploy( + UNISWAP_V3_NPM_ADDRESS, + registryAddr, + USDC_ADDRESS, + WETH_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + deployer.address, // treasury (any non-zero is fine for this test) + PERFORMANCE_FEE_BPS, + COLLECT_FEE_BPS, + MAX_FEE_BPS, + otherAdmin.address, // _initialAdmin → DEFAULT_ADMIN_ROLE + timelockAddr, // _timelock → CRITICAL_ROLE + 0, // _minPoolLiquidity + 0, // _minPositionLiquidity + ); + await rhpStandalone.waitForDeployment(); + const rhpAddr = await rhpStandalone.getAddress(); + + // 4. Direct calls bypassing the timelock are rejected — even the + // DEFAULT_ADMIN_ROLE holder cannot tweak fee setters. + await expect(rhpStandalone.connect(otherAdmin).setPerformanceFeeBps(500)).to.be.revertedWithCustomError( + rhpStandalone, + "AccessControlUnauthorizedAccount", + ); + await expect(rhpStandalone.connect(deployer).setPerformanceFeeBps(500)).to.be.revertedWithCustomError( + rhpStandalone, + "AccessControlUnauthorizedAccount", + ); + + // 5. Schedule a setPerformanceFeeBps(500) call through the timelock. + const newFee = 500; + const callData = rhpStandalone.interface.encodeFunctionData("setPerformanceFeeBps", [newFee]); + const predecessor = ethers.ZeroHash; + const salt = ethers.id("test-update-perf-fee"); + const operationId = await timelock.hashOperation(rhpAddr, 0, callData, predecessor, salt); + + await expect( + timelock.connect(proposerExecutor).schedule(rhpAddr, 0, callData, predecessor, salt, MIN_DELAY), + ).to.emit(timelock, "CallScheduled"); + + // 6. Cannot execute before the delay elapses. + await expect( + timelock.connect(proposerExecutor).execute(rhpAddr, 0, callData, predecessor, salt), + ).to.be.revertedWithCustomError(timelock, "TimelockUnexpectedOperationState"); + + // 7. Advance time past the delay. + await network.provider.send("evm_increaseTime", [MIN_DELAY + 1]); + await network.provider.send("evm_mine"); + + // 8. Execute through the timelock → fee setter fires, fee updates, + // event emits with the actual previous value (PERFORMANCE_FEE_BPS). + await expect(timelock.connect(proposerExecutor).execute(rhpAddr, 0, callData, predecessor, salt)) + .to.emit(rhpStandalone, "PerformanceFeeBpsUpdated") + .withArgs(PERFORMANCE_FEE_BPS, newFee) + .and.to.emit(timelock, "CallExecuted"); + + expect(await rhpStandalone.performanceFeeBps()).to.equal(newFee); + + // 9. Operation is now Done — re-executing reverts (state machine moves + // from Waiting → Ready → Done; a Done op cannot be re-executed). + expect(await timelock.isOperationDone(operationId)).to.equal(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// openLp / closeLp entry-guard reverts — pure input validation that fires +// before any module-mediated call, so no fork Safe / funding is needed. +// The registered operator is authorized for any `_onBehalfOf`, so a dummy +// non-zero address stands in for the Safe. +// ───────────────────────────────────────────────────────────────────────── + +describe("RatehopperUniV3Positions - openLp/closeLp input guards", function () { + async function setup() { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA, , dummySafe] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + return { rhp: rhp.connect(operatorEOA), onBehalf: dummySafe.address }; + } + + it("openLp reverts DeadlineExpired when the deadline has passed", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.openLp(onBehalf, 1n, 0, 0, 500, 0, 0, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 0), + ).to.be.revertedWithCustomError(rhp, "DeadlineExpired"); + }); + + it("openLp reverts SlippageTooLow when slippageBps == 0", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.openLp(onBehalf, 1n, 0, 0, 500, 0, 0, 500, 1n, EXPECTED_SWAP_OUT, 0, FAR_DEADLINE), + ).to.be.revertedWithCustomError(rhp, "SlippageTooLow"); + }); + + it("openLp reverts InvalidExpectedSwapOut when expectedSwapOut == 0", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.openLp(onBehalf, 1n, 0, 0, 500, 0, 0, 500, 1n, 0, SLIPPAGE_BPS, FAR_DEADLINE), + ).to.be.revertedWithCustomError(rhp, "InvalidExpectedSwapOut"); + }); + + it("openLp reverts SwapMinBelowSlippageFloor when swapAmountOutMin undercuts the slippage floor", async function () { + const { rhp, onBehalf } = await setup(); + // expectedSwapOut = 1e6, slippageBps = 100 → floor = 990000; min = 1 < floor. + await expect( + rhp.openLp(onBehalf, 1n, 0, 0, 500, 0, 0, 500, 1n, 1_000_000n, SLIPPAGE_BPS, FAR_DEADLINE), + ).to.be.revertedWithCustomError(rhp, "SwapMinBelowSlippageFloor"); + }); + + it("closeLp reverts DeadlineExpired when the deadline has passed", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.closeLp(onBehalf, 1n, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 5_000, 0, 0, 0, 0), + ).to.be.revertedWithCustomError(rhp, "DeadlineExpired"); + }); + + it("closeLp reverts SlippageTooLow when slippageBps == 0", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.closeLp(onBehalf, 1n, 500, 1n, EXPECTED_SWAP_OUT, 0, 5_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "SlippageTooLow"); + }); + + it("closeLp reverts InvalidExpectedSwapOut when expectedSwapOut == 0", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.closeLp(onBehalf, 1n, 500, 1n, 0, SLIPPAGE_BPS, 5_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "InvalidExpectedSwapOut"); + }); + + it("closeLp reverts SwapMinBelowSlippageFloor when swapAmountOutMin undercuts the slippage floor", async function () { + const { rhp, onBehalf } = await setup(); + await expect( + rhp.closeLp(onBehalf, 1n, 500, 1n, 1_000_000n, SLIPPAGE_BPS, 5_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "SwapMinBelowSlippageFloor"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Integration test — real Base mainnet contracts on the fork +// ───────────────────────────────────────────────────────────────────────── + +describe("RatehopperUniV3Positions - integration (Base fork)", function () { + this.timeout(300_000); + + let signer: ethers.Wallet; + let safeWallet: Awaited>; + + beforeEach(async function () { + if (!process.env.TESTING_SAFE_OWNER_KEY || !process.env.TESTING_SAFE_WALLET_ADDRESS) { + this.skip(); + } + // Reset the fork between tests so each starts from a clean Base + // mainnet snapshot. The env-driven Safe is referenced by address + // (not redeployed), so without this reset state from one test + // (e.g. an unrepaid Fluid debt) leaks into the next. + await network.provider.request({ + method: "hardhat_reset", + params: [{ forking: { jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org" } }], + }); + + signer = new ethers.Wallet(process.env.TESTING_SAFE_OWNER_KEY!, ethers.provider); + await fundSignerWithETH(signer.address, "10"); + await fundSignerWithETH(safeAddress, "10"); + + safeWallet = await Safe.init({ + provider: eip1193Provider, + signer: process.env.TESTING_SAFE_OWNER_KEY, + safeAddress, + }); + }); + + it("closes an LP-backed Fluid debt position end-to-end via openLp + closeLp", async function () { + const { rhp, safeDebtManager, treasury } = await deployFixture(); + const safeDebtManagerAddress = await safeDebtManager.getAddress(); + const rhpAddress = await rhp.getAddress(); + + // Pretty-printers for the running log. + const usdcCx = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const fmtUsdc = (v: bigint) => `${ethers.formatUnits(v, 6)} USDC`; + + console.log("\n ─── Deployed addresses ───"); + console.log(` RatehopperUniV3Positions: ${rhpAddress}`); + console.log(` SafeDebtManager: ${safeDebtManagerAddress}`); + console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Treasury: ${treasury.address}`); + + // 1. Enable both modules on the Safe: SafeDebtManager (for exit's + // token transfer) and RatehopperUniV3Positions (so openLp/closeLp + // can drive the LP lifecycle module-mediated, no approvals needed). + console.log("\n [1/6] Enable SafeDebtManager + RatehopperUniV3Positions as Safe modules"); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(safeDebtManagerAddress)); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + console.log(` modules enabled: ${safeDebtManagerAddress}, ${rhpAddress}`); + + // 2. Open a real Fluid debt position: supply 0.01 ETH (auto-wrapped + // by the WETH/USDC vault), borrow 10 USDC. Safe ends with 10 USDC + // of debt and 10 USDC of free balance — enough for openLp to split. + console.log("\n [2/6] Open Fluid debt: supply 0.01 ETH, borrow 10 USDC"); + const supplyEth = ethers.parseEther("0.01"); + const borrowUsdc = ethers.parseUnits("10", 6); + await supplyAndBorrowOnFluid(signer, safeWallet, FLUID_WETH_USDC_VAULT, supplyEth, borrowUsdc); + const safeUsdcAfterBorrow: bigint = await usdcCx.balanceOf(safeAddress); + console.log(` Safe USDC balance after borrow: ${fmtUsdc(safeUsdcAfterBorrow)}`); + + // 3. Pick a balanced tick range around the current spot (so openLp's + // swap-half-then-mint yields a balanced position). Uses the same + // WETH/USDC 500-bps pool both for the swap leg and the LP mint. + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + console.log(`\n [3/6] Tick range chosen around spot: [${tickLower}, ${tickUpper}]`); + + // 4. openLp via the Safe — the canonical entry point. Splits the + // borrowed USDC, swaps half to WETH on SwapRouter02, mints the + // WETH/USDC LP NFT, and stores `residualBasisUsd6Of[tokenId]` so + // closeLp can read the basis later (C-03). + console.log("\n [4/6] openLp via Safe (USDC split + swap + mint, basis stored on-chain)"); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("openLp", [ + safeAddress, + safeUsdcAfterBorrow, + tickLower, + tickUpper, + 500, // lpPoolFeeTier + 0, // mintAmount0Min — opt-out for balanced range + 0, // mintAmount1Min + 500, // swapPoolFeeTier + 1n, // swapAmountOutMin — contract rejects 0; 1 wei = effectively disabled + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const openedEv = (await rhp.queryFilter(rhp.filters.PositionOpened(safeAddress), -10)).slice(-1)[0].args; + const tokenId: bigint = openedEv.tokenId; + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + const storedBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + console.log(` minted tokenId: ${tokenId}; basis stored: ${fmtUsdc(storedBasis)}`); + + // 5. Capture balances and run closeLp via the Safe. After C-03 the + // perf-fee basis is read on-chain from `residualBasisUsd6Of[tokenId]`; + // caller can no longer attest a value. + const treasuryUsdcBefore = await usdcCx.balanceOf(treasury.address); + const safeUsdcBefore = await usdcCx.balanceOf(safeAddress); + console.log("\n [5/6] Call closeLp via Safe (basis read from storage)"); + console.log(` pre-close treasury USDC: ${fmtUsdc(treasuryUsdcBefore)}`); + console.log(` pre-close safe USDC: ${fmtUsdc(safeUsdcBefore)}`); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("closeLp", [ + safeAddress, + tokenId, + 500, // swapPoolFeeTier + 1n, // swapAmountOutMin — contract rejects 0; 1 wei = effectively disabled + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + 10_000, // exitBps — full close + 0, // decreaseAmount0Min + 0, // decreaseAmount1Min + FAR_DEADLINE, + 0, // minUsdcOut — disabled + ]), + operation: OperationType.Call, + }, + ], + }), + ); + + // 6. Read the PositionClosed event + derive deltas. + const closedEv = (await rhp.queryFilter(rhp.filters.PositionClosed(safeAddress, tokenId), -10)).slice(-1)[0] + .args; + const basisUsd6: bigint = closedEv.basisUsd6; + const currentValueUsd6: bigint = closedEv.currentValueUsd6; + const feeUsd6: bigint = closedEv.feeUsd6; + + const treasuryUsdcAfter = await usdcCx.balanceOf(treasury.address); + const safeUsdcAfter = await usdcCx.balanceOf(safeAddress); + const rhpUsdcAfter = await usdcCx.balanceOf(rhpAddress); + const feeCharged = treasuryUsdcAfter - treasuryUsdcBefore; + const safeNetGain = safeUsdcAfter - safeUsdcBefore; + let nftBurned = false; + try { + await npm.ownerOf(tokenId); + } catch { + nftBurned = true; + } + + // Expected fee = max(0, realized - basis) * perfBps / 10_000. + const expectedFee = + currentValueUsd6 > basisUsd6 + ? ((currentValueUsd6 - basisUsd6) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n + : 0n; + + console.log("\n ─── Results ───"); + console.log(` basis used (event): ${fmtUsdc(basisUsd6)}`); + console.log(` realized value (event): ${fmtUsdc(currentValueUsd6)}`); + console.log(` perf fee (event): ${fmtUsdc(feeUsd6)} (${PERFORMANCE_FEE_BPS / 100}% of profit)`); + console.log(` fee → treasury (delta): ${fmtUsdc(feeCharged)}`); + console.log(` net → Safe (delta): ${fmtUsdc(safeNetGain)}`); + console.log(` RatehopperUniV3Positions residual USDC: ${fmtUsdc(rhpUsdcAfter)} (expected 0)`); + console.log(` NFT burned by closeLp: ${nftBurned}\n`); + + // Assertions + expect(basisUsd6).to.equal(storedBasis); // event basis matches what was stored at openLp + expect(currentValueUsd6).to.be.gt(0n); + expect(feeUsd6).to.equal(expectedFee); + expect(feeCharged).to.equal(feeUsd6); + expect(safeNetGain).to.equal(currentValueUsd6 - feeUsd6); + expect(safeUsdcAfter).to.be.gt(safeUsdcBefore); + expect(nftBurned).to.equal(true); + // Stored basis is wiped on full close. + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + }); + + it("opens an LP position via openLp (supply+borrow done externally)", async function () { + const { rhp } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + + const fmtUsdc = (v: bigint) => `${ethers.formatUnits(v, 6)} USDC`; + + console.log("\n ─── openLp setup ───"); + console.log(` RatehopperUniV3Positions: ${rhpAddress}`); + console.log(` Safe (env-driven): ${safeAddress}`); + + // 1. Enable RatehopperUniV3Positions as a Safe module so openLp can drive + // the swap + LP mint via Safe.execTransactionFromModule. + console.log("\n [openLp 1/3] Enable RatehopperUniV3Positions as Safe module"); + const enableRhpTx = await safeWallet.createEnableModuleTx(rhpAddress); + await safeWallet.executeTransaction(enableRhpTx); + + // 2. Supply + borrow are done OUTSIDE openLp. Per Approach B, the + // user runs Fluid supply+borrow as a separate Safe transaction + // first, leaving the Safe with USDC ready for the LP mint. + console.log("\n [openLp 2/3] Supply 0.01 ETH + borrow 10 USDC on Fluid (external to openLp)"); + await supplyAndBorrowOnFluid( + signer, + safeWallet, + FLUID_WETH_USDC_VAULT, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const usdcCx = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const usdcOnSafe = await usdcCx.balanceOf(safeAddress); + console.log(` Safe USDC after borrow: ${fmtUsdc(usdcOnSafe)}`); + + // Use exactly what's on the Safe, no more no less. + const usdcAmount = usdcOnSafe; + const halfUsdc = usdcAmount / 2n; + + // Pick a wide tick range around current price so the mint accepts + // both legs (we want a balanced LP). + const pool = new ethers.Contract(WETH_USDC_500_POOL, UNISWAP_V3_POOL_ABI, ethers.provider); + const slot0 = await pool.slot0(); + const currentTick = Number(slot0.tick); + const tickSpacing = 10; + const tickLower = Math.floor((currentTick - 5_000) / tickSpacing) * tickSpacing; + const tickUpper = Math.ceil((currentTick + 5_000) / tickSpacing) * tickSpacing; + + console.log("\n [openLp 3/3] Execute openLp via Safe (swap + LP mint)"); + console.log(` usdcAmount: ${fmtUsdc(usdcAmount)}`); + console.log(` halfUsdc: ${fmtUsdc(halfUsdc)}`); + console.log(` tickRange: [${tickLower}, ${tickUpper}]`); + + const safeTx = await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("openLp", [ + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ]), + operation: OperationType.Call, + }, + ], + }); + await safeWallet.executeTransaction(safeTx); + + // Find the freshly-minted LP NFT. + const filter = rhp.filters.PositionOpened(safeAddress); + const events = await rhp.queryFilter(filter, -10); + expect(events.length).to.be.gte(1); + const tokenId = events[events.length - 1].args.tokenId as bigint; + + const nftOwner = await new ethers.Contract( + UNISWAP_V3_NPM_ADDRESS, + ["function ownerOf(uint256) view returns (address)"], + ethers.provider, + ).ownerOf(tokenId); + + console.log("\n ─── openLp results ───"); + console.log(` tokenId: ${tokenId}`); + console.log(` NFT owner: ${nftOwner}`); + console.log(` NFT owner == Safe: ${nftOwner === safeAddress}\n`); + + expect(nftOwner).to.equal(safeAddress); + }); + + it("collectLp harvests owed fees, charges feeCollectBps, forwards the rest, and keeps the position open", async function () { + const { rhp, treasury } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + + const fmtUsdc = (v: bigint) => `${ethers.formatUnits(v, 6)} USDC`; + const fmtEth = (v: bigint) => `${ethers.formatEther(v)} ETH`; + + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const wethCx = new ethers.Contract(WETH_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + + console.log("\n ─── collectLp setup ───"); + console.log(` RatehopperUniV3Positions: ${rhpAddress}`); + console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Treasury: ${treasury.address}`); + console.log(` feeCollectBps: ${COLLECT_FEE_BPS} (${COLLECT_FEE_BPS / 100}%)`); + + // 1. Enable RHP as a module, fund the Safe with USDC, and open a + // balanced in-range LP via openLp. + console.log("\n [collectLp 1/5] Enable RatehopperUniV3Positions as Safe module"); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + + console.log("\n [collectLp 2/5] Supply 0.01 ETH + borrow 10 USDC on Fluid (external to RHP)"); + await supplyAndBorrowOnFluid( + signer, + safeWallet, + FLUID_WETH_USDC_VAULT, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + const usdcAmount = await usdc.balanceOf(safeAddress); + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + console.log(` Safe USDC after borrow: ${fmtUsdc(usdcAmount)}`); + console.log(` LP tick range: [${tickLower}, ${tickUpper}] (balanced, in-range)`); + + console.log("\n [collectLp 3/5] openLp → mint a balanced WETH/USDC LP on the Safe"); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("openLp", [ + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const opened = await rhp.queryFilter(rhp.filters.PositionOpened(safeAddress), -10); + const tokenId = opened[opened.length - 1].args.tokenId as bigint; + console.log(` minted LP tokenId: ${tokenId}`); + + // 2. Zero-fee branch: collecting a fresh position (no owed amounts) + // must move nothing and leave the position open. + console.log("\n [collectLp 4/5] collectLp on the FRESH position (zero-fee branch)"); + const treasuryUsdcPre = await usdc.balanceOf(treasury.address); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("collectLp", [safeAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + const zeroFeeEv = (await rhp.queryFilter(rhp.filters.FeesCollected(safeAddress, tokenId), -10)).slice(-1)[0] + .args; + expect(zeroFeeEv.collected0).to.equal(0n); + expect(zeroFeeEv.collected1).to.equal(0n); + expect(await usdc.balanceOf(treasury.address)).to.equal(treasuryUsdcPre); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + console.log( + ` collected0/1: ${zeroFeeEv.collected0}/${zeroFeeEv.collected1} (both 0 — nothing owed yet)`, + ); + console.log(` treasury USDC unchanged, NFT still on Safe`); + + // 3. Stage collectable amounts deterministically: have the Safe + // partially decreaseLiquidity, which credits principal to the + // position's tokensOwed0/1 WITHOUT transferring. That is exactly + // what `collect` (and therefore collectLp) harvests — identical to + // accrued trading fees from collect's point of view — and it is + // reliable on a deep mainnet-fork pool where a tiny position would + // otherwise accrue ~zero real fees. Half the liquidity stays in + // place so the position remains open. + console.log("\n [collectLp 5/5] Stage owed amounts via partial decreaseLiquidity, then collectLp"); + const liquidity: bigint = (await npm.positions(tokenId)).liquidity; + const liqToOwe = liquidity / 2n; + console.log(` position liquidity: ${liquidity} → decreasing half (${liqToOwe}) into tokensOwed`); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("decreaseLiquidity", [ + { + tokenId, + liquidity: liqToOwe, + amount0Min: 0n, + amount1Min: 0n, + deadline: Math.floor(Date.now() / 1000) + 3_600, + }, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const liqAfterDecrease: bigint = (await npm.positions(tokenId)).liquidity; + console.log(` liquidity left in position: ${liqAfterDecrease} (position stays OPEN)`); + + // 4. Snapshot both legs on treasury + Safe, then collectLp. + const tWeth0 = await wethCx.balanceOf(treasury.address); + const tUsdc0 = await usdc.balanceOf(treasury.address); + const sWeth0 = await wethCx.balanceOf(safeAddress); + const sUsdc0 = await usdc.balanceOf(safeAddress); + console.log(" pre-collect balances:"); + console.log(` treasury: ${fmtEth(tWeth0)} / ${fmtUsdc(tUsdc0)}`); + console.log(` safe: ${fmtEth(sWeth0)} / ${fmtUsdc(sUsdc0)}`); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("collectLp", [safeAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + + const ev = (await rhp.queryFilter(rhp.filters.FeesCollected(safeAddress, tokenId), -10)).slice(-1)[0].args; + const collected0: bigint = ev.collected0; + const fee0: bigint = ev.fee0; + const collected1: bigint = ev.collected1; + const fee1: bigint = ev.fee1; + + console.log("\n ─── collectLp results ───"); + console.log(` token0 WETH (${ev.token0})`); + console.log( + ` collected ${fmtEth(collected0)} | fee ${fmtEth(fee0)} → treasury | ${fmtEth(collected0 - fee0)} → Safe`, + ); + console.log(` token1 USDC (${ev.token1})`); + console.log( + ` collected ${fmtUsdc(collected1)} | fee ${fmtUsdc(fee1)} → treasury | ${fmtUsdc(collected1 - fee1)} → Safe`, + ); + console.log( + ` effective fee rate: token1 ${((Number(fee1) / Number(collected1)) * 10_000).toFixed(0)} bps (expected ${COLLECT_FEE_BPS})`, + ); + console.log( + ` router residual: ${fmtEth(await wethCx.balanceOf(rhpAddress))} / ${fmtUsdc(await usdc.balanceOf(rhpAddress))} (expected 0)`, + ); + console.log(` position still owned by Safe + liquidity unchanged (open)`); + + // The pool orders WETH (token0) before USDC (token1) by address. + expect(ev.token0.toLowerCase()).to.equal(WETH_ADDRESS.toLowerCase()); + expect(ev.token1.toLowerCase()).to.equal(USDC_ADDRESS.toLowerCase()); + + // Both legs were owed (balanced position decreased), so both collected. + expect(collected0).to.be.gt(0n); + expect(collected1).to.be.gt(0n); + + // Fee == feeCollectBps (2.5%) of the gross collected, per leg. + expect(fee0).to.equal((collected0 * BigInt(COLLECT_FEE_BPS)) / 10_000n); + expect(fee1).to.equal((collected1 * BigInt(COLLECT_FEE_BPS)) / 10_000n); + + // Treasury received exactly the fee; the Safe received the remainder. + expect((await wethCx.balanceOf(treasury.address)) - tWeth0).to.equal(fee0); + expect((await usdc.balanceOf(treasury.address)) - tUsdc0).to.equal(fee1); + expect((await wethCx.balanceOf(safeAddress)) - sWeth0).to.equal(collected0 - fee0); + expect((await usdc.balanceOf(safeAddress)) - sUsdc0).to.equal(collected1 - fee1); + + // The router keeps no residual of either leg. + expect(await wethCx.balanceOf(rhpAddress)).to.equal(0n); + expect(await usdc.balanceOf(rhpAddress)).to.equal(0n); + + // Position stays OPEN: collectLp neither changed liquidity nor burned. + expect((await npm.positions(tokenId)).liquidity).to.equal(liqAfterDecrease); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + }); + + it("lets the registry operator drive openLp + closeLp on the Safe's behalf; rejects a stranger", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const [, operatorEOA, stranger] = await ethers.getSigners(); + + const fmtUsdc = (v: bigint) => `${ethers.formatUnits(v, 6)} USDC`; + + console.log("\n ─── operator-path setup ───"); + console.log(` RatehopperUniV3Positions: ${rhpAddress}`); + console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Operator EOA: ${operatorEOA.address}`); + console.log(` Stranger EOA: ${stranger.address}`); + + // Register a dedicated backend operator EOA (distinct from the Safe and + // the owner) as the registry's safeOperator. setOperator is gated by + // the timelock, which the fixture set to `deployer`. + console.log("\n [operator 1/5] Register operator EOA as registry.safeOperator (timelock-gated)"); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + expect(await protocolRegistry.safeOperator()).to.equal(operatorEOA.address); + console.log( + ` registry.safeOperator() == operator: ${(await protocolRegistry.safeOperator()) === operatorEOA.address}`, + ); + + // The Safe enables RHP as a module and funds itself with USDC; from + // here the OPERATOR — not the Safe — drives the LP lifecycle via plain + // EOA transactions (no Safe signature on openLp/closeLp). + console.log("\n [operator 2/5] Safe enables RHP module + supplies/borrows 10 USDC"); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + await supplyAndBorrowOnFluid( + signer, + safeWallet, + FLUID_WETH_USDC_VAULT, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const usdcAmount = await usdc.balanceOf(safeAddress); + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + console.log(` Safe USDC ready: ${fmtUsdc(usdcAmount)}, LP range [${tickLower}, ${tickUpper}]`); + + // A stranger (neither operator nor Safe) is rejected by the gate. + console.log("\n [operator 3/5] Stranger EOA calls openLp → expect NotAuthorized"); + await expect( + rhp + .connect(stranger) + .openLp( + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "NotAuthorized"); + console.log(" stranger rejected with NotAuthorized ✓"); + + // 1. Operator opens the LP directly. + console.log("\n [operator 4/5] Operator EOA calls openLp directly (no Safe signature)"); + await ( + await rhp + .connect(operatorEOA) + .openLp( + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ) + ).wait(); + const opened = await rhp.queryFilter(rhp.filters.PositionOpened(safeAddress), -10); + const tokenId = opened[opened.length - 1].args.tokenId as bigint; + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + console.log(` operator-opened tokenId ${tokenId} (owner == Safe)`); + + // 2. Operator closes the same LP directly; USDC is forwarded to the Safe. + console.log("\n [operator 5/5] Operator EOA calls closeLp directly"); + const safeUsdcBefore = await usdc.balanceOf(safeAddress); + // Operator-driven full close. Perf-fee math is asserted elsewhere; + // this test only validates the operator authorization path. + await ( + await rhp + .connect(operatorEOA) + .closeLp(safeAddress, tokenId, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 10_000, 0, 0, FAR_DEADLINE, 0) + ).wait(); + const safeUsdcAfter = await usdc.balanceOf(safeAddress); + + let nftBurned = false; + try { + await npm.ownerOf(tokenId); + } catch { + nftBurned = true; + } + console.log( + ` operator-closed: Safe USDC +${fmtUsdc(safeUsdcAfter - safeUsdcBefore)}, NFT burned ${nftBurned}`, + ); + + expect(safeUsdcAfter).to.be.gt(safeUsdcBefore); + expect(nftBurned).to.equal(true); + }); + + it("rejects invalid inputs and unauthorized callers", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + const someUsdc = ethers.parseUnits("1", 6); + + console.log("\n ─── input-validation / authorization ───"); + console.log(` Operator EOA: ${operatorEOA.address} (registered as safeOperator)`); + + // usdcAmount == 0 → InvalidUsdcAmount (checked before any module call). + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + 0n, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "InvalidUsdcAmount"); + console.log(" openLp(usdcAmount = 0) → reverted InvalidUsdcAmount ✓"); + + // _onBehalfOf == address(0) → ZeroAddress (from onlyOperatorOrSafe). + await expect( + rhp + .connect(operatorEOA) + .openLp( + ZERO_ADDRESS, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "ZeroAddress"); + console.log(" openLp(_onBehalfOf = 0x0) → reverted ZeroAddress ✓"); + + // RHP not enabled as a module on the Safe → the Safe rejects the + // module call (Gnosis "GS104"), so the whole tx reverts. + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.reverted; + console.log(" openLp(module not enabled) → reverted (Safe GS104) ✓"); + + // slippageBps > maxSlippageBps → SlippageAboveMax (pre-flight, before + // any module call). Default maxSlippageBps = 300; passing 301 reverts. + const maxSlip = Number(await rhp.maxSlippageBps()); + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 0, + EXPECTED_SWAP_OUT, + maxSlip + 1, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "SlippageAboveMax"); + console.log(` openLp(slippageBps = ${maxSlip + 1}) → reverted SlippageAboveMax ✓`); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, 1n, 500, 0, EXPECTED_SWAP_OUT, maxSlip + 1, 10_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "SlippageAboveMax"); + console.log(` closeLp(slippageBps = ${maxSlip + 1}) → reverted SlippageAboveMax ✓`); + + // Disallowed fee tier (e.g. 10000) reverts pre-flight on lpPoolFeeTier, + // swapPoolFeeTier, and closeLp.swapPoolFeeTier. Default allow-list: + // {100, 500, 3000}. + const badTier = 10000; + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + badTier, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "FeeTierNotAllowed"); + console.log(` openLp(lpPoolFeeTier = ${badTier}) → reverted FeeTierNotAllowed ✓`); + + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + badTier, + 0, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "FeeTierNotAllowed"); + console.log(` openLp(swapPoolFeeTier = ${badTier}) → reverted FeeTierNotAllowed ✓`); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, 1n, badTier, 0, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 10_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "FeeTierNotAllowed"); + console.log(` closeLp(swapPoolFeeTier = ${badTier}) → reverted FeeTierNotAllowed ✓`); + + // C-01 floor: swapAmountOutMin must be non-zero so a (compromised) + // operator cannot disable per-call slippage protection by passing 0. + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 0n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "InvalidSwapAmountOutMin"); + console.log(` openLp(swapAmountOutMin = 0) → reverted InvalidSwapAmountOutMin ✓`); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, 1n, 500, 0n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 10_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "InvalidSwapAmountOutMin"); + console.log(` closeLp(swapAmountOutMin = 0) → reverted InvalidSwapAmountOutMin ✓`); + + // H-01: with `minPoolLiquidity` set above any real pool's liquidity, + // _validatePool reverts PoolTooThin pre-flight (during the spot-price + // read in _quoteSwapAmountOutMin). + const maxUint128 = 2n ** 128n - 1n; + await (await rhp.connect(deployer).setMinPoolLiquidity(maxUint128)).wait(); + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + someUsdc, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "PoolTooThin"); + console.log(` openLp(minPoolLiquidity = MAX) → reverted PoolTooThin ✓`); + }); + + it("closeLp reverts MinUsdcOutNotMet when realized < minUsdcOut (C-02 final guard)", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Operator EOA path gives clean typed revert (vs the Safe SDK which + // wraps inner reverts in opaque RPC errors). Stored basis is ~5 USDC; + // ask for 1M USDC → revert MinUsdcOutNotMet. + const huge = ethers.parseUnits("1000000", 6); + await expect( + rhp + .connect(operatorEOA) + .closeLp( + safeAddress, + tokenId, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + 10_000, + 0, + 0, + FAR_DEADLINE, + huge, + ), + ).to.be.revertedWithCustomError(rhp, "MinUsdcOutNotMet"); + console.log(`\n [c-02/minUsdcOut] closeLp with minUsdcOut = 1M USDC → reverted MinUsdcOutNotMet ✓`); + }); + + it("closeLp uses the stored basis from openLp and applies the canonical fee formula", async function () { + const { rhp, treasury } = await deployFixture(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // After C-03 the basis is auto-stored at openLp; caller can no longer + // attest. Read what's stored so we can compute the expected fee. + const storedBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const { basisUsd6, currentValueUsd6, feeUsd6, treasuryDelta, safeDelta } = await closeAndMeasure( + rhp, + safeWallet, + treasury.address, + tokenId, + ); + const expectedFee = + currentValueUsd6 > storedBasis + ? ((currentValueUsd6 - storedBasis) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n + : 0n; + console.log( + `\n [perf-fee/formula] basis ${ethers.formatUnits(storedBasis, 6)} realized ${ethers.formatUnits(currentValueUsd6, 6)} → fee ${ethers.formatUnits(feeUsd6, 6)}`, + ); + + expect(basisUsd6).to.equal(storedBasis); + expect(feeUsd6).to.equal(expectedFee); + expect(treasuryDelta).to.equal(feeUsd6); + expect(safeDelta).to.equal(currentValueUsd6 - feeUsd6); + // Stored basis is deleted on full close. + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + }); + + it("closeLp on an open-and-immediately-close position naturally settles at break-even / loss (fee = 0)", async function () { + const { rhp, treasury } = await deployFixture(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // With no external pool volume between open and close, realized USDC + // < stored basis because of swap fees + spread on the round trip, + // so the perf fee is 0. + const storedBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const { currentValueUsd6, feeUsd6, treasuryDelta, safeDelta } = await closeAndMeasure( + rhp, + safeWallet, + treasury.address, + tokenId, + ); + console.log( + `\n [perf-fee/break-even] basis ${ethers.formatUnits(storedBasis, 6)} realized ${ethers.formatUnits(currentValueUsd6, 6)} → fee ${ethers.formatUnits(feeUsd6, 6)}`, + ); + + expect(currentValueUsd6).to.be.gt(0n); + expect(currentValueUsd6).to.be.lte(storedBasis); // round-trip cost makes this true + expect(feeUsd6).to.equal(0n); + expect(treasuryDelta).to.equal(0n); + expect(safeDelta).to.equal(currentValueUsd6); + }); + + it("closeLp applies the owner-updated performanceFeeBps rate", async function () { + const { rhp, deployer, treasury } = await deployFixture(); + + // Owner lowers the performance fee 10% → 5%; closeLp must use the new + // rate when computing fees against the stored basis. + await (await rhp.connect(deployer).setPerformanceFeeBps(500)).wait(); + expect(await rhp.performanceFeeBps()).to.equal(500); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const storedBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const { basisUsd6, currentValueUsd6, feeUsd6, treasuryDelta } = await closeAndMeasure( + rhp, + safeWallet, + treasury.address, + tokenId, + ); + const expectedFee = currentValueUsd6 > storedBasis ? ((currentValueUsd6 - storedBasis) * 500n) / 10_000n : 0n; + console.log( + `\n [perf-fee/updated-rate] basis ${ethers.formatUnits(storedBasis, 6)} realized ${ethers.formatUnits(currentValueUsd6, 6)} → fee ${ethers.formatUnits(feeUsd6, 6)} (5%)`, + ); + + expect(basisUsd6).to.equal(storedBasis); + expect(feeUsd6).to.equal(expectedFee); + expect(treasuryDelta).to.equal(feeUsd6); + }); + + // SKIPPED: triggering UnknownPosition requires a real WETH/USDC NPM + // position owned by the Safe but never registered via openLp (i.e. a + // direct NPM mint), because the H-03 + M-02 helper + // `_requireWethUsdcPositionOwnedBy` fires before this check. Synthetic + // tokenIds (e.g. 999_999) trip NPM's "Invalid token ID" string revert + // first. Rewrite needed: have the Safe directly NPM-mint a WETH/USDC LP + // (giving it WETH + USDC + approvals first), then call closeLp on the + // resulting tokenId. + it("closeLp on an unknown tokenId reverts with UnknownPosition", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + // Use an arbitrary tokenId that this contract never opened — the + // residual basis slot is zero, so closeLp must revert UnknownPosition. + await expect( + rhp + .connect(operatorEOA) + .closeLp( + safeAddress, + 999_999n, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + 10_000, + 0, + 0, + FAR_DEADLINE, + 0, + ), + ).to.be.revertedWithCustomError(rhp, "UnknownPosition"); + }); + + it("collectLp on an unknown tokenId reverts with UnknownPosition (L-1)", async function () { + // L-1 audit fix: collectLp must reject tokenIds that were not opened + // via openLp (residualBasisUsd6Of slot is zero) so neither the Safe + // nor the registry.safeOperator can route arbitrary Safe-owned + // WETH/USDC NPM NFTs through the protocol's feeCollectBps path. + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + await expect(rhp.connect(operatorEOA).collectLp(safeAddress, 999_999n)).to.be.revertedWithCustomError( + rhp, + "UnknownPosition", + ); + }); + + it("closeLp rejects exitBps == 0 and exitBps > 10_000 with InvalidExitBps", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, 1n, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 0, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "InvalidExitBps"); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, 1n, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 10_001, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "InvalidExitBps"); + }); + + it("openLp reverts PositionLiquidityTooLow when minted liquidity is below the floor (L-2)", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + const rhpAddress = await rhp.getAddress(); + + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + await supplyAndBorrowOnFluid( + signer, + safeWallet, + FLUID_WETH_USDC_VAULT, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const usdcAmount = await usdc.balanceOf(safeAddress); + const { tickLower, tickUpper } = await wideTicksAroundSpot(); + + // type(uint128).max floor — no real ~5-USDC mint can reach it, so the + // L-2 minted-liquidity guard must reject the open. + const MAX_UINT128 = (1n << 128n) - 1n; + await (await rhp.connect(deployer).setMinPositionLiquidity(MAX_UINT128)).wait(); + + await expect( + rhp + .connect(operatorEOA) + .openLp( + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ), + ).to.be.revertedWithCustomError(rhp, "PositionLiquidityTooLow"); + }); + + it("closeLp reverts InvalidExitBps when a partial exit would decrement basis without removing liquidity (L-4)", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Shrink NPM liquidity to dust (< 10_000) WITHOUT collecting, so a 1-bps + // partial close truncates liquidityToRemove to 0 while the stored basis + // (~5 USDC) still prorates to a non-zero basisForExit — the exact L-4 + // desync the guard must reject. + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const liquidity: bigint = (await npm.positions(tokenId)).liquidity; + const dust = 5_000n; + expect(liquidity).to.be.gt(dust); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("decreaseLiquidity", [ + { + tokenId, + liquidity: liquidity - dust, + amount0Min: 0, + amount1Min: 0, + deadline: FAR_DEADLINE, + }, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + expect((await npm.positions(tokenId)).liquidity).to.equal(dust); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, tokenId, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 1, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "InvalidExitBps"); + }); + + it("closeLp(exitBps = 5000) keeps the NFT alive, halves liquidity, and prorates the stored basis", async function () { + const { rhp, treasury } = await deployFixture(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const liquidityBefore: bigint = (await npm.positions(tokenId)).liquidity; + const storedBasisBefore: bigint = await rhp.residualBasisUsd6Of(tokenId); + const expectedBasisForExit = (storedBasisBefore * 5_000n) / 10_000n; + const expectedResidualAfter = storedBasisBefore - expectedBasisForExit; + + const { basisUsd6, currentValueUsd6, feeUsd6, treasuryDelta, safeDelta, exitBpsEmitted } = + await closeAndMeasure(rhp, safeWallet, treasury.address, tokenId, 5_000); + + const expectedFee = + currentValueUsd6 > expectedBasisForExit + ? ((currentValueUsd6 - expectedBasisForExit) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n + : 0n; + + const liquidityAfter: bigint = (await npm.positions(tokenId)).liquidity; + const expectedRemoved = (liquidityBefore * 5_000n) / 10_000n; + const expectedRemaining = liquidityBefore - expectedRemoved; + const storedBasisAfter: bigint = await rhp.residualBasisUsd6Of(tokenId); + + console.log( + `\n [partial/50%] liquidity ${liquidityBefore} → ${liquidityAfter} (removed ~${expectedRemoved}); stored basis ${ethers.formatUnits(storedBasisBefore, 6)} → ${ethers.formatUnits(storedBasisAfter, 6)}; realized ${ethers.formatUnits(currentValueUsd6, 6)} basis-for-this-slice ${ethers.formatUnits(expectedBasisForExit, 6)} → fee ${ethers.formatUnits(feeUsd6, 6)}`, + ); + + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(liquidityAfter).to.equal(expectedRemaining); + expect(exitBpsEmitted).to.equal(5_000); + expect(basisUsd6).to.equal(expectedBasisForExit); + expect(storedBasisAfter).to.equal(expectedResidualAfter); // residual decremented on-chain + expect(feeUsd6).to.equal(expectedFee); + expect(treasuryDelta).to.equal(feeUsd6); + expect(safeDelta).to.equal(currentValueUsd6 - feeUsd6); + }); + + it("closeLp partial(5000) then full(10000) drains liquidity, burns the NFT, and the contract self-bookkeeps residual basis", async function () { + const { rhp, treasury } = await deployFixture(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + + const openBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const expectedBasisFirst = (openBasis * 5_000n) / 10_000n; + const expectedResidualAfterFirst = openBasis - expectedBasisFirst; + + const first = await closeAndMeasure(rhp, safeWallet, treasury.address, tokenId, 5_000); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(expectedResidualAfterFirst); + + // Second close (full): basis should equal the residual after the first + // close — the contract bookkeeps this internally; caller passes nothing. + const second = await closeAndMeasure(rhp, safeWallet, treasury.address, tokenId, 10_000); + + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); // deleted on full close + + const expectedFeeFirst = + first.currentValueUsd6 > expectedBasisFirst + ? ((first.currentValueUsd6 - expectedBasisFirst) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n + : 0n; + const expectedFeeSecond = + second.currentValueUsd6 > expectedResidualAfterFirst + ? ((second.currentValueUsd6 - expectedResidualAfterFirst) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n + : 0n; + + console.log( + `\n [partial→full] open basis ${ethers.formatUnits(openBasis, 6)} | slice 1: basis ${ethers.formatUnits(expectedBasisFirst, 6)} realized ${ethers.formatUnits(first.currentValueUsd6, 6)} fee ${ethers.formatUnits(first.feeUsd6, 6)} | slice 2: basis ${ethers.formatUnits(expectedResidualAfterFirst, 6)} realized ${ethers.formatUnits(second.currentValueUsd6, 6)} fee ${ethers.formatUnits(second.feeUsd6, 6)} (slice 1 basis + slice 2 basis = open basis)`, + ); + + expect(first.exitBpsEmitted).to.equal(5_000); + expect(second.exitBpsEmitted).to.equal(10_000); + expect(first.basisUsd6).to.equal(expectedBasisFirst); + expect(second.basisUsd6).to.equal(expectedResidualAfterFirst); + expect(first.feeUsd6).to.equal(expectedFeeFirst); + expect(second.feeUsd6).to.equal(expectedFeeSecond); + expect(first.basisUsd6 + second.basisUsd6).to.equal(openBasis); // conservation + }); + + it("closeLp charges perf fee on net profit (feeUsd6 > 0 branch, basis simulated via storage override)", async function () { + const { rhp, treasury } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Discover the storage slot for `residualBasisUsd6Of[tokenId]` at + // runtime — robust to inheritance reordering. The slot is + // `keccak256(abi.encode(tokenId, mappingBaseSlot))`; scan + // mappingBaseSlot 0..15 and pick the one whose stored value matches + // the public getter. + const openBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const coder = ethers.AbiCoder.defaultAbiCoder(); + let mappingSlot = -1; + for (let s = 0; s < 16; s++) { + const candidate = ethers.keccak256(coder.encode(["uint256", "uint256"], [tokenId, s])); + const raw = await ethers.provider.getStorage(rhpAddress, candidate); + if (BigInt(raw) === openBasis) { + mappingSlot = s; + break; + } + } + expect(mappingSlot, "residualBasisUsd6Of storage slot not found").to.not.equal(-1); + + // Overwrite the basis to 1 wei. Realized USDC at close (~5 USDC) will + // then far exceed basis → fee = (realized - 1) * 1000 / 10_000. + const targetSlot = ethers.keccak256(coder.encode(["uint256", "uint256"], [tokenId, mappingSlot])); + await network.provider.send("hardhat_setStorageAt", [rhpAddress, targetSlot, ethers.zeroPadValue("0x01", 32)]); + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(1n); + + // Close + assert fee math against the lowered basis. + const { basisUsd6, currentValueUsd6, feeUsd6, treasuryDelta, safeDelta } = await closeAndMeasure( + rhp, + safeWallet, + treasury.address, + tokenId, + ); + const expectedFee = ((currentValueUsd6 - 1n) * BigInt(PERFORMANCE_FEE_BPS)) / 10_000n; + + console.log( + `\n [perf-fee/profit] basis(forced) 0.000001 realized ${ethers.formatUnits(currentValueUsd6, 6)} → fee ${ethers.formatUnits(feeUsd6, 6)} (${PERFORMANCE_FEE_BPS / 100}%)`, + ); + + expect(basisUsd6).to.equal(1n); // basis-for-this-slice == stored + expect(currentValueUsd6).to.be.gt(1n); // we engineered a profit + expect(feeUsd6).to.equal(expectedFee); + expect(feeUsd6).to.be.gt(0n); // the load-bearing branch — fee transferred to treasury + expect(treasuryDelta).to.equal(feeUsd6); + expect(safeDelta).to.equal(currentValueUsd6 - feeUsd6); + }); + + it("rescueERC721 recovers a misdirected NFT, emits, and rejects zero-address / non-admin", async function () { + const { rhp, deployer } = await deployFixture(); + const [, other, recipient] = await ethers.getSigners(); + const rhpAddress = await rhp.getAddress(); + + // Stage: open an LP via openLp (NFT lands on Safe), then route it to + // RHP using a plain `transferFrom` from the Safe — simulates the + // misdirected-NFT scenario rescueERC721 is built for. We use + // `transferFrom` (not `safeTransferFrom`) because RHP doesn't + // implement `onERC721Received` — that's intentional, RHP isn't a + // normal NFT recipient. `rescueERC721` handles whatever lands here. + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("transferFrom", [safeAddress, rhpAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + expect(await npm.ownerOf(tokenId)).to.equal(rhpAddress); + + // Happy path: rescue the NFT to a fresh recipient EOA. + await expect(rhp.connect(deployer).rescueERC721(UNISWAP_V3_NPM_ADDRESS, tokenId, recipient.address)) + .to.emit(rhp, "NftRescued") + .withArgs(UNISWAP_V3_NPM_ADDRESS, recipient.address, tokenId); + expect(await npm.ownerOf(tokenId)).to.equal(recipient.address); + + // Reverts on zero token address (pre-flight, before external call). + await expect( + rhp.connect(deployer).rescueERC721(ZERO_ADDRESS, tokenId, recipient.address), + ).to.be.revertedWithCustomError(rhp, "ZeroAddress"); + + // Reverts on zero recipient address. + await expect( + rhp.connect(deployer).rescueERC721(UNISWAP_V3_NPM_ADDRESS, tokenId, ZERO_ADDRESS), + ).to.be.revertedWithCustomError(rhp, "ZeroAddress"); + + // Reverts when called by non-admin. + await expect( + rhp.connect(other).rescueERC721(UNISWAP_V3_NPM_ADDRESS, tokenId, recipient.address), + ).to.be.revertedWithCustomError(rhp, "AccessControlUnauthorizedAccount"); + }); + + it("closeLp and collectLp revert LpNotOnSafe after the Safe transfers the NFT away", async function () { + const { rhp, protocolRegistry, deployer } = await deployFixture(); + const [, operatorEOA, stranger] = await ethers.getSigners(); + await (await protocolRegistry.connect(deployer).setOperator(operatorEOA.address)).wait(); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Safe moves the NFT off itself. The stored basis stays set, so the + // UnknownPosition gate passes and the ownership check is load-bearing. + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("transferFrom", [ + safeAddress, + stranger.address, + tokenId, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + expect(await npm.ownerOf(tokenId)).to.equal(stranger.address); + expect(await rhp.residualBasisUsd6Of(tokenId)).to.be.gt(0n); + + await expect( + rhp + .connect(operatorEOA) + .closeLp(safeAddress, tokenId, 500, 1n, EXPECTED_SWAP_OUT, SLIPPAGE_BPS, 10_000, 0, 0, FAR_DEADLINE, 0), + ).to.be.revertedWithCustomError(rhp, "LpNotOnSafe"); + + await expect(rhp.connect(operatorEOA).collectLp(safeAddress, tokenId)).to.be.revertedWithCustomError( + rhp, + "LpNotOnSafe", + ); + }); + + it("collectLp tolerates a blacklisted treasury: USDC fee leg waived, full amount forwarded to the Safe (L-3)", async function () { + const { rhp, treasury } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const wethCx = new ethers.Contract(WETH_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Stage owed amounts on BOTH legs via a partial decrease. + const liquidity: bigint = (await npm.positions(tokenId)).liquidity; + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("decreaseLiquidity", [ + { + tokenId, + liquidity: liquidity / 2n, + amount0Min: 0n, + amount1Min: 0n, + deadline: FAR_DEADLINE, + }, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + + await blacklistUsdc(treasury.address); + + const tUsdc0 = await usdc.balanceOf(treasury.address); + const tWeth0 = await wethCx.balanceOf(treasury.address); + const sUsdc0 = await usdc.balanceOf(safeAddress); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("collectLp", [safeAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + + // The USDC fee hop reverted → caught, surfaced, and waived. + const failEv = ( + await rhp.queryFilter(rhp.filters.CollectFeeTransferFailed(safeAddress, tokenId, USDC_ADDRESS), -10) + ).slice(-1)[0].args; + expect(failEv.token.toLowerCase()).to.equal(USDC_ADDRESS.toLowerCase()); + + const ev = (await rhp.queryFilter(rhp.filters.FeesCollected(safeAddress, tokenId), -10)).slice(-1)[0].args; + // USDC leg: fee zeroed in the event, full amount forwarded to the Safe. + expect(ev.collected1).to.be.gt(0n); + expect(ev.fee1).to.equal(0n); + expect((await usdc.balanceOf(treasury.address)) - tUsdc0).to.equal(0n); + expect((await usdc.balanceOf(safeAddress)) - sUsdc0).to.equal(ev.collected1); + // WETH leg is unaffected: its fee still reaches the treasury normally. + expect(ev.fee0).to.equal((ev.collected0 * BigInt(COLLECT_FEE_BPS)) / 10_000n); + expect((await wethCx.balanceOf(treasury.address)) - tWeth0).to.equal(ev.fee0); + }); + + it("closeLp tolerates a blacklisted treasury on the perf fee: position still exits, fee waived (FeeTransferFailed)", async function () { + const { rhp, treasury } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + + // Force a profit by lowering the stored basis to 1 (same slot-discovery + // technique as the perf-fee/profit test) so the fee branch is entered. + const openBasis: bigint = await rhp.residualBasisUsd6Of(tokenId); + const coder = ethers.AbiCoder.defaultAbiCoder(); + let mappingSlot = -1; + for (let s = 0; s < 16; s++) { + const candidate = ethers.keccak256(coder.encode(["uint256", "uint256"], [tokenId, s])); + if (BigInt(await ethers.provider.getStorage(rhpAddress, candidate)) === openBasis) { + mappingSlot = s; + break; + } + } + expect(mappingSlot, "residualBasisUsd6Of storage slot not found").to.not.equal(-1); + const targetSlot = ethers.keccak256(coder.encode(["uint256", "uint256"], [tokenId, mappingSlot])); + await network.provider.send("hardhat_setStorageAt", [rhpAddress, targetSlot, ethers.zeroPadValue("0x01", 32)]); + + await blacklistUsdc(treasury.address); + + const { feeUsd6, currentValueUsd6, treasuryDelta, safeDelta } = await closeAndMeasure( + rhp, + safeWallet, + treasury.address, + tokenId, + ); + + const failEv = await rhp.queryFilter(rhp.filters.FeeTransferFailed(safeAddress, tokenId), -10); + expect(failEv.length, "FeeTransferFailed not emitted").to.be.gt(0); + expect(feeUsd6).to.equal(0n); // PositionClosed reports the waived fee as 0 + expect(treasuryDelta).to.equal(0n); // treasury (blacklisted) received nothing + expect(safeDelta).to.equal(currentValueUsd6); // Safe keeps the entire realized USDC + }); + + it("collectLp with feeCollectBps == 0 skims nothing and forwards the full collected amounts", async function () { + const { rhp, treasury, deployer } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const wethCx = new ethers.Contract(WETH_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + + await (await rhp.connect(deployer).setFeeCollectBps(0)).wait(); + + const tokenId = await openBalancedPosition( + rhp, + safeWallet, + signer, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + const liquidity: bigint = (await npm.positions(tokenId)).liquidity; + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("decreaseLiquidity", [ + { + tokenId, + liquidity: liquidity / 2n, + amount0Min: 0n, + amount1Min: 0n, + deadline: FAR_DEADLINE, + }, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + + const tUsdc0 = await usdc.balanceOf(treasury.address); + const tWeth0 = await wethCx.balanceOf(treasury.address); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("collectLp", [safeAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + + const ev = (await rhp.queryFilter(rhp.filters.FeesCollected(safeAddress, tokenId), -10)).slice(-1)[0].args; + expect(ev.collected1).to.be.gt(0n); + expect(ev.fee0).to.equal(0n); + expect(ev.fee1).to.equal(0n); + expect((await usdc.balanceOf(treasury.address)) - tUsdc0).to.equal(0n); + expect((await wethCx.balanceOf(treasury.address)) - tWeth0).to.equal(0n); + }); + + it("handles a one-sided USDC position: collectLp (collected0 == 0) and closeLp (wethToSwap == 0)", async function () { + const { rhp } = await deployFixture(); + const rhpAddress = await rhp.getAddress(); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_VIEW_ABI, ethers.provider); + + await safeWallet.executeTransaction(await safeWallet.createEnableModuleTx(rhpAddress)); + await supplyAndBorrowOnFluid( + signer, + safeWallet, + FLUID_WETH_USDC_VAULT, + ethers.parseEther("0.01"), + ethers.parseUnits("10", 6), + ); + const usdcAmount = await usdc.balanceOf(safeAddress); + // Range entirely below spot → the minted LP holds only token1 (USDC). + const { tickLower, tickUpper } = await ticksBelowSpot(); + + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("openLp", [ + safeAddress, + usdcAmount, + tickLower, + tickUpper, + 500, + 0, + 0, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + FAR_DEADLINE, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const tokenId = (await rhp.queryFilter(rhp.filters.PositionOpened(safeAddress), -10)).slice(-1)[0].args + .tokenId as bigint; + + const pos = await npm.positions(tokenId); + expect(pos.token0.toLowerCase()).to.equal(WETH_ADDRESS.toLowerCase()); + + // collectLp on a USDC-only position exercises the `collected0 == 0` + // (no-WETH-valuation) branch inside _collectLp. + const liquidity: bigint = pos.liquidity; + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: UNISWAP_V3_NPM_ADDRESS, + value: "0", + data: npm.interface.encodeFunctionData("decreaseLiquidity", [ + { + tokenId, + liquidity: liquidity / 2n, + amount0Min: 0n, + amount1Min: 0n, + deadline: FAR_DEADLINE, + }, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("collectLp", [safeAddress, tokenId]), + operation: OperationType.Call, + }, + ], + }), + ); + const cev = (await rhp.queryFilter(rhp.filters.FeesCollected(safeAddress, tokenId), -10)).slice(-1)[0].args; + expect(cev.collected0).to.equal(0n); + expect(cev.collected1).to.be.gt(0n); + + // closeLp full exit → no WETH collected → the `wethToSwap == 0` branch + // (swap skipped). The position fully closes and clears its basis. + await safeWallet.executeTransaction( + await safeWallet.createTransaction({ + transactions: [ + { + to: rhpAddress, + value: "0", + data: rhp.interface.encodeFunctionData("closeLp", [ + safeAddress, + tokenId, + 500, + 1n, + EXPECTED_SWAP_OUT, + SLIPPAGE_BPS, + 10_000, + 0, + 0, + FAR_DEADLINE, + 0, + ]), + operation: OperationType.Call, + }, + ], + }), + ); + const closeEv = (await rhp.queryFilter(rhp.filters.PositionClosed(safeAddress, tokenId), -10)).slice(-1)[0] + .args; + expect(Number(closeEv.exitBps)).to.equal(10_000); + expect(await rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + }); +}); diff --git a/test/ratehopperUniV3PositionsMocks.ts b/test/ratehopperUniV3PositionsMocks.ts new file mode 100644 index 0000000..f1a9593 --- /dev/null +++ b/test/ratehopperUniV3PositionsMocks.ts @@ -0,0 +1,682 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +// ───────────────────────────────────────────────────────────────────────── +// Mock-driven unit/branch-coverage suite for RatehopperUniV3Positions. +// +// Unlike the fork integration suite (which needs a real Base RPC + an +// env-driven Gnosis Safe and therefore skips in plain CI), this suite wires +// RHP to fully-controlled mocks — a Safe-module executor, swap router, NPM, +// factory/pool, and ERC20s — so the entire openLp / closeLp / collectLp +// lifecycle AND every defensive revert branch execute deterministically with +// no external dependencies. +// ───────────────────────────────────────────────────────────────────────── + +const ZERO = "0x0000000000000000000000000000000000000000"; +const DEADLINE = ethers.MaxUint256; +const SLIP = 100; // 1% +const PERF_FEE_BPS = 1000n; // 10% +const COLLECT_FEE_BPS = 250n; // 2.5% +const MAX_FEE_BPS = 2000; +const Q96 = 1n << 96n; + +const USDC_AMOUNT = 1_000_000n; // openLp input +const HALF = USDC_AMOUNT / 2n; +const WETH_OUT = 2_000_000n; // WETH produced by the openLp swap + +// Module-call failure data the MockSafe bubbles for the revert-bubble branch. +const BUBBLE_REASON = "module boom"; +const BUBBLE_DATA = ethers.concat([ + "0x08c379a0", + ethers.AbiCoder.defaultAbiCoder().encode(["string"], [BUBBLE_REASON]), +]); + +async function deployMockHarness() { + const [deployer, operatorEOA, treasury, stranger] = await ethers.getSigners(); + + const ERC = await ethers.getContractFactory("MockERC20"); + const tokenA = await ERC.deploy("Token A", "TKA", 18); + const tokenB = await ERC.deploy("Token B", "TKB", 6); + await tokenA.waitForDeployment(); + await tokenB.waitForDeployment(); + const addrA = (await tokenA.getAddress()).toLowerCase(); + const addrB = (await tokenB.getAddress()).toLowerCase(); + // RHP requires WETH < USDC by address. + const [weth, usdc] = addrA < addrB ? [tokenA, tokenB] : [tokenB, tokenA]; + const wethAddr = await weth.getAddress(); + const usdcAddr = await usdc.getAddress(); + + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const validPool = await Pool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await validPool.waitForDeployment(); + + const Factory = await ethers.getContractFactory("MockUniswapV3Factory"); + const factory = await Factory.deploy(); + await factory.waitForDeployment(); + await (await factory.setPool(await validPool.getAddress())).wait(); + + const NPM = await ethers.getContractFactory("MockNonfungiblePositionManager"); + const npm = await NPM.deploy(); + await npm.waitForDeployment(); + + const Router = await ethers.getContractFactory("MockSwapRouter"); + const router = await Router.deploy(); + await router.waitForDeployment(); + + const Safe = await ethers.getContractFactory("MockSafeHarness"); + const safe = await Safe.deploy(); + await safe.waitForDeployment(); + const safeAddr = await safe.getAddress(); + + const Reg = await ethers.getContractFactory("MockRegistry"); + const reg = await Reg.deploy(); + await reg.waitForDeployment(); + await (await reg.setOperator(operatorEOA.address)).wait(); + + const RHP = await ethers.getContractFactory("RatehopperUniV3Positions"); + const rhp = await RHP.deploy( + await npm.getAddress(), + await reg.getAddress(), + usdcAddr, + wethAddr, + await router.getAddress(), + await factory.getAddress(), + treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + deployer.address, // initialAdmin + deployer.address, // timelock + 0, + 0, + ); + await rhp.waitForDeployment(); + + // Funding: Safe holds USDC to open with; router + NPM hold both legs so + // swaps and collects can pay out. + await (await usdc.mint(safeAddr, 10n ** 12n)).wait(); + await (await weth.mint(await router.getAddress(), 10n ** 24n)).wait(); + await (await usdc.mint(await router.getAddress(), 10n ** 18n)).wait(); + await (await weth.mint(await npm.getAddress(), 10n ** 24n)).wait(); + await (await usdc.mint(await npm.getAddress(), 10n ** 18n)).wait(); + + return { + deployer, + operatorEOA, + treasury, + stranger, + weth, + usdc, + wethAddr, + usdcAddr, + validPool, + factory, + npm, + router, + safe, + safeAddr, + reg, + rhp, + }; +} + +type Ctx = Awaited>; + +// openLp via the operator on behalf of the mock Safe. Returns the tokenId. +async function openLp(ctx: Ctx, opts: { usdcAmount?: bigint; wethOut?: bigint } = {}): Promise { + const usdcAmount = opts.usdcAmount ?? USDC_AMOUNT; + await (await ctx.router.setOutput(opts.wethOut ?? WETH_OUT)).wait(); + await ( + await ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, usdcAmount, 0, 0, 500, 0, 0, 500, 1n, 1n, SLIP, DEADLINE) + ).wait(); + const ev = await ctx.rhp.queryFilter(ctx.rhp.filters.PositionOpened(ctx.safeAddr), -5); + return ev[ev.length - 1].args.tokenId as bigint; +} + +function openLpCall(ctx: Ctx) { + return ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0, 0, 500, 1n, 1n, SLIP, DEADLINE); +} + +function closeLpCall(ctx: Ctx, tokenId: bigint, exitBps = 10_000) { + return ctx.rhp + .connect(ctx.operatorEOA) + .closeLp(ctx.safeAddr, tokenId, 500, 1n, 1n, SLIP, exitBps, 0, 0, DEADLINE, 0); +} + +describe("RatehopperUniV3Positions - mock harness (no fork)", function () { + // ── _validatePool branches (revert before any Safe interaction) ────── + + it("openLp reverts PoolDoesNotExist when the factory returns address(0)", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.factory.setPool(ZERO)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "PoolDoesNotExist"); + }); + + it("openLp reverts WrongTokenPair when the pool's tokens are not WETH/USDC", async function () { + const ctx = await loadFixture(deployMockHarness); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + // token0 = USDC (wrong; should be WETH). + const badPool = await Pool.deploy(ctx.usdcAddr, ctx.usdcAddr, Q96, 10n ** 18n); + await (await ctx.factory.setPool(await badPool.getAddress())).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "WrongTokenPair"); + }); + + it("openLp reverts PoolNotInitialized when slot0 sqrtPriceX96 == 0", async function () { + const ctx = await loadFixture(deployMockHarness); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const uninit = await Pool.deploy(ctx.wethAddr, ctx.usdcAddr, 0n, 10n ** 18n); + await (await ctx.factory.setPool(await uninit.getAddress())).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "PoolNotInitialized"); + }); + + it("openLp reverts PoolTooThin when pool liquidity is below minPoolLiquidity", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.rhp.connect(ctx.deployer).setMinPoolLiquidity(2n ** 127n)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "PoolTooThin"); + }); + + // ── openLp swap / mint defensive branches ─────────────────────────── + + it("openLp reverts SwapFailed when the swap yields zero WETH", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.router.setOutput(0n)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "SwapFailed"); + }); + + it("openLp reverts LpNotOnSafe when the minted NFT is not owned by the Safe", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.router.setOutput(WETH_OUT)).wait(); + await (await ctx.npm.setMintOwnerOverride(ctx.stranger.address)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "LpNotOnSafe"); + }); + + it("openLp reverts PositionLiquidityTooLow when minted liquidity is below the floor", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.router.setOutput(WETH_OUT)).wait(); + await (await ctx.rhp.connect(ctx.deployer).setMinPositionLiquidity(2n ** 120n)).wait(); + await (await ctx.npm.setMintLiquidity(1n)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "PositionLiquidityTooLow"); + }); + + // ── Module-call failure branches (_safeApprove / _safeExec / mint) ── + + it("openLp bubbles the inner revert reason when an approve module call fails with returndata", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.safe.setFailData(BUBBLE_DATA)).wait(); + await (await ctx.safe.setFail(ctx.usdcAddr, 2)).wait(); // first approve target = USDC + await expect(openLpCall(ctx)).to.be.revertedWith(BUBBLE_REASON); + }); + + it("openLp reverts ModuleCallFailed(20) when an approve module call fails with empty returndata", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.safe.setFail(ctx.usdcAddr, 1)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "ModuleCallFailed").withArgs(20); + }); + + it("openLp bubbles the inner revert reason when the swap module call fails with returndata", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.router.setOutput(WETH_OUT)).wait(); + await (await ctx.safe.setFailData(BUBBLE_DATA)).wait(); + await (await ctx.safe.setFail(await ctx.router.getAddress(), 2)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWith(BUBBLE_REASON); + }); + + it("openLp reverts ModuleCallFailed(3) when the swap module call fails with empty returndata", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.safe.setFail(await ctx.router.getAddress(), 1)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "ModuleCallFailed").withArgs(3); + }); + + it("openLp reverts ModuleCallFailed(4) when the mint module call fails", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.router.setOutput(WETH_OUT)).wait(); + await (await ctx.safe.setFail(await ctx.npm.getAddress(), 1)).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "ModuleCallFailed").withArgs(4); + }); + + // ── closeLp / collectLp defensive branches ────────────────────────── + + it("collectLp reverts WrongTokenPair when the position is not a WETH/USDC pair", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.npm.setTokens(tokenId, ctx.stranger.address, ctx.usdcAddr)).wait(); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).to.be.revertedWithCustomError( + ctx.rhp, + "WrongTokenPair", + ); + }); + + it("collectLp tolerates a token whose fee transfer returns false (non-reverting): fee waived, full amount forwarded", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + const owed0 = 1_000_000n; + const owed1 = 1_000_000n; + await (await ctx.npm.setOwed(tokenId, owed0, owed1)).wait(); + // WETH transfer to the treasury returns false (no revert) → else branch. + await (await ctx.weth.setFalseTransferTo(ctx.treasury.address)).wait(); + + const tWeth0 = await ctx.weth.balanceOf(ctx.treasury.address); + const sWeth0 = await ctx.weth.balanceOf(ctx.safeAddr); + + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)) + .to.emit(ctx.rhp, "CollectFeeTransferFailed") + .withArgs(ctx.safeAddr, tokenId, ctx.wethAddr, (owed0 * COLLECT_FEE_BPS) / 10_000n); + + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.FeesCollected(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + // WETH leg fee waived; full WETH forwarded to the Safe. + expect(ev.fee0).to.equal(0n); + expect((await ctx.weth.balanceOf(ctx.treasury.address)) - tWeth0).to.equal(0n); + expect((await ctx.weth.balanceOf(ctx.safeAddr)) - sWeth0).to.equal(owed0); + // USDC leg charged normally. + expect(ev.fee1).to.equal((owed1 * COLLECT_FEE_BPS) / 10_000n); + }); + + // ── Happy lifecycle ───────────────────────────────────────────────── + + it("openLp mints a position, stores the basis, and leaves the NFT on the Safe", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + expect(await ctx.npm.ownerOf(tokenId)).to.equal(ctx.safeAddr); + // basis = halfUsdc (WETH leg valued at swap rate) + retainedUsdc = usdcAmount. + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(USDC_AMOUNT); + }); + + it("collectLp harvests owed fees, charges feeCollectBps, and forwards the remainder", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + const owed0 = 800_000n; + const owed1 = 400_000n; + await (await ctx.npm.setOwed(tokenId, owed0, owed1)).wait(); + + const tWeth0 = await ctx.weth.balanceOf(ctx.treasury.address); + const tUsdc0 = await ctx.usdc.balanceOf(ctx.treasury.address); + const sWeth0 = await ctx.weth.balanceOf(ctx.safeAddr); + const sUsdc0 = await ctx.usdc.balanceOf(ctx.safeAddr); + + await (await ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).wait(); + + const fee0 = (owed0 * COLLECT_FEE_BPS) / 10_000n; + const fee1 = (owed1 * COLLECT_FEE_BPS) / 10_000n; + expect((await ctx.weth.balanceOf(ctx.treasury.address)) - tWeth0).to.equal(fee0); + expect((await ctx.usdc.balanceOf(ctx.treasury.address)) - tUsdc0).to.equal(fee1); + expect((await ctx.weth.balanceOf(ctx.safeAddr)) - sWeth0).to.equal(owed0 - fee0); + expect((await ctx.usdc.balanceOf(ctx.safeAddr)) - sUsdc0).to.equal(owed1 - fee1); + // Position stays open. + expect(await ctx.npm.ownerOf(tokenId)).to.equal(ctx.safeAddr); + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(USDC_AMOUNT); + // RHP keeps no residual of either leg. + expect(await ctx.weth.balanceOf(await ctx.rhp.getAddress())).to.equal(0n); + expect(await ctx.usdc.balanceOf(await ctx.rhp.getAddress())).to.equal(0n); + }); + + it("closeLp (full) charges a performance fee on net profit and burns the NFT", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + + // Realized USDC = 500_000 (principal) + closeOutput. basis = 1_000_000. + const closeOutput = 1_000_000n; + await (await ctx.router.setOutput(closeOutput)).wait(); + + const tUsdc0 = await ctx.usdc.balanceOf(ctx.treasury.address); + await (await closeLpCall(ctx, tokenId)).wait(); + + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + const currentValue = HALF + closeOutput; // 500_000 + 1_000_000 + const expectedFee = ((currentValue - USDC_AMOUNT) * PERF_FEE_BPS) / 10_000n; + expect(ev.basisUsd6).to.equal(USDC_AMOUNT); + expect(ev.currentValueUsd6).to.equal(currentValue); + expect(ev.feeUsd6).to.equal(expectedFee); + expect(ev.feeUsd6).to.be.gt(0n); + expect(Number(ev.exitBps)).to.equal(10_000); + expect((await ctx.usdc.balanceOf(ctx.treasury.address)) - tUsdc0).to.equal(expectedFee); + // NFT burned + basis cleared. + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + expect(await ctx.npm.ownerOf(tokenId)).to.equal(ZERO); + }); + + it("closeLp (full) charges no performance fee at break-even / loss", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + + // Realized = 500_000 + 400_000 = 900_000 < basis 1_000_000 → no fee. + await (await ctx.router.setOutput(400_000n)).wait(); + const tUsdc0 = await ctx.usdc.balanceOf(ctx.treasury.address); + await (await closeLpCall(ctx, tokenId)).wait(); + + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(ev.feeUsd6).to.equal(0n); + expect((await ctx.usdc.balanceOf(ctx.treasury.address)) - tUsdc0).to.equal(0n); + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + }); + + it("closeLp (partial) decrements basis pro-rata and keeps the position open", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.router.setOutput(300_000n)).wait(); + + await (await closeLpCall(ctx, tokenId, 5_000)).wait(); + + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(Number(ev.exitBps)).to.equal(5_000); + expect(ev.basisUsd6).to.equal(USDC_AMOUNT / 2n); + // Residual basis halved; NFT still on the Safe (not burned). + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(USDC_AMOUNT / 2n); + expect(await ctx.npm.ownerOf(tokenId)).to.equal(ctx.safeAddr); + }); + + it("handles a USDC-only position: collectLp (collected0 == 0) and closeLp (wethToSwap == 0)", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + // Reshape the position to be USDC-only (no WETH principal); basis stays + // in RHP storage from openLp. + await ( + await ctx.npm.seedPosition(tokenId, ctx.safeAddr, ctx.wethAddr, ctx.usdcAddr, 500, 1_000_000n, 0n, 500_000n) + ).wait(); + + // collectLp with only USDC owed → _collectLp `collected0 == 0` branch. + await (await ctx.npm.setOwed(tokenId, 0n, 300_000n)).wait(); + await (await ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).wait(); + const cev = (await ctx.rhp.queryFilter(ctx.rhp.filters.FeesCollected(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(cev.collected0).to.equal(0n); + expect(cev.collected1).to.equal(300_000n); + + // closeLp full → no WETH collected → `wethToSwap == 0` (swap skipped). + await (await ctx.router.setOutput(0n)).wait(); + await (await closeLpCall(ctx, tokenId)).wait(); + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(Number(ev.exitBps)).to.equal(10_000); + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(0n); + }); + + // ── Access-control modifier branches ──────────────────────────────── + + it("openLp reverts ZeroAddress when _onBehalfOf is the zero address", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).openLp(ZERO, USDC_AMOUNT, 0, 0, 500, 0, 0, 500, 1n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "ZeroAddress"); + }); + + it("openLp reverts NotAuthorized for a caller that is neither operator nor Safe", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp + .connect(ctx.stranger) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0, 0, 500, 1n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "NotAuthorized"); + }); + + it("closeLp reverts ZeroAddress when _onBehalfOf is the zero address", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).closeLp(ZERO, 1n, 500, 1n, 1n, SLIP, 5_000, 0, 0, DEADLINE, 0), + ).to.be.revertedWithCustomError(ctx.rhp, "ZeroAddress"); + }); + + it("closeLp reverts NotAuthorized for a caller that is neither operator nor Safe", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.stranger).closeLp(ctx.safeAddr, 1n, 500, 1n, 1n, SLIP, 5_000, 0, 0, DEADLINE, 0), + ).to.be.revertedWithCustomError(ctx.rhp, "NotAuthorized"); + }); + + it("collectLp reverts ZeroAddress when _onBehalfOf is the zero address", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ZERO, 1n)).to.be.revertedWithCustomError( + ctx.rhp, + "ZeroAddress", + ); + }); + + it("collectLp reverts NotAuthorized for a caller that is neither operator nor Safe", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect(ctx.rhp.connect(ctx.stranger).collectLp(ctx.safeAddr, 1n)).to.be.revertedWithCustomError( + ctx.rhp, + "NotAuthorized", + ); + }); + + // ── Remaining openLp entry guards ─────────────────────────────────── + + it("openLp reverts InvalidUsdcAmount when usdcAmount == 0", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).openLp(ctx.safeAddr, 0, 0, 0, 500, 0, 0, 500, 1n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "InvalidUsdcAmount"); + }); + + it("openLp reverts SlippageAboveMax when slippageBps exceeds maxSlippageBps", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0, 0, 500, 1n, 1n, 9999, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "SlippageAboveMax"); + }); + + it("openLp reverts FeeTierNotAllowed for a disallowed LP fee tier", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 10000, 0, 0, 500, 1n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "FeeTierNotAllowed"); + }); + + it("openLp reverts FeeTierNotAllowed for a disallowed swap fee tier", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0, 0, 10000, 1n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "FeeTierNotAllowed"); + }); + + it("openLp reverts InvalidSwapAmountOutMin when swapAmountOutMin == 0", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0, 0, 500, 0n, 1n, SLIP, DEADLINE), + ).to.be.revertedWithCustomError(ctx.rhp, "InvalidSwapAmountOutMin"); + }); + + it("openLp reverts WrongTokenPair when only the pool's token1 is wrong (second operand)", async function () { + const ctx = await loadFixture(deployMockHarness); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const badPool = await Pool.deploy(ctx.wethAddr, ctx.stranger.address, Q96, 10n ** 18n); + await (await ctx.factory.setPool(await badPool.getAddress())).wait(); + await expect(openLpCall(ctx)).to.be.revertedWithCustomError(ctx.rhp, "WrongTokenPair"); + }); + + // ── closeLp entry guards ──────────────────────────────────────────── + + it("closeLp reverts InvalidExitBps for exitBps == 0 and exitBps > 10_000", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect(closeLpCall(ctx, 1n, 0)).to.be.revertedWithCustomError(ctx.rhp, "InvalidExitBps"); + await expect(closeLpCall(ctx, 1n, 10_001)).to.be.revertedWithCustomError(ctx.rhp, "InvalidExitBps"); + }); + + it("closeLp reverts SlippageAboveMax when slippageBps exceeds maxSlippageBps", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).closeLp(ctx.safeAddr, 1n, 500, 1n, 1n, 9999, 5_000, 0, 0, DEADLINE, 0), + ).to.be.revertedWithCustomError(ctx.rhp, "SlippageAboveMax"); + }); + + it("closeLp reverts FeeTierNotAllowed for a disallowed swap fee tier", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).closeLp(ctx.safeAddr, 1n, 10000, 1n, 1n, SLIP, 5_000, 0, 0, DEADLINE, 0), + ).to.be.revertedWithCustomError(ctx.rhp, "FeeTierNotAllowed"); + }); + + it("closeLp reverts InvalidSwapAmountOutMin when swapAmountOutMin == 0", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect( + ctx.rhp.connect(ctx.operatorEOA).closeLp(ctx.safeAddr, 1n, 500, 0n, 1n, SLIP, 5_000, 0, 0, DEADLINE, 0), + ).to.be.revertedWithCustomError(ctx.rhp, "InvalidSwapAmountOutMin"); + }); + + it("closeLp reverts UnknownPosition for a tokenId never opened via this contract", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect(closeLpCall(ctx, 999n)).to.be.revertedWithCustomError(ctx.rhp, "UnknownPosition"); + }); + + it("collectLp reverts UnknownPosition for a tokenId never opened via this contract", async function () { + const ctx = await loadFixture(deployMockHarness); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, 999n)).to.be.revertedWithCustomError( + ctx.rhp, + "UnknownPosition", + ); + }); + + it("collectLp reverts LpNotOnSafe when the position is owned by another address", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.npm.setOwner(tokenId, ctx.stranger.address)).wait(); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).to.be.revertedWithCustomError( + ctx.rhp, + "LpNotOnSafe", + ); + }); + + it("collectLp reverts WrongTokenPair when only token1 is wrong (second operand)", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.npm.setTokens(tokenId, ctx.wethAddr, ctx.stranger.address)).wait(); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).to.be.revertedWithCustomError( + ctx.rhp, + "WrongTokenPair", + ); + }); + + it("closeLp reverts MinUsdcOutNotMet when realized USDC is below minUsdcOut", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.router.setOutput(1_000_000n)).wait(); + await expect( + ctx.rhp + .connect(ctx.operatorEOA) + .closeLp(ctx.safeAddr, tokenId, 500, 1n, 1n, SLIP, 10_000, 0, 0, DEADLINE, 10n ** 18n), + ).to.be.revertedWithCustomError(ctx.rhp, "MinUsdcOutNotMet"); + }); + + it("closeLp reverts InvalidExitBps (L-4) when a partial exit would remove zero liquidity", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + // Tiny-liquidity position → liquidityToRemove truncates to 0 while + // basisForExit stays > 0. + await ( + await ctx.npm.seedPosition(tokenId, ctx.safeAddr, ctx.wethAddr, ctx.usdcAddr, 500, 1n, 0n, 500_000n) + ).wait(); + await expect(closeLpCall(ctx, tokenId, 5_000)).to.be.revertedWithCustomError(ctx.rhp, "InvalidExitBps"); + }); + + it("closeLp partial dust no-op: zero basisForExit and zero liquidityToRemove leaves the position untouched", async function () { + const ctx = await loadFixture(deployMockHarness); + // basis = 2 (usdcAmount = 2) and liquidity = 1, so a 1-bps exit floors + // both basisForExit and liquidityToRemove to 0 → the L-4 guard does not + // fire (basisForExit == 0) and the decrease block (line 583) is skipped. + await (await ctx.npm.setMintLiquidity(1n)).wait(); + const tokenId = await openLp(ctx, { usdcAmount: 2n }); + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(2n); + + await (await ctx.router.setOutput(0n)).wait(); + await (await closeLpCall(ctx, tokenId, 1)).wait(); + + // No basis drawn down, position untouched and still open. + expect(await ctx.rhp.residualBasisUsd6Of(tokenId)).to.equal(2n); + expect(await ctx.npm.ownerOf(tokenId)).to.equal(ctx.safeAddr); + }); + + it("closeLp charges no performance fee when the profit rounds the fee to zero", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + // currentValue = 500_000 + 500_005 = 1_000_005; profit = 5; fee = 5*1000/10000 = 0. + await (await ctx.router.setOutput(500_005n)).wait(); + await (await closeLpCall(ctx, tokenId)).wait(); + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(ev.currentValueUsd6).to.be.gt(ev.basisUsd6); + expect(ev.feeUsd6).to.equal(0n); + }); + + it("closeLp waives the performance fee (FeeTransferFailed) when the treasury transfer reverts", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.router.setOutput(1_000_000n)).wait(); // profit + await (await ctx.usdc.setRevertTransferTo(ctx.treasury.address)).wait(); + const tUsdc0 = await ctx.usdc.balanceOf(ctx.treasury.address); + await expect(closeLpCall(ctx, tokenId)).to.emit(ctx.rhp, "FeeTransferFailed"); + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.PositionClosed(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(ev.feeUsd6).to.equal(0n); + expect((await ctx.usdc.balanceOf(ctx.treasury.address)) - tUsdc0).to.equal(0n); + }); + + it("collectLp surfaces CollectFeeTransferFailed (catch branch) when the fee transfer reverts", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.npm.setOwed(tokenId, 1_000_000n, 1_000_000n)).wait(); + await (await ctx.weth.setRevertTransferTo(ctx.treasury.address)).wait(); + await expect(ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)) + .to.emit(ctx.rhp, "CollectFeeTransferFailed") + .withArgs(ctx.safeAddr, tokenId, ctx.wethAddr, (1_000_000n * COLLECT_FEE_BPS) / 10_000n); + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.FeesCollected(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(ev.fee0).to.equal(0n); + }); + + it("collectLp with feeCollectBps == 0 skims nothing (fee == 0 branch)", async function () { + const ctx = await loadFixture(deployMockHarness); + await (await ctx.rhp.connect(ctx.deployer).setFeeCollectBps(0)).wait(); + const tokenId = await openLp(ctx); + await (await ctx.npm.setOwed(tokenId, 800_000n, 400_000n)).wait(); + const tWeth0 = await ctx.weth.balanceOf(ctx.treasury.address); + await (await ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId)).wait(); + const ev = (await ctx.rhp.queryFilter(ctx.rhp.filters.FeesCollected(ctx.safeAddr, tokenId), -5)).slice(-1)[0] + .args; + expect(ev.fee0).to.equal(0n); + expect(ev.fee1).to.equal(0n); + expect((await ctx.weth.balanceOf(ctx.treasury.address)) - tWeth0).to.equal(0n); + }); + + // ── rescueERC721 ──────────────────────────────────────────────────── + + it("rescueERC721 transfers a stranded NFT and rejects zero addresses / non-admin", async function () { + const ctx = await loadFixture(deployMockHarness); + const ERC721 = await ethers.getContractFactory("MockERC721"); + const nft = await ERC721.deploy(); + await nft.waitForDeployment(); + const nftAddr = await nft.getAddress(); + await (await nft.mint(await ctx.rhp.getAddress(), 7n)).wait(); + + await expect(ctx.rhp.connect(ctx.deployer).rescueERC721(nftAddr, 7n, ctx.stranger.address)) + .to.emit(ctx.rhp, "NftRescued") + .withArgs(nftAddr, ctx.stranger.address, 7n); + expect(await nft.ownerOf(7n)).to.equal(ctx.stranger.address); + + await expect( + ctx.rhp.connect(ctx.deployer).rescueERC721(ZERO, 7n, ctx.stranger.address), + ).to.be.revertedWithCustomError(ctx.rhp, "ZeroAddress"); + await expect(ctx.rhp.connect(ctx.deployer).rescueERC721(nftAddr, 7n, ZERO)).to.be.revertedWithCustomError( + ctx.rhp, + "ZeroAddress", + ); + await expect( + ctx.rhp.connect(ctx.stranger).rescueERC721(nftAddr, 7n, ctx.stranger.address), + ).to.be.revertedWithCustomError(ctx.rhp, "AccessControlUnauthorizedAccount"); + }); +}); diff --git a/test/ratehopperUniV3PositionsTimelock.ts b/test/ratehopperUniV3PositionsTimelock.ts new file mode 100644 index 0000000..81d22de --- /dev/null +++ b/test/ratehopperUniV3PositionsTimelock.ts @@ -0,0 +1,189 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; +import { + PARASWAP_V6_CONTRACT_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, +} from "./constants"; + +const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; +const MAX_FEE_BPS = 2000; +const PERFORMANCE_FEE_BPS = 1000; +const COLLECT_FEE_BPS = 250; +const TWO_DAYS = 2 * 24 * 60 * 60; + +describe("RatehopperUniV3Positions - Timelock Bypass Regression", function () { + async function deployFixture() { + const [deployer, admin, treasury, attacker, otherTreasury] = await ethers.getSigners(); + + // Deploy a real TimelockController for end-to-end verification. + const TimelockController = await ethers.getContractFactory("TimelockController"); + const timelock = await TimelockController.deploy( + TWO_DAYS, + [deployer.address], + [deployer.address], + deployer.address, + ); + await timelock.waitForDeployment(); + + const ProtocolRegistry = await ethers.getContractFactory("ProtocolRegistry"); + const protocolRegistry = await ProtocolRegistry.deploy( + WETH_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + admin.address, + await timelock.getAddress(), + deployer.address, + PARASWAP_V6_CONTRACT_ADDRESS, + ); + await protocolRegistry.waitForDeployment(); + + const RHP = await ethers.getContractFactory("RatehopperUniV3Positions"); + const rhp = await RHP.deploy( + UNISWAP_V3_NPM_ADDRESS, + await protocolRegistry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + treasury.address, + PERFORMANCE_FEE_BPS, + COLLECT_FEE_BPS, + MAX_FEE_BPS, + admin.address, + await timelock.getAddress(), + 0, // _minPoolLiquidity + 0, // _minPositionLiquidity + ); + await rhp.waitForDeployment(); + + const DEFAULT_ADMIN_ROLE = await rhp.DEFAULT_ADMIN_ROLE(); + const CRITICAL_ROLE = await rhp.CRITICAL_ROLE(); + + return { + rhp, + protocolRegistry, + timelock, + deployer, + admin, + treasury, + attacker, + otherTreasury, + DEFAULT_ADMIN_ROLE, + CRITICAL_ROLE, + }; + } + + describe("Direct admin calls", function () { + it("DEFAULT_ADMIN_ROLE cannot directly call setTreasury", async function () { + const { rhp, admin, otherTreasury, CRITICAL_ROLE } = await loadFixture(deployFixture); + await expect(rhp.connect(admin).setTreasury(otherTreasury.address)) + .to.be.revertedWithCustomError(rhp, "AccessControlUnauthorizedAccount") + .withArgs(admin.address, CRITICAL_ROLE); + }); + + it("DEFAULT_ADMIN_ROLE cannot directly call setPerformanceFeeBps", async function () { + const { rhp, admin, CRITICAL_ROLE } = await loadFixture(deployFixture); + await expect(rhp.connect(admin).setPerformanceFeeBps(500)) + .to.be.revertedWithCustomError(rhp, "AccessControlUnauthorizedAccount") + .withArgs(admin.address, CRITICAL_ROLE); + }); + + it("DEFAULT_ADMIN_ROLE cannot directly call setFeeCollectBps", async function () { + const { rhp, admin, CRITICAL_ROLE } = await loadFixture(deployFixture); + await expect(rhp.connect(admin).setFeeCollectBps(500)) + .to.be.revertedWithCustomError(rhp, "AccessControlUnauthorizedAccount") + .withArgs(admin.address, CRITICAL_ROLE); + }); + }); + + describe("Role self-grant bypass", function () { + it("DEFAULT_ADMIN_ROLE cannot grant itself CRITICAL_ROLE because the role is self-administered", async function () { + const { rhp, admin, CRITICAL_ROLE } = await loadFixture(deployFixture); + // CRITICAL_ROLE is admined by itself, not by DEFAULT_ADMIN_ROLE, + // so admin cannot grant it to anyone (including itself). + expect(await rhp.getRoleAdmin(CRITICAL_ROLE)).to.equal(CRITICAL_ROLE); + + await expect(rhp.connect(admin).grantRole(CRITICAL_ROLE, admin.address)) + .to.be.revertedWithCustomError(rhp, "AccessControlUnauthorizedAccount") + .withArgs(admin.address, CRITICAL_ROLE); + }); + + it("Even with CRITICAL_ROLE, non-timelock caller cannot call setTreasury (defense in depth)", async function () { + const { rhp, timelock, otherTreasury, CRITICAL_ROLE } = await loadFixture(deployFixture); + + // Grant CRITICAL_ROLE to an attacker EOA via the timelock (the only legitimate path). + const [, , , attacker] = await ethers.getSigners(); + const grantData = rhp.interface.encodeFunctionData("grantRole", [CRITICAL_ROLE, attacker.address]); + const salt = ethers.id("grant-critical-to-attacker"); + await timelock.schedule(await rhp.getAddress(), 0, grantData, ethers.ZeroHash, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(await rhp.getAddress(), 0, grantData, ethers.ZeroHash, salt); + expect(await rhp.hasRole(CRITICAL_ROLE, attacker.address)).to.be.true; + + // Even with CRITICAL_ROLE, the attacker is not the timelock - must revert + await expect(rhp.connect(attacker).setTreasury(otherTreasury.address)).to.be.revertedWithCustomError( + rhp, + "OnlyTimelock", + ); + await expect(rhp.connect(attacker).setPerformanceFeeBps(500)).to.be.revertedWithCustomError( + rhp, + "OnlyTimelock", + ); + await expect(rhp.connect(attacker).setFeeCollectBps(500)).to.be.revertedWithCustomError( + rhp, + "OnlyTimelock", + ); + }); + }); + + describe("Timelock-scheduled execution", function () { + it("Timelock can call setTreasury after delay", async function () { + const { rhp, timelock, otherTreasury } = await loadFixture(deployFixture); + const data = rhp.interface.encodeFunctionData("setTreasury", [otherTreasury.address]); + const salt = ethers.id("set-treasury"); + + await timelock.schedule(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt); + + expect(await rhp.treasury()).to.equal(otherTreasury.address); + }); + + it("Timelock can call setPerformanceFeeBps after delay", async function () { + const { rhp, timelock } = await loadFixture(deployFixture); + const data = rhp.interface.encodeFunctionData("setPerformanceFeeBps", [500]); + const salt = ethers.id("set-perf-fee"); + + await timelock.schedule(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt); + + expect(await rhp.performanceFeeBps()).to.equal(500); + }); + + it("Timelock can call setFeeCollectBps after delay", async function () { + const { rhp, timelock } = await loadFixture(deployFixture); + const data = rhp.interface.encodeFunctionData("setFeeCollectBps", [400]); + const salt = ethers.id("set-fee-collect"); + + await timelock.schedule(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt, TWO_DAYS); + await time.increase(TWO_DAYS); + await timelock.execute(await rhp.getAddress(), 0, data, ethers.ZeroHash, salt); + + expect(await rhp.feeCollectBps()).to.equal(400); + }); + + it("Direct call from timelock signer fails (must go through schedule/execute)", async function () { + const { rhp, timelock, otherTreasury } = await loadFixture(deployFixture); + // Direct call as the timelock's deployer/proposer signer - msg.sender will be that EOA, not timelock address + const [deployer] = await ethers.getSigners(); + await expect(rhp.connect(deployer).setTreasury(otherTreasury.address)).to.be.revertedWithCustomError( + rhp, + "AccessControlUnauthorizedAccount", + ); + }); + }); +}); diff --git a/test/safeTestContext.ts b/test/safeTestContext.ts new file mode 100644 index 0000000..20393b3 --- /dev/null +++ b/test/safeTestContext.ts @@ -0,0 +1,4 @@ +import dotenv from "dotenv"; +dotenv.config(); + +export const safeAddress = process.env.TESTING_SAFE_WALLET_ADDRESS!;