diff --git a/.env.sample b/.env.sample index e9b3a85..c693f51 100644 --- a/.env.sample +++ b/.env.sample @@ -14,6 +14,13 @@ EXPLORER_KEY= # endpoint; a dedicated archive node is strongly recommended for reliable forking. BASE_RPC_URL= +# [OPTIONAL] Pin the global Hardhat Base fork. Tests default to 49470000 for +# deterministic protocol state; override this only when intentionally testing +# newer Base state. +# Leave empty when running ParaSwap integration tests because its API quotes +# against the latest chain state. Set a block only for fully deterministic tests. +BASE_FORK_BLOCK_NUMBER= + # ============================================================================= # ACCESS CONTROL ADDRESSES # ============================================================================= @@ -26,7 +33,7 @@ BASE_RPC_URL= ADMIN_ADDRESS= # [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 (8-hour delay by default) +# This address is stored in ProtocolRegistry and can only be changed via TimelockController (2-day delay by default) SAFE_OPERATOR_ADDRESS= # [REQUIRED] Pauser address that can pause/unpause SafeDebtManager and LeveragedPosition in emergencies @@ -34,7 +41,7 @@ SAFE_OPERATOR_ADDRESS= PAUSER_ADDRESS= # ============================================================================= -# TIMELOCK DEPLOYMENT (shared by deploy:1_core and deploy:2_univ3_helper) +# TIMELOCK DEPLOYMENT (shared by deploy:1_core and deploy:2_yield_manager) # ============================================================================= # [OPTIONAL] EOA/multisig granted BOTH proposer and executor roles on the deployed @@ -42,43 +49,69 @@ PAUSER_ADDRESS= TIMELOCK_ADMIN= # [OPTIONAL] Minimum delay (seconds) before queued timelock ops can execute. -# Defaults to 28800 (8 hours). +# Defaults to 172800 (2 days). TIMELOCK_DELAY= # ============================================================================= -# UNISWAP V3 HELPER DEPLOYMENT (deploy:2_univ3_helper) +# YIELD DEPLOY CONFIG — for deploy:2_yield_manager. +# +# Resolution order: SYM_* module override → shared unprefixed name below → +# legacy RHP_* fallback (addresses only) → default. An EMPTY value (X=) +# counts as unset and falls through, so leave optional variables commented +# out unless you mean to override them. # ============================================================================= # [REQUIRED] Treasury address that collects performance + collect fees. -# The deploy reverts without a valid treasury. -RHP_TREASURY= +# The deploys revert without a valid treasury. +TREASURY= -# [OPTIONAL] ProtocolRegistry to wire RHP to. If unset, falls back to +# [OPTIONAL] ProtocolRegistry to wire against. Falls back to # PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts (auto-synced by registry/core deploys). -RHP_REGISTRY= +# REGISTRY= -# [OPTIONAL] DEFAULT_ADMIN_ROLE holder on RatehopperUniV3Positions. Falls back to ADMIN_ADDRESS. -RHP_INITIAL_ADMIN= +# [OPTIONAL] DEFAULT_ADMIN_ROLE holder. Falls back to ADMIN_ADDRESS. +# INITIAL_ADMIN= # [OPTIONAL] Reuse an existing TimelockController instead of deploying a new one. -# When set, the shared TimelockControllerModule is skipped for this deploy. +# When set, the shared TimelockControllerModule is skipped. Read by +# deploy:2_yield_manager only (module override: SYM_TIMELOCK); the registry +# deploy uses REGISTRY_TIMELOCK below. 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= +# [OPTIONAL] deploy:0_registry only — DEFAULT_ADMIN_ROLE holder on the new +# ProtocolRegistry (falls back to ADMIN_ADDRESS) and an existing +# TimelockController to reuse instead of the shared sub-module. +# REGISTRY_INITIAL_ADMIN= +# REGISTRY_TIMELOCK= + +# [OPTIONAL] Fee config (bps). Defaults: 1000 (10%) / 250 (2.5%) / 2000 (20%). +# PERFORMANCE_FEE_BPS=1000 +# FEE_COLLECT_BPS=250 +# MAX_FEE_BPS=2000 + +# [OPTIONAL] Floor on NPM mint liquidity (dust-close hardening) and on +# pool.liquidity() for spot-price reads (manipulation hardening). +# Defaults: 10000 / 0 (disabled). See the module docs in ignition/modules/ before raising. +# MIN_POSITION_LIQUIDITY=10000 +# MIN_POOL_LIQUIDITY=0 + +# SYM_* overrides (rarely needed): same suffix with the SYM_ prefix, e.g. +# SYM_TREASURY / SYM_MAX_FEE_BPS. The pauser on SafeYieldManager comes from +# SYM_PAUSER / PAUSER_ADDRESS / ADMIN_ADDRESS. +# SafeYieldManager additionally supports per-protocol floors: +# SYM_UNIV3_MIN_POSITION_LIQUIDITY=10000 +# SYM_AERODROME_MIN_POSITION_LIQUIDITY=10000 +# SYM_UNIV4_MIN_POSITION_LIQUIDITY=10000 +# SYM_UNIV3_MIN_POOL_LIQUIDITY=0 +# SYM_AERODROME_MIN_POOL_LIQUIDITY=0 +# SYM_UNIV4_MIN_POOL_LIQUIDITY=0 + +# [OPTIONAL] Uniswap V4 address overrides — default to the canonical Base +# addresses in contractAddresses.ts; only set when targeting another network. +# SYM_UNIV4_POSITION_MANAGER= +# SYM_UNIVERSAL_ROUTER= +# SYM_PERMIT2= +# SYM_UNIV4_STATE_VIEW= # ============================================================================= # TESTING CONFIGURATION (for running tests and scripts) @@ -92,9 +125,6 @@ TESTING_SAFE_OWNER_KEY= # [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) # ============================================================================= @@ -105,9 +135,12 @@ TIMELOCK_ADDRESS= # [REQUIRED] ProtocolRegistry contract address (set after deployment) PROTOCOL_REGISTRY_ADDRESS= -# [REQUIRED] New Paraswap V6 address (for timelock-update-paraswap.ts script) +# [REQUIRED] New Paraswap V6 address (for timelockUpdateParaswap.ts script) NEW_PARASWAP_ADDRESS= +# [REQUIRED] New operator address (for timelockUpdateOperator.ts script) +NEW_OPERATOR_ADDRESS= + # [OPTIONAL] Set to "true" to execute a scheduled timelock operation (default: schedule only) EXECUTE= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1d7116..3a62633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,19 +33,27 @@ jobs: - 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. + # Coverage is scoped to the RatehopperUniV3Positions and + # SafeYieldManager suites. The 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:gated`) 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) + # + # BASE_FORK_BLOCK_NUMBER pins the default-network fork so the run is + # deterministic: without it the fork tracks the latest Base block and + # the well-known Hardhat deployer accounts inherit whatever on-chain + # nonce they have there, shifting every mock deploy address (and the + # branch coverage that depends on address ordering) from run to run. + - name: Run coverage (gated contracts) env: BASE_RPC_URL: ${{ secrets.BASE_RPC_URL }} - run: yarn coverage:rhp + BASE_FORK_BLOCK_NUMBER: "49470000" + run: yarn coverage:gated - name: Enforce branch-coverage gate run: yarn coverage:check diff --git a/.gitignore b/.gitignore index fd179f8..32e246d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,13 @@ node_modules # Hardhat Ignition default folder for deployments against a local node ignition/deployments/chain-* +ignition/deployments/local-operator-v1/ +ignition/deployments/yield-v2/ + +# Local deployment records (keep the stable Base manifest reviewable) +/deployments/* +!/deployments/base.json /.vscode yarn-error.log eth-sdk/abis/* -.claude \ No newline at end of file +.claude diff --git a/.solcover.js b/.solcover.js index f6f9c15..2f5d40f 100644 --- a/.solcover.js +++ b/.solcover.js @@ -1,9 +1,12 @@ 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, + // RatehopperUniV3Positions compiles with viaIR; instrumented branches in + // large frames (e.g. switchLp) trip "stack too deep" unless the Yul + // optimizer pipeline is reduced to the minimum (Foundry ir-minimum + // equivalent). Supersedes configureYulOptimizer, which stopped being + // enough once switchLp grew. + irMinimum: true, mocha: { parallel: false, }, diff --git a/CLAUDE.md b/CLAUDE.md index cd01988..628b42f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,15 +16,24 @@ RateHopper Contracts is a DeFi smart contract system enabling automated debt pos ### Core Contracts - **SafeDebtManager.sol**: Main entry point for debt swaps via Gnosis Safe +- **SafeYieldManager.sol**: Single Safe-module entry point for yield (LP) protocols; delegatecalls stateless yield handlers, shared state in ERC-7201 namespace (`YieldStorage`) - **LeveragedPosition.sol**: Creates leveraged positions across protocols - **ProtocolRegistry.sol**: Central registry for token mappings, operator, and protocol configs -- **Types.sol**: Shared type definitions +- **Types.sol**: Shared type definitions (`DebtProtocol` enum, `YIELD_PROTOCOL_*` uint8 id constants) +- **RatehopperUniV3Positions.sol**: Legacy standalone yield module, deployed and serving existing positions; superseded by SafeYieldManager for new positions (coexistence — do not modify) -### Protocol Handlers (`contracts/protocols/`, `contracts/protocolsSafe/`) +### Protocol Handlers (`contracts/debt/handlers/`) -- **AaveV3Handler.sol**, **CompoundHandler.sol**, **MoonwellHandler.sol**, **FluidSafeHandler.sol** +- **AaveV3DebtHandler.sol**, **CompoundDebtHandler.sol**, **MorphoDebtHandler.sol**, **MoonwellDebtHandler.sol**, **FluidSafeDebtHandler.sol** extend **BaseDebtHandler.sol** - Each implements: `getDebtAmount`, `switchIn`, `switchFrom`, `switchTo`, `repay` +### Yield Handlers (`contracts/yield/handlers/`) + +- **BaseYieldHandler.sol** owns the shared LP flow, protocol diffs in virtual hooks; **V3StyleYieldHandler.sol** implements the hooks against canonical Uniswap V3 interfaces (protocol id as constructor arg); **UniV3YieldHandler.sol** extends V3StyleYieldHandler, **AerodromeYieldHandler.sol** extends BaseYieldHandler directly +- **UniV4YieldHandler.sol** implements `IYieldHandler` directly (V4 singleton/actions model doesn't fit the V3-shaped hooks): PoolKey pool params (`keccak256(poolParam)` == V4 PoolId), Permit2 two-step approvals, UniversalRouter swaps, native ETH pools supported; V4 math imported from exact-pinned npm packages (`@uniswap/v4-core@1.0.2`, `@uniswap/v4-periphery@1.0.3` — keep exact versions, no `^`); V4 interfaces remain hand-written minimal versions vendored under `contracts/interfaces/uniswapV4/` (do not replace with official interfaces — their `Currency`/`PositionInfo` types would leak into handler code) +- Stateless delegatecall targets: MUST NOT declare storage variables; mutable state only via `YieldStorage._yieldStorage()` +- Pool selection params are ABI-encoded bytes (`uint24` feeTier / `int24` tickSpacing / full V4 `PoolKey` tuple) + ### Access Control - **DEFAULT_ADMIN_ROLE**: Routine operations (whitelist, token mappings) @@ -42,14 +51,14 @@ RateHopper Contracts is a DeFi smart contract system enabling automated debt pos ### Naming -- Handlers: `Handler.sol` +- Handlers: `DebtHandler.sol` (debt) / `YieldHandler.sol` (yield) - Interfaces: `I.sol` -- Tests: `test/.ts` +- Tests: `test//.ts` — areas: `debt/`, `registry/`, `yield/`, `legacy/` (deployed standalone modules), `helpers/` (fixtures/utils, no tests) ## Key Files - `contractAddresses.ts`: Token and protocol addresses -- `test/constants.ts`, `test/utils.ts`, `test/deployUtils.ts`: Test helpers +- `test/helpers/constants.ts`, `test/helpers/utils.ts`, `test/helpers/deployUtils.ts`: Test helpers ## Security Requirements diff --git a/README.md b/README.md index 883b9ca..1b2f32d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ RateHopper Contracts is a smart contract system that enables users to automatica ## Key Features - **Multi-Protocol Support**: Currently supports borrowing from: - - Aave V3 - Compound - Morpho @@ -26,48 +25,55 @@ RateHopper Contracts is a smart contract system that enables users to automatica - **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. +- **Unified Yield Module (SafeYieldManager)**: `SafeYieldManager` is the single Safe module for all yield (LP) protocols — the yield-side counterpart of `SafeDebtManager`. Users enable this ONE contract as a module; per-protocol mechanics live in stateless handlers (`UniV3YieldHandler`, `AerodromeYieldHandler`, `UniV4YieldHandler`) invoked via delegatecall. Protocols are identified by plain `uint8` ids (the `YIELD_PROTOCOL_*` constants in `Types.sol` document the canonical assignment), so adding a protocol is a handler deployment + timelocked `setYieldHandler` on the already-deployed manager — no redeploy, and not a new module every user must enable. The standalone `RatehopperUniV3Positions` module above is **legacy**: it keeps serving positions opened through it (coexistence, no state migration), while new positions open through `SafeYieldManager` — Uniswap V3, Aerodrome Slipstream (CL, with optional staking for AERO emissions), and Uniswap V4 (including native ETH pools), over any allow-listed pair. + ## Architecture +See [`docs/SECURITY_MODEL.md`](docs/SECURITY_MODEL.md) for what the Safe-module +custody model implies: why the performance fee is cooperative, why repayment +residue is refunded, and why the pauser can delay exits but never trap a +position. + The system consists of several key components: 1. **Governance & Access Control**: - - - `TimelockController`: OpenZeppelin's timelock implementation with 8-hour delay by default for critical operations + - `TimelockController`: OpenZeppelin's timelock implementation with 2-day delay by default for critical operations - `ProtocolRegistry.sol`: Central registry with hybrid access control: - `DEFAULT_ADMIN_ROLE`: For routine operations (whitelist, token mappings) - immediate execution - `CRITICAL_ROLE`: For critical operations (setParaswapV6, setOperator) - requires timelock -2. **DebtSwap.sol**: The main contract that orchestrates the debt switching process using flash loans. - -3. **Protocol Handlers**: Individual handlers for each supported lending protocol: - - - In `contracts/protocols/` directory: +2. **SafeDebtManager.sol**: The main contract that orchestrates the debt switching process using flash loans. - - `AaveV3Handler.sol`: Handles interactions with Aave V3 protocol - - `CompoundHandler.sol`: Handles interactions with Compound protocol - - `MorphoHandler.sol`: Handles interactions with Morpho protocol - - - In `contracts/protocolsSafe/` directory: - - `MoonwellHandler.sol`: Handles interactions with Moonwell protocol - - `FluidSafeHandler.sol`: Handles interactions with Fluid protocol through Safe +3. **Debt Handlers** (`contracts/debt/handlers/`): Individual handlers for each supported lending protocol, all implementing `IDebtHandler` and extending `BaseDebtHandler.sol`: + - `AaveV3DebtHandler.sol`: Handles interactions with Aave V3 protocol + - `CompoundDebtHandler.sol`: Handles interactions with Compound protocol + - `MorphoDebtHandler.sol`: Handles interactions with Morpho protocol + - `MoonwellDebtHandler.sol`: Handles interactions with Moonwell protocol + - `FluidSafeDebtHandler.sol`: Handles interactions with Fluid protocol through Safe 4. **Safe Modules**: Modules for Gnosis Safe integration: - - `SafeDebtManager.sol`: Enables debt swaps through Gnosis Safe - Both operator-initiated and Safe owner-initiated transactions supported 5. **LeveragedPosition.sol**: Facilitates creation of leveraged positions across protocols. 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: +7. **SafeYieldManager.sol + Yield Handlers** (`contracts/yield/`): Adapter-pattern successor to (6). `SafeYieldManager` is the single Safe module users enable; it owns basis bookkeeping (`residualBasisUsd6Of`, keyed by `(uint8 protocolId, tokenId)`), the performance fee, pause / per-protocol disable switches, and the timelocked setter surface. Protocol ids are plain `uint8` (not a Solidity enum) end-to-end, so a NEW protocol registers on the deployed manager via `setYieldHandler(id, handler)` — followed by the pauser enabling open/close and the admin allow-listing pool params — with no redeploy; the `YIELD_PROTOCOL_*` constants in `Types.sol` just document the canonical id assignment (append-only). Protocol mechanics live in stateless handlers executed via delegatecall: + - `switchLp()` atomically closes a full position and opens its replacement in another allowed pool/protocol, carrying only the cost basis attributable to value actually deployed into the new LP. + - `BaseYieldHandler.sol` — the shared `openLp`/`closeLp`/`collectLp` flow for V3-style CL protocols over ANY token pair; protocol diffs are isolated in virtual hooks (pool resolution, pair decoding, `slot0` read, swap calldata, mint calldata, `positions` decoding). USDC stays the sole funding/accounting currency: each non-USDC side of the pair is acquired/realized through its own USDC `SwapLeg` (a side that IS USDC needs no swap), so a WETH/USDC position swaps one leg and a WBTC/USDT-style position swaps both. + - `V3StyleYieldHandler.sol` — the `BaseYieldHandler` hooks implemented once against the canonical Uniswap V3 interfaces (factory `getPool` by `uint24 feeTier`, 7-field `slot0`, SwapRouter02), with the protocol id as a constructor argument so V3-shaped protocols and tests reuse the hook bodies. + - `UniV3YieldHandler.sol` (extends `V3StyleYieldHandler`) / `AerodromeYieldHandler.sol` (extends `BaseYieldHandler`) — concrete adapters holding protocol immutables. + - `UniV4YieldHandler.sol` — implements `IYieldHandler` directly (the V4 singleton/actions model doesn't fit the V3-shaped hooks): pool params are the full V4 `PoolKey` tuple (`keccak256(poolParam)` IS the V4 PoolId), ERC20 sides route through two-step Permit2 approvals and UniversalRouter swaps, and native ETH pools (`currency0 == address(0)`) are supported. + - Shared mutable state lives in an ERC-7201 namespace (`YieldStorage`, `ratehopper.storage.yield`), so handler delegatecode can never collide with the manager's inherited storage. Handlers MUST NOT declare storage variables. + - Pool selection params are ABI-encoded bytes carrying the PAIR plus the protocol key (`abi.encode(token0, token1, uint24 feeTier)` for Uniswap V3, `abi.encode(token0, token1, int24 tickSpacing)` for Aerodrome), allow-listed by `keccak256(poolParam)` — richer identifiers fit without interface changes (Uniswap V4 uses the full `PoolKey` tuple). A protocol whose mechanics don't fit `BaseYieldHandler` implements `IYieldHandler` directly. +8. **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 @@ -83,12 +89,12 @@ The system consists of several key components: **Compound V3:** -- Call `allow()` to authorize DebtSwap contract +- Call `allow()` to authorize the SafeDebtManager contract - Extra data: `"0x"` **Morpho:** -- Call `setAuthorization(debtSwapContract, true)` +- Call `setAuthorization(safeDebtManager, true)` - Extra data: Encode `(MarketParams, borrowShares)` - **REQUIRED** **Moonwell:** @@ -111,7 +117,7 @@ The system consists of several key components: ## Key Functions -### DebtSwap Contract +### SafeDebtManager Contract - `executeDebtSwap`: Main entry point for initiating a debt position transfer - `uniswapV3FlashCallback`: Handles the flash loan callback from Uniswap V3 @@ -120,9 +126,9 @@ The system consists of several key components: - `getHandler`: Retrieves the handler address for a specific protocol - `emergencyWithdraw`: Allows the owner to withdraw tokens in case of emergency -### Protocol Handlers +### Debt Handlers -Each protocol handler implements the following key functions: +Each debt handler implements the following key functions: - `getDebtAmount`: Retrieves current debt amount for a user - `switchIn`: Handles debt switching within the same protocol @@ -139,14 +145,14 @@ To execute a debt swap, you'll need to provide the following parameters: ```solidity function executeDebtSwap( address _flashloanPool, // Uniswap V3 pool address for flash loan - Protocol _fromProtocol, // Source protocol enum (COMPOUND, AAVE_V3, MORPHO, FLUID, MOONWELL) - Protocol _toProtocol, // Destination protocol enum + DebtProtocol _fromProtocol, // Source protocol enum (AAVE_V3, COMPOUND, MORPHO, FLUID, MOONWELL) + DebtProtocol _toProtocol, // Destination protocol enum address _fromDebtAsset, // Debt asset address on source protocol address _toDebtAsset, // Debt asset address on destination protocol uint256 _amount, // Amount to swap (use type(uint256).max for full debt) CollateralAsset[] calldata _collateralAssets, // Array of collateral assets - bytes calldata _fromExtraData, // Extra data for source protocol - bytes calldata _toExtraData, // Extra data for destination protocol + address _onBehalfOf, // Safe address the swap is executed for + bytes[2] calldata _extraData, // [fromExtraData, toExtraData] for the two protocols ParaswapParams calldata _paraswapParams // Paraswap parameters for token swaps ) ``` @@ -155,8 +161,8 @@ function executeDebtSwap( ```solidity struct CollateralAsset { - address asset; // Collateral asset address - uint256 amount; // Collateral amount + address asset; // Collateral asset address + uint256 amount; // Collateral amount } ``` @@ -164,37 +170,62 @@ struct CollateralAsset { ```solidity struct ParaswapParams { - uint256 srcAmount; // Source amount with slippage adjustment (for token swaps) - bytes swapData; // Encoded swap data from Paraswap API + uint256 srcAmount; // Source amount with slippage adjustment (for token swaps) + bytes swapData; // Encoded swap data from Paraswap API } ``` - ## Environment Variables Create a `.env` file with the following required variables (use `.env.sample` as a template): ```env # Core deploy -ADMIN_ADDRESS=0x... # Initial admin and timelock proposer/executor (used by 1_DeployCore AND 2_DeployUniV3Helper) +ADMIN_ADDRESS=0x... # Initial admin and timelock proposer/executor (used by all deploy modules) 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 +BASE_FORK_BLOCK_NUMBER=49470000 # Optional deterministic fork block for CI + +# Yield deploy config (deploy:2_yield_manager) +# Resolution order: SYM_* module override → shared unprefixed name → legacy +# RHP_* fallback (addresses only) → default. Empty values (X=) count as +# unset and fall through. +TREASURY=0x... # Fee treasury for all yield modules. REQUIRED. +REGISTRY=0x... # Optional. Falls back to PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts +INITIAL_ADMIN=0x... # Optional. DEFAULT_ADMIN_ROLE holder. Falls back to ADMIN_ADDRESS +RHP_TIMELOCK=0x... # Optional. Reuse an existing TimelockController for the yield module only + # (module override: SYM_TIMELOCK). The registry deploy uses REGISTRY_TIMELOCK. +PERFORMANCE_FEE_BPS=1000 # Optional. Performance fee on profit at closeLp (bps). Default 1000 (10%) +FEE_COLLECT_BPS=250 # Optional. Fee on accrued LP fees (bps). Default 250 (2.5%) +MAX_FEE_BPS=2000 # Optional. Hard upper bound on BOTH fees (bps). Default 2000 (20%) +MIN_POSITION_LIQUIDITY=10000 # Optional. Floor on NPM mint liquidity. Default 10000 +MIN_POOL_LIQUIDITY=0 # Optional. Floor on pool.liquidity() for spot-price reads. Default 0 (disabled) + +# Optional — TimelockController sub-module (shared by all deploys) TIMELOCK_ADMIN=0x... # Proposer + executor on the new timelock. Falls back to ADMIN_ADDRESS -TIMELOCK_DELAY=28800 # Min delay before queued ops execute (seconds). Default 28800 (8 hours) +TIMELOCK_DELAY=172800 # Min delay before queued ops execute (seconds). Default 172800 (2 days) + +# Optional — per-module overrides: the same suffix with the module prefix wins +# over the shared name, e.g. SYM_TREASURY / RHP_MAX_FEE_BPS. +# SafeYieldManager-specific: +SYM_PAUSER=0x... # Pauser (pause / per-protocol disable). Falls back to PAUSER_ADDRESS, then ADMIN_ADDRESS +SYM_UNIV3_MIN_POSITION_LIQUIDITY=10000 # Per-protocol floors; fall back to MIN_POSITION_LIQUIDITY / +SYM_AERODROME_MIN_POSITION_LIQUIDITY=10000 # MIN_POOL_LIQUIDITY, then the defaults +SYM_UNIV3_MIN_POOL_LIQUIDITY=0 # Set independently after measuring the target Uni V3 pool +SYM_AERODROME_MIN_POOL_LIQUIDITY=0 # Set independently after measuring the target Slipstream pool +SYM_UNIV4_MIN_POSITION_LIQUIDITY=10000 +SYM_UNIV4_MIN_POOL_LIQUIDITY=0 # Set independently after measuring the target V4 pool + +# Optional Uniswap V4 address overrides — default to the canonical Base +# addresses in contractAddresses.ts; only set when targeting another network. +# SYM_UNIV4_POSITION_MANAGER=0x... # V4 PositionManager +# SYM_UNIVERSAL_ROUTER=0x... # UniversalRouter +# SYM_PERMIT2=0x... # Permit2 (Base uses 0x...B43aC78BA3) +# SYM_UNIV4_STATE_VIEW=0x... # StateView lens ``` + ## Setup and Development 1. Install dependencies: @@ -226,13 +257,17 @@ The project uses: ## Testing -Comprehensive tests are available in the `/test` directory covering: +Comprehensive tests are available in the `/test` directory, organized by area (`test/debt/`, `test/yield/`, `test/registry/`, `test/legacy/`, plus shared fixtures in `test/helpers/`), covering: -- Individual protocol handlers +- Individual debt protocol handlers - Cross-protocol debt switching flows - Multiple collateral asset scenarios - Safe module integration - Leveraged position creation +- Yield stack: `SafeYieldManager` unit suites (mock-driven) and per-protocol fork suites (Uniswap V3, Aerodrome, Uniswap V4, cross-protocol `switchLp`) +- Legacy standalone yield module (`test/legacy/`) + +The `*Fork.ts` suites run against a Base mainnet fork and need `BASE_RPC_URL` (see Environment Variables). Run tests with: @@ -258,7 +293,7 @@ The core contracts are defined in a single module at `ignition/modules/1_DeployC ### Export ABIs -ABI files for the four core integration contracts are exported to `abis/` from Hardhat artifacts: +ABI files for the five core integration contracts are exported to `abis/` from Hardhat artifacts: ```bash yarn abis @@ -270,6 +305,7 @@ This compiles the contracts and writes: - `abis/RatehopperUniV3Positions.json` - `abis/SafeDebtManager.json` - `abis/SafeExecTransactionWrapper.json` +- `abis/SafeYieldManager.json` The deploy scripts below run the ABI exporter automatically after a successful deployment. @@ -301,22 +337,23 @@ This deploys all contracts sequentially in a single transaction chain: 4. **LeveragedPosition** → `transferOwnership` to `ADMIN_ADDRESS` 5. **SafeExecTransactionWrapper** -### Deploy RatehopperUniV3Positions (Standalone) +### Deploy SafeYieldManager (Unified Yield Stack) -`RatehopperUniV3Positions` is deployed separately because it sits on top of an existing `ProtocolRegistry` and needs its own TimelockController for fund-impacting setters. +Deploys the adapter-pattern yield stack: `UniV3YieldHandler` + `AerodromeYieldHandler` + `UniV4YieldHandler` + `SafeYieldManager`. ```bash -yarn deploy:2_univ3_helper +yarn deploy:2_yield_manager ``` -This deploys `ignition/modules/2_DeployUniV3Helper.ts` to Base with verification enabled and refreshes the ABI files in `abis/`. +This deploys `ignition/modules/2_DeployYieldManager.ts` to Base with verification enabled and refreshes the ABI files in `abis/`. -This module by default deploys: +The module by default deploys: -1. **TimelockController** (proposer + executor = `TIMELOCK_ADMIN` ?? `ADMIN_ADDRESS`; delay = `TIMELOCK_DELAY` ?? 8 hours) -2. **RatehopperUniV3Positions** (wired to the registry from `RHP_REGISTRY` ?? `PROTOCOL_REGISTRY_ADDRESS`; CRITICAL_ROLE granted to the timelock from step 1) +1. **TimelockController** (shared `TimelockControllerModule`; skipped when `SYM_TIMELOCK` / `RHP_TIMELOCK` is set) +2. **UniV3YieldHandler**, **AerodromeYieldHandler** and **UniV4YieldHandler** (stateless delegatecall targets pinned to the canonical Base position-manager / factory / router / Permit2 / USDC addresses) +3. **SafeYieldManager** with all three handlers registered, protocols enabled, and default pool-param allow-lists seeded (WETH/USDC on Uniswap V3 fee tiers `{100, 500, 3000}` and Aerodrome tick spacings `{100, 200}`; the hookless native ETH/USDC 0.05% pool on Uniswap V4). Additional pools are allow-listed post-deploy via `setPoolParamAllowed` — hooked V4 pools only after the hook is audited. -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. +Coexistence note: the standalone `RatehopperUniV3Positions` deployment keeps serving positions opened through it. `SafeYieldManager` rejects those tokenIds (`UnknownPosition`) and vice versa — there is no basis migration; legacy positions drain naturally via the legacy module. ### Deployment Output @@ -335,6 +372,15 @@ Inspect deployed addresses: cat ignition/deployments/chain-8453/deployed_addresses.json ``` +Ignition state is intentionally gitignored. Every deploy command also syncs a +stable, reviewable public manifest to `deployments/base.json`; commit that file +after a production deployment so downstream consumers do not depend on a local +Ignition directory. It can also be refreshed manually with: + +```bash +yarn deployments:sync +``` + ### Wiping Deployments Use `wipe:all` when you want to clear all local Ignition state for Base and redeploy everything from scratch: @@ -349,9 +395,9 @@ Shortcut scripts are available for common futures: ```bash yarn wipe:leveraged-position -yarn wipe:univ3-positions yarn wipe:safe-debt-manager yarn wipe:safe-wrapper +yarn wipe:yield-manager ``` In short: use `wipe:all` for a clean redeploy of the whole Base deployment, and use `wipe` or a `wipe:*` shortcut only when you intentionally want to rerun one named future. @@ -376,7 +422,7 @@ This script: ### Timelock Operations -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` (8-hour delay by default). 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. +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 by default). 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 @@ -427,6 +473,17 @@ EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS yarn hardhat run scripts/timelockUpdateOperator.ts --network base ``` +### Yield / LP Operations Scripts + +Operational scripts for driving a deployed `SafeYieldManager` from a user's own Safe (the `msg.sender == Safe` path of `onlyOperatorOrSafe`) live in `scripts/`: + +- `openLpBySafe.ts` — opens an LP position (Uniswap V3 / Aerodrome / Uniswap V4 pool params supported) +- `closeLpBySafe.ts` — partial or full close +- `collectLpBySafe.ts` — mid-position fee harvest +- `switchLpBySafe.ts` — atomic move of a position to another allowed pool/protocol +- `lpSafeShared.ts` — shared ABIs/helpers (no entry point) + +Each script documents its configuration constants in its header comment; set `SAFE_OWNER_PRIVATE_KEY` (or `DEPLOYER_PRIVATE_KEY`) in `.env` and run with `npx hardhat run scripts/.ts --network base`. ## Security Features @@ -434,7 +491,7 @@ The contracts include several security features: ### Access Control & Governance -- **Timelock Controller**: 8-hour delay by default for critical operations (Paraswap and operator updates) +- **Timelock Controller**: 2-day delay by default for critical operations (Paraswap and operator updates) - **Hybrid Access Control**: - `DEFAULT_ADMIN_ROLE`: For routine operations (immediate execution) - `CRITICAL_ROLE`: For critical operations (requires timelock) diff --git a/abis/LeveragedPosition.json b/abis/LeveragedPosition.json index 9d5e7d6..b6c21cd 100644 --- a/abis/LeveragedPosition.json +++ b/abis/LeveragedPosition.json @@ -7,7 +7,7 @@ "type": "address" }, { - "internalType": "enum Protocol[]", + "internalType": "enum DebtProtocol[]", "name": "protocols", "type": "uint8[]" }, @@ -179,7 +179,7 @@ }, { "indexed": false, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -228,7 +228,7 @@ }, { "indexed": false, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -316,7 +316,7 @@ "inputs": [ { "indexed": true, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -357,7 +357,7 @@ "type": "address" }, { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, @@ -422,7 +422,7 @@ "type": "address" }, { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, @@ -585,7 +585,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "", "type": "uint8" } @@ -650,7 +650,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, diff --git a/abis/RatehopperUniV3Positions.json b/abis/RatehopperUniV3Positions.json index 446254f..c4216c2 100644 --- a/abis/RatehopperUniV3Positions.json +++ b/abis/RatehopperUniV3Positions.json @@ -101,6 +101,11 @@ "name": "DeadlineExpired", "type": "error" }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, { "inputs": [], "name": "FeeAboveMax", @@ -949,6 +954,36 @@ "internalType": "uint256", "name": "tokenId", "type": "uint256" + }, + { + "internalType": "bool", + "name": "swapWethToUsdc", + "type": "bool" + }, + { + "internalType": "uint24", + "name": "swapPoolFeeTier", + "type": "uint24" + }, + { + "internalType": "uint256", + "name": "swapAmountOutMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expectedSwapOut", + "type": "uint256" + }, + { + "internalType": "uint16", + "name": "slippageBps", + "type": "uint16" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" } ], "name": "collectLp", diff --git a/abis/SafeDebtManager.json b/abis/SafeDebtManager.json index 2b8346d..0de8ad7 100644 --- a/abis/SafeDebtManager.json +++ b/abis/SafeDebtManager.json @@ -7,7 +7,7 @@ "type": "address" }, { - "internalType": "enum Protocol[]", + "internalType": "enum DebtProtocol[]", "name": "protocols", "type": "uint8[]" }, @@ -105,7 +105,7 @@ }, { "indexed": false, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -154,13 +154,13 @@ }, { "indexed": false, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "fromProtocol", "type": "uint8" }, { "indexed": false, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "toProtocol", "type": "uint8" }, @@ -323,7 +323,7 @@ "inputs": [ { "indexed": true, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -348,7 +348,7 @@ "inputs": [ { "indexed": true, - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "protocol", "type": "uint8" }, @@ -420,12 +420,12 @@ "type": "address" }, { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_fromProtocol", "type": "uint8" }, { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_toProtocol", "type": "uint8" }, @@ -497,7 +497,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, @@ -611,7 +611,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "", "type": "uint8" } @@ -630,7 +630,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "", "type": "uint8" } @@ -662,7 +662,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "", "type": "uint8" } @@ -714,7 +714,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, @@ -732,7 +732,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, @@ -763,7 +763,7 @@ { "inputs": [ { - "internalType": "enum Protocol", + "internalType": "enum DebtProtocol", "name": "_protocol", "type": "uint8" }, diff --git a/abis/SafeYieldManager.json b/abis/SafeYieldManager.json new file mode 100644 index 0000000..c371bb6 --- /dev/null +++ b/abis/SafeYieldManager.json @@ -0,0 +1,2672 @@ +[ + { + "inputs": [ + { + "internalType": "contract IProtocolRegistry", + "name": "_registry", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "_usdc", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "_weth", + "type": "address" + }, + { + "internalType": "uint8[]", + "name": "_protocols", + "type": "uint8[]" + }, + { + "internalType": "address[]", + "name": "_handlers", + "type": "address[]" + }, + { + "internalType": "bytes[][]", + "name": "_allowedPoolParams", + "type": "bytes[][]" + }, + { + "internalType": "uint128[]", + "name": "_minPoolLiquidity", + "type": "uint128[]" + }, + { + "internalType": "uint128[]", + "name": "_minPositionLiquidity", + "type": "uint128[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "components": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint32", + "name": "window", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "minCardinality", + "type": "uint16" + } + ], + "internalType": "struct TwapConfig", + "name": "config", + "type": "tuple" + } + ], + "internalType": "struct TwapSeed[]", + "name": "_twapSeeds", + "type": "tuple[]" + }, + { + "internalType": "address", + "name": "_treasury", + "type": "address" + }, + { + "internalType": "uint16", + "name": "_performanceFeeBps", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "_feeCollectBps", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "_maxFeeBps", + "type": "uint16" + }, + { + "internalType": "address", + "name": "_initialAdmin", + "type": "address" + }, + { + "internalType": "address", + "name": "_timelock", + "type": "address" + }, + { + "internalType": "address", + "name": "_pauser", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "DeadlineExpired", + "type": "error" + }, + { + "inputs": [], + "name": "EnforcedPause", + "type": "error" + }, + { + "inputs": [], + "name": "ExpectedPause", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "FeeAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "HandlerCallFailed", + "type": "error" + }, + { + "inputs": [], + "name": "HandlerNotSet", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "expected", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "actual", + "type": "uint8" + } + ], + "name": "HandlerProtocolMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidExitBps", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidHandler", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSwapAmountOutMin", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimelock", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTreasury", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + } + ], + "name": "InvalidTwapReferencePool", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidUsdcAmount", + "type": "error" + }, + { + "inputs": [], + "name": "LengthMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "LpNotOnSafe", + "type": "error" + }, + { + "inputs": [], + "name": "MinUsdcOutNotMet", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "step", + "type": "uint8" + } + ], + "name": "ModuleCallFailed", + "type": "error" + }, + { + "inputs": [], + "name": "NotAuthorized", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyTimelock", + "type": "error" + }, + { + "inputs": [], + "name": "PoolDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "PoolNotInitialized", + "type": "error" + }, + { + "inputs": [], + "name": "PoolParamNotAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "PoolTooThin", + "type": "error" + }, + { + "inputs": [], + "name": "PositionLiquidityTooLow", + "type": "error" + }, + { + "inputs": [], + "name": "ProtocolDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "bits", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SafeCastOverflowedUintDowncast", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "SlippageAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "SlippageTooLow", + "type": "error" + }, + { + "inputs": [], + "name": "SwapFailed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TokenNotWhitelisted", + "type": "error" + }, + { + "inputs": [], + "name": "TwapCardinalityBelowFloor", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint16", + "name": "have", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "need", + "type": "uint16" + } + ], + "name": "TwapCardinalityTooLow", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TwapNotConfigured", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint32", + "name": "age", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "maxAge", + "type": "uint32" + } + ], + "name": "TwapObservationStale", + "type": "error" + }, + { + "inputs": [], + "name": "TwapPoolPairMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TwapReferenceRemovalNotAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "TwapWindowTooShort", + "type": "error" + }, + { + "inputs": [], + "name": "TwapWindowZero", + "type": "error" + }, + { + "inputs": [], + "name": "UnknownPosition", + "type": "error" + }, + { + "inputs": [], + "name": "WrongTokenPair", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "attemptedFee", + "type": "uint256" + } + ], + "name": "CollectFeeTransferFailed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "previousFeeCollectBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newFeeCollectBps", + "type": "uint16" + } + ], + "name": "FeeCollectBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "feeUsd6", + "type": "uint128" + } + ], + "name": "FeeTransferFailed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "token0", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "collected0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "token1", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "collected1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee1", + "type": "uint256" + } + ], + "name": "FeesCollected", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "previousMaxSlippageBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newMaxSlippageBps", + "type": "uint16" + } + ], + "name": "MaxSlippageBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "previousValue", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "newValue", + "type": "uint128" + } + ], + "name": "MinPoolLiquidityUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "previousValue", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "newValue", + "type": "uint128" + } + ], + "name": "MinPositionLiquidityUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "NftRescued", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousPauser", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newPauser", + "type": "address" + } + ], + "name": "PauserUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "previousPerformanceFeeBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newPerformanceFeeBps", + "type": "uint16" + } + ], + "name": "PerformanceFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "bool", + "name": "previousAllowed", + "type": "bool" + }, + { + "indexed": false, + "internalType": "bool", + "name": "newAllowed", + "type": "bool" + } + ], + "name": "PoolParamAllowedUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "basisUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "currentValueUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "feeUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "exitBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "carryForExitUsd6", + "type": "uint128" + } + ], + "name": "PositionClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "usdcInput", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount0ToLp", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount1ToLp", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "currentValueUsd6", + "type": "uint128" + } + ], + "name": "PositionOpened", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "fromProtocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "toProtocol", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldTokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newTokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "carriedBasisUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "withdrawn0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "withdrawn1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "used0", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "used1", + "type": "uint128" + } + ], + "name": "PositionSwitched", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "releasedBasisUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "releasedCarryUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "name": "PositionWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "bool", + "name": "forOpen", + "type": "bool" + }, + { + "indexed": false, + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "ProtocolStatusChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "rewardToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "grossReward", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "feePaid", + "type": "uint256" + } + ], + "name": "StakedRewardCollected", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "newTokenId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "residual0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "residual1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "residualUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "newBasisUsd6", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "newCarryUsd6", + "type": "uint128" + } + ], + "name": "SwitchResidueSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "TokenRescued", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousTreasury", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newTreasury", + "type": "address" + } + ], + "name": "TreasuryUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "window", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "minCardinality", + "type": "uint16" + } + ], + "name": "TwapConfigUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "address", + "name": "oldHandler", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newHandler", + "type": "address" + } + ], + "name": "YieldHandlerUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_FEE_BPS", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_SETTABLE_SLIPPAGE_BPS", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_TWAP_CARDINALITY", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_TWAP_WINDOW", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "REGISTRY", + "outputs": [ + { + "internalType": "contract IProtocolRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "USDC", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WETH", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "allowedPoolParamAt", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + } + ], + "name": "allowedPoolParamCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "carryProfitUsd6Of", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint16", + "name": "exitBps", + "type": "uint16" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap0", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap1", + "type": "tuple" + }, + { + "internalType": "uint16", + "name": "slippageBps", + "type": "uint16" + }, + { + "internalType": "uint256", + "name": "decreaseAmount0Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decreaseAmount1Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minUsdcOut", + "type": "uint256" + } + ], + "internalType": "struct CloseLpParams", + "name": "params", + "type": "tuple" + } + ], + "name": "closeLp", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "swapFeesToUsdc", + "type": "bool" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap0", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap1", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "swapRewardToUsdc", + "type": "bool" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "rewardSwap", + "type": "tuple" + }, + { + "internalType": "uint16", + "name": "slippageBps", + "type": "uint16" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "internalType": "struct CollectLpParams", + "name": "params", + "type": "tuple" + } + ], + "name": "collectLp", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "feeCollectBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "name": "isPoolParamAllowed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxSlippageBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + } + ], + "name": "minPoolLiquidity", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + } + ], + "name": "minPositionLiquidity", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "internalType": "uint256", + "name": "usdcAmount", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "internalType": "uint256", + "name": "mintAmount0Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mintAmount1Min", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap0", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + } + ], + "internalType": "struct SwapLeg", + "name": "swap1", + "type": "tuple" + }, + { + "internalType": "uint16", + "name": "slippageBps", + "type": "uint16" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "lpPoolParam", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "stake", + "type": "bool" + } + ], + "internalType": "struct OpenLpParams", + "name": "params", + "type": "tuple" + } + ], + "name": "openLp", + "outputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pauser", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "performanceFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "positionHandlerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "name": "protocolEnabledForClose", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "name": "protocolEnabledForOpen", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + } + ], + "name": "rescueERC721", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "rescueToken", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "residualBasisUsd6Of", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "newFeeCollectBps", + "type": "uint16" + } + ], + "name": "setFeeCollectBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "newMaxSlippageBps", + "type": "uint16" + } + ], + "name": "setMaxSlippageBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint128", + "name": "newValue", + "type": "uint128" + } + ], + "name": "setMinPoolLiquidity", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint128", + "name": "newValue", + "type": "uint128" + } + ], + "name": "setMinPositionLiquidity", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newPauser", + "type": "address" + } + ], + "name": "setPauser", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "newPerformanceFeeBps", + "type": "uint16" + } + ], + "name": "setPerformanceFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "poolParam", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setPoolParamAllowed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "setProtocolEnabledForClose", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "setProtocolEnabledForOpen", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newTreasury", + "type": "address" + } + ], + "name": "setTreasury", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint32", + "name": "window", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "minCardinality", + "type": "uint16" + } + ], + "name": "setTwapConfig", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "address", + "name": "handler", + "type": "address" + } + ], + "name": "setYieldHandler", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "stakePoolOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "fromProtocol", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "toProtocol", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decreaseAmount0Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decreaseAmount1Min", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "internalType": "uint256", + "name": "mintAmount0Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mintAmount1Min", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "lpPoolParam", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "internalType": "struct SwitchLpParams", + "name": "params", + "type": "tuple" + } + ], + "name": "switchLp", + "outputs": [ + { + "internalType": "uint256", + "name": "newTokenId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "timelock", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "treasury", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "twapConfigOf", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint32", + "name": "window", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "minCardinality", + "type": "uint16" + } + ], + "internalType": "struct TwapConfig", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenIn", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenOut", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint16", + "name": "slippageBps", + "type": "uint16" + } + ], + "name": "twapMinimumOut", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenIn", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenOut", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + } + ], + "name": "twapQuote", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "protocol", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "onBehalfOf", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decreaseAmount0Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decreaseAmount1Min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "internalType": "struct WithdrawLpParams", + "name": "params", + "type": "tuple" + } + ], + "name": "withdrawLp", + "outputs": [ + { + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "name": "yieldHandlers", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/contractAddresses.ts b/contractAddresses.ts index 7908f87..fb4fc45 100644 --- a/contractAddresses.ts +++ b/contractAddresses.ts @@ -1,3 +1,5 @@ +import { AbiCoder } from "ethers"; + // Token addresses export const WETH_ADDRESS = "0x4200000000000000000000000000000000000006"; export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Circle @@ -26,6 +28,43 @@ export const UNISWAP_V3_FACTORY_ADDRESS = "0x33128a8fC17869897dcE68Ed026d694621f export const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; export const UNISWAP_V3_SWAP_ROUTER_ADDRESS = "0x2626664c2603336E57B271c5C0b26F421741e481"; +// Aerodrome Slipstream (CL) — the concentrated-liquidity product (NOT the V2 +// AMM `IRouter`). Used by AerodromeYieldHandler for WETH/USDC LPs. The +// canonical contracts and live WETH/USDC spacing-100 pool are also +// exercised by test/yield/safeYieldManagerAerodromeFork.ts on a pinned Base fork. +export const AERODROME_CL_FACTORY_ADDRESS = "0x5e7BB104d84c7CB9B682AaC2F3d509f5F406809A"; + +// Uniswap V3 price references for SafeYieldManager's swap floor (H-01). +// Chosen for observation history, not for where a swap executes: both carry +// observationCardinality 2000, while other Base pools on the same pairs sit at +// cardinality 1 and answer a 30-minute query with nothing but live spot. +export const TWAP_REF_WETH_USDC_POOL = "0xb4CB800910B228ED3d0834cF79D697127BBB00e5"; // 0.01% fee +export const TWAP_REF_AERO_USDC_POOL = "0xE5B5f522E98B5a2baAe212d4dA66b865B781DB97"; // 0.05% fee +export const TWAP_WINDOW = 1800; +export const TWAP_CARDINALITY = 60; + +export const AERODROME_SLIPSTREAM_NPM_ADDRESS = "0x827922686190790b37229fd06084350E74485b72"; +export const AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS = "0xBE6D8f0d05cC4be24d5167a3eF062215bE6D18a5"; +// Aerodrome Voter — canonical pool -> stake pool registry. AerodromeYieldHandler +// resolves the stakePool to stake into (open) / withdraw from (close) via +// `VOTER.gauges(pool)` (verified: gauges(WETH/USDC spacing-100 pool) is live). +export const AERODROME_VOTER_ADDRESS = "0x16613524e02ad97eDfeF371bC883F2F5d6C480A5"; + +// Uniswap V4 — singleton architecture: pools live inside the PoolManager and +// are identified by PoolId = keccak256(abi.encode(PoolKey)), not by a pool +// address. Reads go through StateView; LP through the V4 PositionManager +// (ERC-721, pulls ERC20s via Permit2); swaps through the UniversalRouter. +// Native ETH pools use currency0 == address(0). Addresses verified against +// the official Uniswap deployments page and live on-chain reads. +export const UNISWAP_V4_POOL_MANAGER_ADDRESS = "0x498581fF718922c3f8e6A244956aF099B2652b2b"; +export const UNISWAP_V4_POSITION_MANAGER_ADDRESS = "0x7C5f5A4bBd8fD63184577525326123B519429bDc"; +export const UNISWAP_V4_STATE_VIEW_ADDRESS = "0xA3c0c9b65baD0b08107Aa264b0f3dB444b867A71"; +export const UNISWAP_V4_QUOTER_ADDRESS = "0x0d5e0F971ED27FBfF6c2837bf31316121532048D"; +export const UNIVERSAL_ROUTER_ADDRESS = "0x6fF5693b99212Da76ad316178A184AB56D299b43"; +// NOTE: Base uses this Permit2 deployment (verified on-chain via +// PositionManager.permit2()), NOT the older 0x...72F4d96f8 address. +export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + // Paraswap export const PARASWAP_V6_CONTRACT_ADDRESS = "0x6a000f20005980200259b80c5102003040001068"; @@ -46,8 +85,8 @@ export const COMPTROLLER_ADDRESS = "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c"; 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). +// (e.g. 2_DeployYieldManager reads PROTOCOL_REGISTRY_ADDRESS to wire +// SafeYieldManager to the existing registry). // // AUTO-MANAGED: registry/core deploy scripts run scripts/syncRegistryAddress.js, // rewriting the address below to the configured @@ -55,8 +94,8 @@ export const ADMIN_ADDRESS = "0xc74fc973A0740Ca1ED6f8F31Ed56003A13D4F5F1"; // a registry deployed outside this repo's Ignition flow. export const PROTOCOL_REGISTRY_ADDRESS = "0x2f1331Df43E2f63e01298f570F9e467375077d7d"; -// Protocol enum -export enum Protocol { +// DebtProtocol enum +export enum DebtProtocol { AAVE_V3, COMPOUND, MORPHO, @@ -64,6 +103,40 @@ export enum Protocol { MOONWELL, } +// Mirrors the YIELD_PROTOCOL_* uint8 id constants in contracts/Types.sol (append-only) +export enum YieldProtocol { + UNISWAP_V3, + AERODROME, + UNISWAP_V4, +} + +// SafeYieldManager pool params carry the pair: abi.encode(token0, token1, key). +// These are THE encoding points — ignition, scripts, and tests must all agree +// with the handlers' _decodePoolParam, so never hand-roll the encode elsewhere. +export function encodeUniV3PoolParam(token0: string, token1: string, feeTier: number | bigint): string { + return AbiCoder.defaultAbiCoder().encode(["address", "address", "uint24"], [token0, token1, feeTier]); +} + +export function encodeAerodromePoolParam(token0: string, token1: string, tickSpacing: number | bigint): string { + return AbiCoder.defaultAbiCoder().encode(["address", "address", "int24"], [token0, token1, tickSpacing]); +} + +// Uniswap V4 pool param is the full PoolKey tuple, so keccak256 of it IS the +// V4 PoolId. Native ETH pools use currency0 = ZeroAddress; hookless pools use +// hooks = ZeroAddress (hooked pools are gated by the admin allow-list only). +export function encodeUniV4PoolParam( + currency0: string, + currency1: string, + fee: number | bigint, + tickSpacing: number | bigint, + hooks: string, +): string { + return AbiCoder.defaultAbiCoder().encode( + ["address", "address", "uint24", "int24", "address"], + [currency0, currency1, fee, tickSpacing, hooks], + ); +} + export const USDC_COMET_ADDRESS = "0xb125E6687d4313864e53df431d5425969c15Eb2F"; export const USDbC_COMET_ADDRESS = "0x9c4ec768c28520B50860ea7a15bd7213a9fF58bf"; export const WETH_COMET_ADDRESS = "0x46e6b214b524310239732D51387075E0e70970bf"; @@ -97,7 +170,6 @@ export function getCTokenMappingArrays(): [string[], string[]] { // https://docs.moonwell.fi/moonwell/protocol-information/contracts#token-contract-addresses export const mDAI = "0x73b06d8d18de422e269645eace15400de7462417"; export const mUSDC = "0xedc817a28e8b93b03976fbd4a3ddbc9f7d176c22"; -export const mUSDbC = "0x703843C3379b52F9FF486c9f5892218d2a065cC8"; export const mWETH = "0x628ff693426583D9a7FB391E54366292F509D457"; export const mcbETH = "0x3bf93770f2d4a794c3d9ebefbaebae2a8f09a5e5"; export const mwstETH = "0x627Fe393Bc6EdDA28e99AE648fD6fF362514304b"; diff --git a/contracts/Types.sol b/contracts/Types.sol deleted file mode 100644 index 158e54e..0000000 --- a/contracts/Types.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.28; - -// Role required for critical operations like updating protocol handlers -bytes32 constant CRITICAL_ROLE = keccak256("CRITICAL_ROLE"); - -enum Protocol { - AAVE_V3, - COMPOUND, - MORPHO, - FLUID, - MOONWELL -} - -struct CollateralAsset { - address asset; - uint256 amount; -} - -struct ParaswapParams { - uint256 srcAmount; - bytes swapData; -} diff --git a/contracts/Imports.sol b/contracts/common/Imports.sol similarity index 100% rename from contracts/Imports.sol rename to contracts/common/Imports.sol diff --git a/contracts/ProtocolRegistry.sol b/contracts/common/ProtocolRegistry.sol similarity index 100% rename from contracts/ProtocolRegistry.sol rename to contracts/common/ProtocolRegistry.sol diff --git a/contracts/common/Types.sol b/contracts/common/Types.sol new file mode 100644 index 0000000..56afe12 --- /dev/null +++ b/contracts/common/Types.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +// Role required for critical operations like updating protocol handlers +bytes32 constant CRITICAL_ROLE = keccak256("CRITICAL_ROLE"); + +enum DebtProtocol { + AAVE_V3, + COMPOUND, + MORPHO, + FLUID, + MOONWELL +} + +// Canonical yield (LP) protocol ids. SafeYieldManager and the yield +// handlers key everything by plain uint8 so NEW protocols can be +// registered on the deployed manager via `setYieldHandler` without a +// redeploy. Append-only: new ids (e.g. UNISWAP_V4 = 2) must never reuse +// an existing value, so handler registrations and stored basis keys +// keep their meaning. Mirrored by the YieldProtocol enum in +// contractAddresses.ts for off-chain consumers. +uint8 constant YIELD_PROTOCOL_UNISWAP_V3 = 0; +uint8 constant YIELD_PROTOCOL_AERODROME = 1; +uint8 constant YIELD_PROTOCOL_UNISWAP_V4 = 2; + +struct CollateralAsset { + address asset; + uint256 amount; +} + +struct ParaswapParams { + uint256 srcAmount; + bytes swapData; +} + +/// @notice Reference price source for valuing one token against USDC. +/// @dev Deliberately decoupled from the pool a swap EXECUTES in: the +/// reference is chosen for depth and observation history, while +/// execution may route through a thinner pool or another protocol +/// entirely (Aerodrome, Uniswap V4). One trusted price per token, +/// regardless of venue. Packs into a single slot. +struct TwapConfig { + /// @dev Uniswap V3 pool trading {token, USDC}. Zero means unconfigured, + /// which is a hard failure rather than a fallback to spot. + address pool; + /// @dev Averaging window in seconds. + uint32 window; + /// @dev Required `observationCardinality`. A pool below this cannot + /// retain `window` of history; see TwapOracle for why a bare + /// `observe()` call is not enough to detect that. + uint16 minCardinality; +} + +/// @notice One constructor-seeded price reference. Pairing the key with its +/// config in a single struct keeps them from ever being supplied at +/// different lengths, and keeps the manager's constructor one argument +/// under the stack limit that coverage instrumentation imposes (viaIR, +/// see .solcover.js — the same reason `MintArgs` and `SwapSteps` exist). +struct TwapSeed { + address token; + TwapConfig config; +} diff --git a/contracts/LeveragedPosition.sol b/contracts/debt/LeveragedPosition.sol similarity index 92% rename from contracts/LeveragedPosition.sol rename to contracts/debt/LeveragedPosition.sol index e77cd04..9feb865 100644 --- a/contracts/LeveragedPosition.sol +++ b/contracts/debt/LeveragedPosition.sol @@ -1,18 +1,18 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "./dependencies/uniswapV3/CallbackValidation.sol"; -import {PoolAddress} from "./dependencies/uniswapV3/PoolAddress.sol"; +import "../dependencies/uniswapV3/CallbackValidation.sol"; +import {PoolAddress} from "../dependencies/uniswapV3/PoolAddress.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import {IProtocolHandler} from "./interfaces/IProtocolHandler.sol"; +import {IDebtHandler} from "../interfaces/IDebtHandler.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/Pausable.sol"; -import "./Types.sol"; -import "./interfaces/safe/ISafe.sol"; -import "./ProtocolRegistry.sol"; +import "../common/Types.sol"; +import "../interfaces/safe/ISafe.sol"; +import "../common/ProtocolRegistry.sol"; /// @title LeveragedPosition /// @notice Creates and manages leveraged positions on DeFi lending protocols @@ -24,7 +24,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { uint8 public protocolFee; address public feeBeneficiary; ProtocolRegistry public immutable registry; - mapping(Protocol => address) public protocolHandlers; + mapping(DebtProtocol => address) public protocolHandlers; address public pauser; error InsufficientTokenBalanceAfterSwap(uint256 expected, uint256 actual); @@ -49,11 +49,6 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { _; } - modifier onlyCriticalRole() { - require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); - _; - } - modifier onlyTimelockCriticalRole() { if (msg.sender != registry.timelock()) revert OnlyTimelock(); require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); @@ -61,8 +56,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { } struct CreateCallbackData { - address flashloanPool; - Protocol protocol; + DebtProtocol protocol; address collateralAsset; address debtAsset; uint256 principleCollateralAmount; @@ -73,8 +67,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { } struct CloseCallbackData { - address flashloanPool; - Protocol protocol; + DebtProtocol protocol; address collateralAsset; address debtAsset; uint256 debtAmount; @@ -86,7 +79,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { event LeveragedPositionCreated( address indexed onBehalfOf, - Protocol protocol, + DebtProtocol protocol, address collateralAsset, uint256 principleCollateralAmount, uint256 targetCollateralAmount, @@ -95,7 +88,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { event LeveragedPositionClosed( address indexed onBehalfOf, - Protocol protocol, + DebtProtocol protocol, address collateralAsset, uint256 collateralAmount, address debtAsset, @@ -111,11 +104,11 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { event EmergencyETHWithdrawn(uint256 amount, address indexed to); - event ProtocolHandlerUpdated(Protocol indexed protocol, address indexed oldHandler, address indexed newHandler); + event ProtocolHandlerUpdated(DebtProtocol indexed protocol, address indexed oldHandler, address indexed newHandler); constructor( address _registry, - Protocol[] memory protocols, + DebtProtocol[] memory protocols, address[] memory handlers, address _pauser ) Ownable(msg.sender) { @@ -157,7 +150,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { /// @param _handler The new handler address /// @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 onlyTimelockCriticalRole { + function setProtocolHandler(DebtProtocol _protocol, address _handler) external onlyTimelockCriticalRole { require(_handler != address(0), "Invalid handler address"); address oldHandler = protocolHandlers[_protocol]; protocolHandlers[_protocol] = _handler; @@ -177,7 +170,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { /// @dev Flash loans collateral difference, supplies to protocol, borrows debt, swaps back to repay flash loan function createLeveragedPosition( address _flashloanPool, - Protocol _protocol, + DebtProtocol _protocol, address _collateralAsset, uint256 _principleCollateralAmount, uint256 _targetCollateralAmount, @@ -208,7 +201,6 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { bytes memory data = abi.encode( OperationType.Create, CreateCallbackData({ - flashloanPool: _flashloanPool, protocol: _protocol, collateralAsset: _collateralAsset, debtAsset: _debtAsset, @@ -236,7 +228,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { /// @dev Flash loans debt amount, repays debt, withdraws collateral, swaps to repay flash loan function deleveragePosition( address _flashloanPool, - Protocol _protocol, + DebtProtocol _protocol, address _collateralAsset, uint256 _collateralAmount, address _debtAsset, @@ -269,7 +261,6 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { bytes memory data = abi.encode( OperationType.Close, CloseCallbackData({ - flashloanPool: _flashloanPool, protocol: _protocol, collateralAsset: _collateralAsset, debtAsset: _debtAsset, @@ -327,17 +318,14 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { (bool successSupply, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.supply, + IDebtHandler.supply, (decoded.collateralAsset, decoded.targetCollateralAmount, decoded.onBehalfOf, decoded.extraData) ) ); require(successSupply, "Supply failed"); (bool successBorrow, ) = handler.delegatecall( - abi.encodeCall( - IProtocolHandler.borrow, - (decoded.debtAsset, amountInMax, decoded.onBehalfOf, decoded.extraData) - ) + abi.encodeCall(IDebtHandler.borrow, (decoded.debtAsset, amountInMax, decoded.onBehalfOf, decoded.extraData)) ); require(successBorrow, "Borrow failed"); @@ -369,13 +357,20 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { if (remainingBalance > 0) { (bool successRepay, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.repay, + IDebtHandler.repay, (decoded.debtAsset, remainingBalance, decoded.onBehalfOf, decoded.extraData) ) ); require(successRepay, "Repay remaining amount failed"); } + // Whatever the handler declined to repay (Aave's 1 wei floor, Fluid's + // minimum operate amount, Moonwell's cap at the debt) is the user's. + uint256 remainingDebtAsset = IERC20(decoded.debtAsset).balanceOf(address(this)); + if (remainingDebtAsset > 0) { + IERC20(decoded.debtAsset).safeTransfer(decoded.onBehalfOf, remainingDebtAsset); + } + emit LeveragedPositionCreated( decoded.onBehalfOf, decoded.protocol, @@ -393,7 +388,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { // Flash loan borrowed the full debt amount - repay the debt (bool successRepay, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.repay, + IDebtHandler.repay, (decoded.debtAsset, decoded.debtAmount, decoded.onBehalfOf, decoded.extraData) ) ); @@ -401,7 +396,7 @@ contract LeveragedPosition is Ownable, ReentrancyGuard, Pausable { (bool successWithdraw, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.withdraw, + IDebtHandler.withdraw, (decoded.collateralAsset, decoded.collateralAmount, decoded.onBehalfOf, decoded.extraData) ) ); diff --git a/contracts/SafeDebtManager.sol b/contracts/debt/SafeDebtManager.sol similarity index 90% rename from contracts/SafeDebtManager.sol rename to contracts/debt/SafeDebtManager.sol index 8ad0303..334fc40 100644 --- a/contracts/SafeDebtManager.sol +++ b/contracts/debt/SafeDebtManager.sol @@ -1,16 +1,16 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "./dependencies/uniswapV3/CallbackValidation.sol"; +import "../dependencies/uniswapV3/CallbackValidation.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {PoolAddress} from "./dependencies/uniswapV3/PoolAddress.sol"; +import {PoolAddress} from "../dependencies/uniswapV3/PoolAddress.sol"; import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import "./Types.sol"; -import "./interfaces/safe/ISafe.sol"; -import {IProtocolHandler} from "./interfaces/IProtocolHandler.sol"; -import "./ProtocolRegistry.sol"; +import "../common/Types.sol"; +import "../interfaces/safe/ISafe.sol"; +import {IDebtHandler} from "../interfaces/IDebtHandler.sol"; +import "../common/ProtocolRegistry.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Pausable.sol"; @@ -26,16 +26,16 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { address public feeBeneficiary; address public pauser; ProtocolRegistry public immutable registry; - mapping(Protocol => address) public protocolHandlers; - mapping(Protocol => bool) public protocolEnabledForSwitchFrom; - mapping(Protocol => bool) public protocolEnabledForSwitchTo; + mapping(DebtProtocol => address) public protocolHandlers; + mapping(DebtProtocol => bool) public protocolEnabledForSwitchFrom; + mapping(DebtProtocol => bool) public protocolEnabledForSwitchTo; error InsufficientTokenBalanceAfterSwap(uint256 expected, uint256 actual); error OnlyTimelock(); struct FlashCallbackData { - Protocol fromProtocol; - Protocol toProtocol; + DebtProtocol fromProtocol; + DebtProtocol toProtocol; address fromAsset; address toAsset; uint256 amount; @@ -48,8 +48,8 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { event DebtSwapped( address indexed onBehalfOf, - Protocol fromProtocol, - Protocol toProtocol, + DebtProtocol fromProtocol, + DebtProtocol toProtocol, address fromAsset, address toAsset, uint256 amount, @@ -58,7 +58,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { event DebtPositionExited( address indexed onBehalfOf, - Protocol protocol, + DebtProtocol protocol, address debtAsset, uint256 debtAmount, CollateralAsset[] collateralAssets @@ -68,13 +68,13 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { event ProtocolFeeSet(uint8 oldFee, uint8 newFee); - event ProtocolStatusChanged(Protocol indexed protocol, string operationType, bool enabled); + event ProtocolStatusChanged(DebtProtocol indexed protocol, string operationType, bool enabled); event EmergencyWithdrawn(address indexed token, uint256 amount, address indexed to); event EmergencyETHWithdrawn(uint256 amount, address indexed to); - event ProtocolHandlerUpdated(Protocol indexed protocol, address indexed oldHandler, address indexed newHandler); + event ProtocolHandlerUpdated(DebtProtocol indexed protocol, address indexed oldHandler, address indexed newHandler); modifier onlyOwnerOrOperator(address onBehalfOf) { require(onBehalfOf != address(0), "onBehalfOf cannot be zero address"); @@ -89,11 +89,6 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { _; } - modifier onlyCriticalRole() { - require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); - _; - } - modifier onlyTimelockCriticalRole() { if (msg.sender != registry.timelock()) revert OnlyTimelock(); require(registry.hasRole(CRITICAL_ROLE, msg.sender), "Caller does not have CRITICAL_ROLE"); @@ -102,7 +97,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { constructor( address _registry, - Protocol[] memory protocols, + DebtProtocol[] memory protocols, address[] memory handlers, address _pauser ) Ownable(msg.sender) { @@ -144,7 +139,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { /// @param _protocol The protocol to enable/disable /// @param _enabled True to enable, false to disable /// @dev Only callable by the pauser. Used for emergency protocol disabling. - function setProtocolEnabledForSwitchFrom(Protocol _protocol, bool _enabled) external onlyPauser { + function setProtocolEnabledForSwitchFrom(DebtProtocol _protocol, bool _enabled) external onlyPauser { require(protocolHandlers[_protocol] != address(0), "Protocol handler not set"); protocolEnabledForSwitchFrom[_protocol] = _enabled; emit ProtocolStatusChanged(_protocol, "switchFrom", _enabled); @@ -154,7 +149,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { /// @param _protocol The protocol to enable/disable /// @param _enabled True to enable, false to disable /// @dev Only callable by the pauser. Used for emergency protocol disabling. - function setProtocolEnabledForSwitchTo(Protocol _protocol, bool _enabled) external onlyPauser { + function setProtocolEnabledForSwitchTo(DebtProtocol _protocol, bool _enabled) external onlyPauser { require(protocolHandlers[_protocol] != address(0), "Protocol handler not set"); protocolEnabledForSwitchTo[_protocol] = _enabled; emit ProtocolStatusChanged(_protocol, "switchTo", _enabled); @@ -165,7 +160,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { /// @param _handler The new handler address /// @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 onlyTimelockCriticalRole { + function setProtocolHandler(DebtProtocol _protocol, address _handler) external onlyTimelockCriticalRole { require(_handler != address(0), "Invalid handler address"); address oldHandler = protocolHandlers[_protocol]; protocolHandlers[_protocol] = _handler; @@ -186,8 +181,8 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { /// @dev Uses Uniswap V3 flash loan to atomically repay source debt and borrow on destination function executeDebtSwap( address _flashloanPool, - Protocol _fromProtocol, - Protocol _toProtocol, + DebtProtocol _fromProtocol, + DebtProtocol _toProtocol, address _fromDebtAsset, address _toDebtAsset, uint256 _amount, @@ -222,7 +217,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { } if (_amount == type(uint256).max) { - debtAmount = IProtocolHandler(fromHandler).getDebtAmount(_fromDebtAsset, _onBehalfOf, _extraData[0]); + debtAmount = IDebtHandler(fromHandler).getDebtAmount(_fromDebtAsset, _onBehalfOf, _extraData[0]); require(debtAmount >= 10000, "Debt amount below minimum threshold"); } @@ -305,7 +300,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { if (decoded.fromProtocol == decoded.toProtocol) { (bool success, ) = fromHandler.delegatecall( abi.encodeCall( - IProtocolHandler.switchIn, + IDebtHandler.switchIn, ( decoded.fromAsset, decoded.toAsset, @@ -322,7 +317,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { } else { (bool successFrom, ) = fromHandler.delegatecall( abi.encodeCall( - IProtocolHandler.switchFrom, + IDebtHandler.switchFrom, (decoded.fromAsset, decoded.amount, safe, decoded.collateralAssets, decoded.fromExtraData) ) ); @@ -330,7 +325,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { (bool successTo, ) = toHandler.delegatecall( abi.encodeCall( - IProtocolHandler.switchTo, + IDebtHandler.switchTo, (decoded.toAsset, amountTotal, safe, decoded.collateralAssets, decoded.toExtraData) ) ); @@ -364,12 +359,20 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { if (remainingBalance > 0) { (bool success, ) = toHandler.delegatecall( - abi.encodeCall(IProtocolHandler.repay, (decoded.toAsset, remainingBalance, safe, decoded.toExtraData)) + abi.encodeCall(IDebtHandler.repay, (decoded.toAsset, remainingBalance, safe, decoded.toExtraData)) ); require(success, "Repay remainingBalance failed"); } + // A handler may decline part of the repayment: Aave skips 1 wei, Fluid + // skips below its minimum operate amount, Moonwell caps at the debt. + // Whatever it left belongs to the user, not to the next operation. + uint256 toTokenRemainingBalance = toToken.balanceOf(address(this)); + if (toTokenRemainingBalance > 0) { + toToken.safeTransfer(decoded.onBehalfOf, toTokenRemainingBalance); + } + // send dust amount back to user if it exists uint256 fromTokenRemainingBalance = IERC20(decoded.fromAsset).balanceOf(address(this)); if (fromTokenRemainingBalance > 0) { @@ -421,7 +424,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { * @param _withdrawCollateral Whether to withdraw collateral assets after repaying debt */ function exit( - Protocol _protocol, + DebtProtocol _protocol, address _debtAsset, uint256 _debtAmount, CollateralAsset[] calldata _collateralAssets, @@ -447,7 +450,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { // Determine repay amount - if max, get actual debt from handler uint256 repayAmount = _debtAmount; if (_debtAmount == type(uint256).max) { - uint256 debtAmount = IProtocolHandler(handler).getDebtAmount(_debtAsset, _onBehalfOf, _extraData); + uint256 debtAmount = IDebtHandler(handler).getDebtAmount(_debtAsset, _onBehalfOf, _extraData); uint256 safeBalance = IERC20(_debtAsset).balanceOf(_onBehalfOf); require(safeBalance >= debtAmount, "Insufficient balance"); repayAmount = debtAmount; @@ -462,7 +465,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(IDebtHandler.repay, (_debtAsset, repayAmount, _onBehalfOf, _extraData)) ); require(repaySuccess, "Repay failed"); @@ -480,7 +483,7 @@ contract SafeDebtManager is Ownable, ReentrancyGuard, Pausable { (bool withdrawSuccess, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.withdraw, + IDebtHandler.withdraw, (_collateralAssets[i].asset, _collateralAssets[i].amount, _onBehalfOf, _extraData) ) ); diff --git a/contracts/SafeExecTransactionWrapper.sol b/contracts/debt/SafeExecTransactionWrapper.sol similarity index 98% rename from contracts/SafeExecTransactionWrapper.sol rename to contracts/debt/SafeExecTransactionWrapper.sol index 6d53fb8..1271381 100644 --- a/contracts/SafeExecTransactionWrapper.sol +++ b/contracts/debt/SafeExecTransactionWrapper.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity =0.8.28; -import "./interfaces/safe/ISafe.sol"; +import "../interfaces/safe/ISafe.sol"; /** * @title SafeExecTransactionWrapper diff --git a/contracts/protocols/AaveV3Handler.sol b/contracts/debt/handlers/AaveV3DebtHandler.sol similarity index 94% rename from contracts/protocols/AaveV3Handler.sol rename to contracts/debt/handlers/AaveV3DebtHandler.sol index 119e806..174f3f6 100644 --- a/contracts/protocols/AaveV3Handler.sol +++ b/contracts/debt/handlers/AaveV3DebtHandler.sol @@ -2,17 +2,17 @@ pragma solidity ^0.8.28; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IPoolV3} from "../interfaces/aaveV3/IPoolV3.sol"; +import {IPoolV3} from "../../interfaces/aaveV3/IPoolV3.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {DataTypes} from "../interfaces/aaveV3/DataTypes.sol"; -import {IAaveProtocolDataProvider} from "../interfaces/aaveV3/IAaveProtocolDataProvider.sol"; -import {IAaveOracle} from "../interfaces/aaveV3/IAaveOracle.sol"; -import {IPoolAddressesProvider} from "../interfaces/aaveV3/IPoolAddressesProvider.sol"; -import "./BaseProtocolHandler.sol"; -import "../ProtocolRegistry.sol"; - -contract AaveV3Handler is BaseProtocolHandler { +import {DataTypes} from "../../interfaces/aaveV3/DataTypes.sol"; +import {IAaveProtocolDataProvider} from "../../interfaces/aaveV3/IAaveProtocolDataProvider.sol"; +import {IAaveOracle} from "../../interfaces/aaveV3/IAaveOracle.sol"; +import {IPoolAddressesProvider} from "../../interfaces/aaveV3/IPoolAddressesProvider.sol"; +import "./BaseDebtHandler.sol"; +import "../../common/ProtocolRegistry.sol"; + +contract AaveV3DebtHandler is BaseDebtHandler { using SafeERC20 for IERC20; IPoolV3 public immutable aaveV3Pool; @@ -23,7 +23,7 @@ contract AaveV3Handler is BaseProtocolHandler { address _AAVE_V3_DATA_PROVIDER_ADDRESS, address _UNISWAP_V3_FACTORY_ADDRESS, address _REGISTRY_ADDRESS - ) BaseProtocolHandler(_UNISWAP_V3_FACTORY_ADDRESS, _REGISTRY_ADDRESS) { + ) BaseDebtHandler(_UNISWAP_V3_FACTORY_ADDRESS, _REGISTRY_ADDRESS) { aaveV3Pool = IPoolV3(_AAVE_V3_POOL_ADDRESS); dataProvider = IAaveProtocolDataProvider(_AAVE_V3_DATA_PROVIDER_ADDRESS); } diff --git a/contracts/protocols/BaseProtocolHandler.sol b/contracts/debt/handlers/BaseDebtHandler.sol similarity index 91% rename from contracts/protocols/BaseProtocolHandler.sol rename to contracts/debt/handlers/BaseDebtHandler.sol index 738dee6..cb2ada3 100644 --- a/contracts/protocols/BaseProtocolHandler.sol +++ b/contracts/debt/handlers/BaseDebtHandler.sol @@ -1,18 +1,18 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "../interfaces/IProtocolHandler.sol"; -import {PoolAddress} from "../dependencies/uniswapV3/PoolAddress.sol"; -import "../dependencies/uniswapV3/CallbackValidation.sol"; +import "../../interfaces/IDebtHandler.sol"; +import {PoolAddress} from "../../dependencies/uniswapV3/PoolAddress.sol"; +import "../../dependencies/uniswapV3/CallbackValidation.sol"; import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import "../ProtocolRegistry.sol"; +import "../../common/ProtocolRegistry.sol"; /** - * @title BaseProtocolHandler + * @title BaseDebtHandler * @dev Base abstract contract for protocol handlers with Uniswap V3 pool validation * @notice This contract provides common functionality and security modifiers for all protocol handlers */ -abstract contract BaseProtocolHandler is IProtocolHandler { +abstract contract BaseDebtHandler is IDebtHandler { /// @notice The Uniswap V3 factory address used for pool validation address public immutable uniswapV3Factory; diff --git a/contracts/protocols/CompoundHandler.sol b/contracts/debt/handlers/CompoundDebtHandler.sol similarity index 96% rename from contracts/protocols/CompoundHandler.sol rename to contracts/debt/handlers/CompoundDebtHandler.sol index f82cac9..b6a5f2a 100644 --- a/contracts/protocols/CompoundHandler.sol +++ b/contracts/debt/handlers/CompoundDebtHandler.sol @@ -1,17 +1,16 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import {IComet, AssetInfo} from "../interfaces/compound/IComet.sol"; +import {IComet, AssetInfo} from "../../interfaces/compound/IComet.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ProtocolRegistry} from "../ProtocolRegistry.sol"; -import {CollateralAsset} from "../Types.sol"; -import "./BaseProtocolHandler.sol"; +import {CollateralAsset} from "../../common/Types.sol"; +import "./BaseDebtHandler.sol"; -contract CompoundHandler is BaseProtocolHandler { +contract CompoundDebtHandler is BaseDebtHandler { using SafeERC20 for IERC20; - constructor(address _registry, address _uniswapV3Factory) BaseProtocolHandler(_uniswapV3Factory, _registry) {} + constructor(address _registry, address _uniswapV3Factory) BaseDebtHandler(_uniswapV3Factory, _registry) {} function getCContract(address token) internal view returns (address) { return registry.getCContract(token); diff --git a/contracts/protocolsSafe/FluidSafeHandler.sol b/contracts/debt/handlers/FluidSafeDebtHandler.sol similarity index 96% rename from contracts/protocolsSafe/FluidSafeHandler.sol rename to contracts/debt/handlers/FluidSafeDebtHandler.sol index 5cc03a9..03fd5d8 100644 --- a/contracts/protocolsSafe/FluidSafeHandler.sol +++ b/contracts/debt/handlers/FluidSafeDebtHandler.sol @@ -1,21 +1,19 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "../interfaces/safe/ISafe.sol"; -import "../Types.sol"; +import "../../interfaces/safe/ISafe.sol"; +import "../../common/Types.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IPoolV3} from "../interfaces/aaveV3/IPoolV3.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {DataTypes} from "../interfaces/aaveV3/DataTypes.sol"; -import "../interfaces/fluid/IFluidVault.sol"; -import "../interfaces/fluid/IFluidVaultResolver.sol"; -import "../interfaces/IProtocolHandler.sol"; -import {Structs} from "../dependencies/fluid/structs.sol"; -import "../protocols/BaseProtocolHandler.sol"; -import "../ProtocolRegistry.sol"; -import "../interfaces/IWETH9.sol"; - -contract FluidSafeHandler is BaseProtocolHandler { +import "../../interfaces/fluid/IFluidVault.sol"; +import "../../interfaces/fluid/IFluidVaultResolver.sol"; +import "../../interfaces/IDebtHandler.sol"; +import {Structs} from "../../dependencies/fluid/structs.sol"; +import "./BaseDebtHandler.sol"; +import "../../common/ProtocolRegistry.sol"; +import "../../interfaces/IWETH9.sol"; + +contract FluidSafeDebtHandler is BaseDebtHandler { using SafeERC20 for IERC20; // Note: The registry contract holds configuration data including the Fluid vault resolver address and WETH address @@ -25,7 +23,7 @@ contract FluidSafeHandler is BaseProtocolHandler { constructor( address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS - ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) {} + ) BaseDebtHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) {} function getDebtAmount( address /* asset */, diff --git a/contracts/protocolsSafe/MoonwellHandler.sol b/contracts/debt/handlers/MoonwellDebtHandler.sol similarity index 97% rename from contracts/protocolsSafe/MoonwellHandler.sol rename to contracts/debt/handlers/MoonwellDebtHandler.sol index 162b64c..f543131 100644 --- a/contracts/protocolsSafe/MoonwellHandler.sol +++ b/contracts/debt/handlers/MoonwellDebtHandler.sol @@ -1,17 +1,16 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "../interfaces/safe/ISafe.sol"; -import "../interfaces/moonwell/IMToken.sol"; -import {IComptroller, IMoonwellOracle} from "../interfaces/moonwell/Comptroller.sol"; +import "../../interfaces/safe/ISafe.sol"; +import "../../interfaces/moonwell/IMToken.sol"; +import {IComptroller, IMoonwellOracle} from "../../interfaces/moonwell/Comptroller.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "../Types.sol"; +import "../../common/Types.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {ProtocolRegistry} from "../ProtocolRegistry.sol"; -import "../protocols/BaseProtocolHandler.sol"; -import "../interfaces/IWETH9.sol"; +import "./BaseDebtHandler.sol"; +import "../../interfaces/IWETH9.sol"; -contract MoonwellHandler is BaseProtocolHandler { +contract MoonwellDebtHandler is BaseDebtHandler { using SafeERC20 for IERC20; address public immutable COMPTROLLER; @@ -20,7 +19,7 @@ contract MoonwellHandler is BaseProtocolHandler { address _comptroller, address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS - ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { + ) BaseDebtHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { COMPTROLLER = _comptroller; } diff --git a/contracts/protocols/MorphoHandler.sol b/contracts/debt/handlers/MorphoDebtHandler.sol similarity index 94% rename from contracts/protocols/MorphoHandler.sol rename to contracts/debt/handlers/MorphoDebtHandler.sol index cbfd956..09a2e18 100644 --- a/contracts/protocols/MorphoHandler.sol +++ b/contracts/debt/handlers/MorphoDebtHandler.sol @@ -3,15 +3,14 @@ pragma solidity ^0.8.28; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {DataTypes} from "../interfaces/aaveV3/DataTypes.sol"; -import "../interfaces/morpho/IMorpho.sol"; -import {IMorphoOracle} from "../interfaces/morpho/IMorphoOracle.sol"; -import {MarketParamsLib} from "../dependencies/morpho/MarketParamsLib.sol"; -import {SharesMathLib} from "../dependencies/morpho/SharesMathLib.sol"; -import "./BaseProtocolHandler.sol"; -import "../ProtocolRegistry.sol"; - -contract MorphoHandler is BaseProtocolHandler { +import "../../interfaces/morpho/IMorpho.sol"; +import {IMorphoOracle} from "../../interfaces/morpho/IMorphoOracle.sol"; +import {MarketParamsLib} from "../../dependencies/morpho/MarketParamsLib.sol"; +import {SharesMathLib} from "../../dependencies/morpho/SharesMathLib.sol"; +import "./BaseDebtHandler.sol"; +import "../../common/ProtocolRegistry.sol"; + +contract MorphoDebtHandler is BaseDebtHandler { using MarketParamsLib for MarketParams; using SafeERC20 for IERC20; using SharesMathLib for uint256; @@ -22,7 +21,7 @@ contract MorphoHandler is BaseProtocolHandler { address _MORPHO_ADDRESS, address _UNISWAP_V3_FACTORY, address _REGISTRY_ADDRESS - ) BaseProtocolHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { + ) BaseDebtHandler(_UNISWAP_V3_FACTORY, _REGISTRY_ADDRESS) { morpho = IMorpho(_MORPHO_ADDRESS); } diff --git a/contracts/dependencies/IERC20.sol b/contracts/dependencies/IERC20.sol deleted file mode 100644 index 321dc35..0000000 --- a/contracts/dependencies/IERC20.sol +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -/** - * @dev Interface of the ERC20 standard as defined in the EIP. - */ -interface IERC20 { - /** - * @dev Returns the amount of tokens in existence. - */ - function totalSupply() external view returns (uint256); - - /** - * @dev Returns the amount of tokens owned by `account`. - */ - function balanceOf(address account) external view returns (uint256); - - /** - * @dev Moves `amount` tokens from the caller's account to `recipient`. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * Emits a {Transfer} event. - */ - function transfer(address recipient, uint256 amount) external returns (bool); - - /** - * @dev Returns the remaining number of tokens that `spender` will be - * allowed to spend on behalf of `owner` through {transferFrom}. This is - * zero by default. - * - * This value changes when {approve} or {transferFrom} are called. - */ - function allowance(address owner, address spender) external view returns (uint256); - - /** - * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * IMPORTANT: Beware that changing an allowance with this method brings the risk - * that someone may use both the old and the new allowance by unfortunate - * transaction ordering. One possible solution to mitigate this race - * condition is to first reduce the spender's allowance to 0 and set the - * desired value afterwards: - * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 - * - * Emits an {Approval} event. - */ - function approve(address spender, uint256 amount) external returns (bool); - - /** - * @dev Moves `amount` tokens from `sender` to `recipient` using the - * allowance mechanism. `amount` is then deducted from the caller's - * allowance. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * Emits a {Transfer} event. - */ - function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); - - function decimals() external view returns (uint8); - - /** - * @dev Emitted when `value` tokens are moved from one account (`from`) to - * another (`to`). - * - * Note that `value` may be zero. - */ - event Transfer(address indexed from, address indexed to, uint256 value); - - /** - * @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); -} diff --git a/contracts/dependencies/TransferHelper.sol b/contracts/dependencies/TransferHelper.sol deleted file mode 100644 index 891c107..0000000 --- a/contracts/dependencies/TransferHelper.sol +++ /dev/null @@ -1,49 +0,0 @@ -// 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"; - -library TransferHelper { - /// @notice Transfers tokens from the targeted address to the given destination - /// @notice Errors with 'STF' if transfer fails - /// @param token The contract address of the token to be transferred - /// @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"); - } - - /// @notice Transfers tokens from msg.sender to a recipient - /// @dev Errors with ST if transfer fails - /// @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 { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); - } - - /// @notice Approves the stipulated contract to spend the given allowance in the given token - /// @dev Errors with 'SA' if transfer fails - /// @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 { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); - } - - /// @notice Transfers ETH to the recipient address - /// @dev Fails with `STE` - /// @param to The destination of the transfer - /// @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"); - } -} diff --git a/contracts/interfaces/IProtocolHandler.sol b/contracts/interfaces/IDebtHandler.sol similarity index 95% rename from contracts/interfaces/IProtocolHandler.sol rename to contracts/interfaces/IDebtHandler.sol index fa73a8d..56325ef 100644 --- a/contracts/interfaces/IProtocolHandler.sol +++ b/contracts/interfaces/IDebtHandler.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "../Types.sol"; +import "../common/Types.sol"; -interface IProtocolHandler { +interface IDebtHandler { function getDebtAmount(address asset, address onBehalfOf, bytes calldata extraData) external returns (uint256); function switchIn( diff --git a/contracts/interfaces/IProtocolRegistry.sol b/contracts/interfaces/IProtocolRegistry.sol index d98a671..145ca13 100644 --- a/contracts/interfaces/IProtocolRegistry.sol +++ b/contracts/interfaces/IProtocolRegistry.sol @@ -1,9 +1,11 @@ // 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(). +/// @notice Minimal interface exposing the ProtocolRegistry surface the yield +/// stack needs: the `safeOperator` getter used to authorize callers +/// and the token whitelist gating openLp pool tokens. interface IProtocolRegistry { function safeOperator() external view returns (address); + + function whitelistedTokens(address token) external view returns (bool); } diff --git a/contracts/interfaces/IYieldHandler.sol b/contracts/interfaces/IYieldHandler.sol new file mode 100644 index 0000000..1601021 --- /dev/null +++ b/contracts/interfaces/IYieldHandler.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @notice One USDC<->pool-token swap leg. `poolParam` selects the pool the +/// swap routes through (same ABI-encoded shape as LP pool params, so +/// it also pins the pair being traded). A leg whose pool token IS +/// USDC needs no swap and is ignored entirely — leave its fields +/// zero/empty. +/// @dev There is deliberately no caller-supplied "expected output" here. A +/// floor that a caller certifies against its own expectation is not a +/// floor: passing 1 and 1 satisfies any ratio between them. `amountOutMin` +/// is checked against a reference TWAP the contract reads itself — see +/// TwapOracle — so this struct carries only what the contract cannot know. +struct SwapLeg { + uint256 amountOutMin; + bytes poolParam; +} + +/// @notice Parameters for opening an LP position. Pool selection is carried +/// in `lpPoolParam` / `SwapLeg.poolParam` as ABI-encoded bytes so new +/// protocols with richer pool identifiers (e.g. Uniswap V4 `PoolKey`) +/// fit without changing this interface. The pair is part of the pool +/// identity: +/// - Uniswap V3: abi.encode(address token0, address token1, uint24 feeTier) +/// - Aerodrome: abi.encode(address token0, address token1, int24 tickSpacing) +/// Funding is always USDC: `usdcAmount` is split in half per side and +/// each non-USDC side is swapped through its leg's pool. +/// @dev `stake` is an opt-in: when true the freshly-minted NFT is +/// staked into the protocol's stakePool (Aerodrome only — handlers without +/// a stakePool revert `StakingNotSupported`). closeLp auto-unstakes. +struct OpenLpParams { + address onBehalfOf; + uint256 usdcAmount; + int24 tickLower; + int24 tickUpper; + uint256 mintAmount0Min; + uint256 mintAmount1Min; + /// @dev USDC -> token0 leg (ignored when token0 == USDC). + SwapLeg swap0; + /// @dev USDC -> token1 leg (ignored when token1 == USDC). + SwapLeg swap1; + uint16 slippageBps; + uint256 deadline; + bytes lpPoolParam; + bool stake; +} + +/// @notice Parameters for closing (partially or fully) an LP position. +/// Withdrawn non-USDC legs are swapped back to USDC. +struct CloseLpParams { + address onBehalfOf; + uint256 tokenId; + uint16 exitBps; + /// @dev token0 -> USDC leg (ignored when token0 == USDC). + SwapLeg swap0; + /// @dev token1 -> USDC leg (ignored when token1 == USDC). + SwapLeg swap1; + uint16 slippageBps; + uint256 decreaseAmount0Min; + uint256 decreaseAmount1Min; + uint256 deadline; + uint256 minUsdcOut; +} + +/// @notice Parameters for the withdraw leg of an in-kind switch: a full +/// decrease + collect + burn with NO swaps — the pool tokens land on +/// the Safe as-is. A handler whose pool side is native ETH wraps that +/// side to its ERC20, so callers always receive ERC20 addresses and +/// amounts. +struct WithdrawLpParams { + address onBehalfOf; + uint256 tokenId; + uint256 decreaseAmount0Min; + uint256 decreaseAmount1Min; + uint256 deadline; +} + +/// @notice Parameters for the open leg of an in-kind switch: mint straight +/// from the token amounts the withdraw leg delivered, with NO swaps. +/// `token0/token1` are the ERC20s as withdrawn; a handler whose pool +/// side is native ETH unwraps its side itself. The handler reverts +/// `WrongTokenPair` when the provided tokens do not match the +/// destination pool's pair. +struct OpenLpInKindParams { + address onBehalfOf; + address token0; + address token1; + uint256 amount0; + uint256 amount1; + int24 tickLower; + int24 tickUpper; + uint256 mintAmount0Min; + uint256 mintAmount1Min; + bytes lpPoolParam; + uint256 deadline; +} + +/// @notice Parameters for harvesting accrued LP fees without exiting. +struct CollectLpParams { + address onBehalfOf; + uint256 tokenId; + /// @dev Swap harvested non-USDC fees to USDC through the legs below. + bool swapFeesToUsdc; + SwapLeg swap0; + SwapLeg swap1; + /// @dev Swap the stakePool reward claimed for a STAKED position (e.g. AERO) + /// to USDC through `rewardSwap`. Ignored when the position is unstaked. + bool swapRewardToUsdc; + SwapLeg rewardSwap; + uint16 slippageBps; + uint256 deadline; +} + +/// @title IYieldHandler +/// @notice Coarse-grained adapter interface between SafeYieldManager and a +/// yield protocol. Handlers are STATELESS logic contracts executed +/// via delegatecall from the manager: they own the full +/// open/close/collect flow for one protocol and read shared mutable +/// config exclusively from the ERC-7201 `YieldStorage` namespace. +/// Position basis bookkeeping and the performance fee stay in the +/// manager, so handlers only report values back. +interface IYieldHandler { + /// @notice Protocol id implemented by this handler (canonical ids are + /// the YIELD_PROTOCOL_* constants in Types.sol). + /// SafeYieldManager checks this metadata before accepting a + /// handler registration. + function PROTOCOL() external view returns (uint8); + + /// @notice Decode the pair carried by an LP pool param. Used by + /// SafeYieldManager (via staticcall, not delegatecall) to gate + /// openLp on the registry token whitelist. For Uniswap V4 + /// `token0` may be address(0) — the native-ETH currency sentinel. + function poolTokens(bytes calldata lpPoolParam) external pure returns (address token0, address token1); + + /// @return tokenId Newly minted LP NFT id (owned by the Safe). + /// @return basisUsd6 USDC-equivalent value of the freshly minted LP. + /// @return used0 token0 consumed by the mint. + /// @return used1 token1 consumed by the mint. + function openLp( + OpenLpParams calldata params + ) external returns (uint256 tokenId, uint128 basisUsd6, uint128 used0, uint128 used1); + + /// @param basisForExit The manager-computed (exitBps-prorated) basis for + /// this close; used only for the zero-liquidity + /// rounding guard, never trusted from the caller. + /// @return currentValueUsd6 Gross realized USDC credited to the Safe. + function closeLp(CloseLpParams calldata params, uint128 basisForExit) external returns (uint128 currentValueUsd6); + + /// @notice In-kind close leg of a switch: full decrease + collect + burn, + /// NO swaps — both pool tokens land on the Safe (native ETH + /// wrapped to its ERC20). + /// @return token0 ERC20 address of the withdrawn token0 side. + /// @return token1 ERC20 address of the withdrawn token1 side. + /// @return amount0 token0 delivered to the Safe by this withdrawal. + /// @return amount1 token1 delivered to the Safe by this withdrawal. + function withdrawLp( + WithdrawLpParams calldata params + ) external returns (address token0, address token1, uint256 amount0, uint256 amount1); + + /// @notice In-kind open leg of a switch: mint from the provided token + /// amounts, NO swaps. Amounts the mint cannot consume stay on the + /// Safe. + /// @return tokenId Newly minted LP NFT id (owned by the Safe). + /// @return used0 token0 consumed by the mint. + /// @return used1 token1 consumed by the mint. + function openLpInKind( + OpenLpInKindParams calldata params + ) external returns (uint256 tokenId, uint128 used0, uint128 used1); + + function collectLp(CollectLpParams calldata params) external; +} diff --git a/contracts/interfaces/aaveV3/IDebtToken.sol b/contracts/interfaces/aaveV3/IDebtToken.sol deleted file mode 100644 index b07eebc..0000000 --- a/contracts/interfaces/aaveV3/IDebtToken.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity =0.8.28; - -interface IDebtToken { - function approveDelegation(address delegatee, uint256 amount) external; - function borrowAllowance(address fromUser, address toUser) external view returns (uint256); - function delegationWithSig(address, address, uint256, uint256, uint8, bytes32, bytes32) external; - function nonces(address) external view returns (uint256); - function name() external view returns (string memory); -} diff --git a/contracts/interfaces/aerodrome/ICLFactory.sol b/contracts/interfaces/aerodrome/ICLFactory.sol new file mode 100644 index 0000000..232d9e4 --- /dev/null +++ b/contracts/interfaces/aerodrome/ICLFactory.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/** + * @dev Simplified Aerodrome Slipstream `CLFactory` interface — only the + * pool-lookup surface this repo uses. + * + * The load-bearing difference from Uniswap V3's factory is the pool key: + * Slipstream pools are identified by `(token0, token1, int24 tickSpacing)` + * rather than `(token0, token1, uint24 fee)`. The swap fee is decoupled + * from the key and set per-pool by a fee module, so it is NOT part of the + * lookup. + * + * Canonical implementation: + * https://github.com/aerodrome-finance/slipstream/blob/main/contracts/core/CLFactory.sol + */ +interface ICLFactory { + /// @notice Returns the pool address for a token pair and tick spacing, 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 tickSpacing The tick spacing that keys the pool (e.g. 100, 200). + /// @return pool The pool address. + function getPool(address tokenA, address tokenB, int24 tickSpacing) external view returns (address pool); +} diff --git a/contracts/interfaces/aerodrome/ICLPool.sol b/contracts/interfaces/aerodrome/ICLPool.sol new file mode 100644 index 0000000..dedc005 --- /dev/null +++ b/contracts/interfaces/aerodrome/ICLPool.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/** + * @dev Simplified Aerodrome Slipstream `CLPool` interface — only the surface + * this repo uses. + * + * The load-bearing difference from `IUniswapV3Pool` is `slot0`: Slipstream + * drops the `feeProtocol` field, so the tuple is one element shorter. + * Decoding it with the Uniswap layout would mis-read `unlocked` as + * `feeProtocol` and revert / misbehave. + * + * Canonical implementation: + * https://github.com/aerodrome-finance/slipstream/blob/main/contracts/core/CLPool.sol + */ +interface ICLPool { + /// @notice The current price + tick of the pool, packed for gas efficiency. + /// @dev Note the absence of Uniswap V3's `feeProtocol` field. + /// @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 unlocked Whether the pool is currently locked to reentrancy. + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + bool unlocked + ); +} diff --git a/contracts/interfaces/aerodrome/IRouter.sol b/contracts/interfaces/aerodrome/IRouter.sol deleted file mode 100644 index 4b9ffc5..0000000 --- a/contracts/interfaces/aerodrome/IRouter.sol +++ /dev/null @@ -1,462 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {IWETH} from "./IWETH.sol"; - -interface IRouter { - struct Route { - address from; - address to; - bool stable; - address factory; - } - - error ETHTransferFailed(); - error Expired(); - error InsufficientAmount(); - error InsufficientAmountA(); - error InsufficientAmountB(); - error InsufficientAmountADesired(); - error InsufficientAmountBDesired(); - error InsufficientAmountAOptimal(); - error InsufficientLiquidity(); - error InsufficientOutputAmount(); - error InvalidAmountInForETHDeposit(); - error InvalidTokenInForETHDeposit(); - error InvalidPath(); - error InvalidRouteA(); - error InvalidRouteB(); - error OnlyWETH(); - error PoolDoesNotExist(); - error PoolFactoryDoesNotExist(); - error SameAddresses(); - error ZeroAddress(); - - /// @notice Address of FactoryRegistry.sol - function factoryRegistry() external view returns (address); - - /// @notice Address of Protocol PoolFactory.sol - function defaultFactory() external view returns (address); - - /// @notice Address of Voter.sol - function voter() external view returns (address); - - /// @notice Interface of WETH contract used for WETH => ETH wrapping/unwrapping - function weth() external view returns (IWETH); - - /// @dev Represents Ether. Used by zapper to determine whether to return assets as ETH/WETH. - function ETHER() external view returns (address); - - /// @dev Struct containing information necessary to zap in and out of pools - /// @param tokenA . - /// @param tokenB . - /// @param stable Stable or volatile pool - /// @param factory factory of pool - /// @param amountOutMinA Minimum amount expected from swap leg of zap via routesA - /// @param amountOutMinB Minimum amount expected from swap leg of zap via routesB - /// @param amountAMin Minimum amount of tokenA expected from liquidity leg of zap - /// @param amountBMin Minimum amount of tokenB expected from liquidity leg of zap - struct Zap { - address tokenA; - address tokenB; - bool stable; - address factory; - uint256 amountOutMinA; - uint256 amountOutMinB; - uint256 amountAMin; - uint256 amountBMin; - } - - /// @notice Sort two tokens by which address value is less than the other - /// @param tokenA Address of token to sort - /// @param tokenB Address of token to sort - /// @return token0 Lower address value between tokenA and tokenB - /// @return token1 Higher address value between tokenA and tokenB - function sortTokens(address tokenA, address tokenB) external pure returns (address token0, address token1); - - /// @notice Calculate the address of a pool by its' factory. - /// Used by all Router functions containing a `Route[]` or `_factory` argument. - /// Reverts if _factory is not approved by the FactoryRegistry - /// @dev Returns a randomly generated address for a nonexistent pool - /// @param tokenA Address of token to query - /// @param tokenB Address of token to query - /// @param stable True if pool is stable, false if volatile - /// @param _factory Address of factory which created the pool - function poolFor( - address tokenA, - address tokenB, - bool stable, - address _factory - ) external view returns (address pool); - - /// @notice Fetch and sort the reserves for a pool - /// @param tokenA . - /// @param tokenB . - /// @param stable True if pool is stable, false if volatile - /// @param _factory Address of PoolFactory for tokenA and tokenB - /// @return reserveA Amount of reserves of the sorted token A - /// @return reserveB Amount of reserves of the sorted token B - function getReserves( - address tokenA, - address tokenB, - bool stable, - address _factory - ) external view returns (uint256 reserveA, uint256 reserveB); - - /// @notice Perform chained getAmountOut calculations on any number of pools - function getAmountsOut(uint256 amountIn, Route[] memory routes) external view returns (uint256[] memory amounts); - - // **** ADD LIQUIDITY **** - - /// @notice Quote the amount deposited into a Pool - /// @param tokenA . - /// @param tokenB . - /// @param stable True if pool is stable, false if volatile - /// @param _factory Address of PoolFactory for tokenA and tokenB - /// @param amountADesired Amount of tokenA desired to deposit - /// @param amountBDesired Amount of tokenB desired to deposit - /// @return amountA Amount of tokenA to actually deposit - /// @return amountB Amount of tokenB to actually deposit - /// @return liquidity Amount of liquidity token returned from deposit - function quoteAddLiquidity( - address tokenA, - address tokenB, - bool stable, - address _factory, - uint256 amountADesired, - uint256 amountBDesired - ) external view returns (uint256 amountA, uint256 amountB, uint256 liquidity); - - /// @notice Quote the amount of liquidity removed from a Pool - /// @param tokenA . - /// @param tokenB . - /// @param stable True if pool is stable, false if volatile - /// @param _factory Address of PoolFactory for tokenA and tokenB - /// @param liquidity Amount of liquidity to remove - /// @return amountA Amount of tokenA received - /// @return amountB Amount of tokenB received - function quoteRemoveLiquidity( - address tokenA, - address tokenB, - bool stable, - address _factory, - uint256 liquidity - ) external view returns (uint256 amountA, uint256 amountB); - - /// @notice Add liquidity of two tokens to a Pool - /// @param tokenA . - /// @param tokenB . - /// @param stable True if pool is stable, false if volatile - /// @param amountADesired Amount of tokenA desired to deposit - /// @param amountBDesired Amount of tokenB desired to deposit - /// @param amountAMin Minimum amount of tokenA to deposit - /// @param amountBMin Minimum amount of tokenB to deposit - /// @param to Recipient of liquidity token - /// @param deadline Deadline to receive liquidity - /// @return amountA Amount of tokenA to actually deposit - /// @return amountB Amount of tokenB to actually deposit - /// @return liquidity Amount of liquidity token returned from deposit - function addLiquidity( - address tokenA, - address tokenB, - bool stable, - uint256 amountADesired, - uint256 amountBDesired, - uint256 amountAMin, - uint256 amountBMin, - address to, - uint256 deadline - ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); - - /// @notice Add liquidity of a token and WETH (transferred as ETH) to a Pool - /// @param token . - /// @param stable True if pool is stable, false if volatile - /// @param amountTokenDesired Amount of token desired to deposit - /// @param amountTokenMin Minimum amount of token to deposit - /// @param amountETHMin Minimum amount of ETH to deposit - /// @param to Recipient of liquidity token - /// @param deadline Deadline to add liquidity - /// @return amountToken Amount of token to actually deposit - /// @return amountETH Amount of tokenETH to actually deposit - /// @return liquidity Amount of liquidity token returned from deposit - function addLiquidityETH( - address token, - bool stable, - uint256 amountTokenDesired, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline - ) external payable returns (uint256 amountToken, uint256 amountETH, uint256 liquidity); - - // **** REMOVE LIQUIDITY **** - - /// @notice Remove liquidity of two tokens from a Pool - /// @param tokenA . - /// @param tokenB . - /// @param stable True if pool is stable, false if volatile - /// @param liquidity Amount of liquidity to remove - /// @param amountAMin Minimum amount of tokenA to receive - /// @param amountBMin Minimum amount of tokenB to receive - /// @param to Recipient of tokens received - /// @param deadline Deadline to remove liquidity - /// @return amountA Amount of tokenA received - /// @return amountB Amount of tokenB received - function removeLiquidity( - address tokenA, - address tokenB, - bool stable, - uint256 liquidity, - uint256 amountAMin, - uint256 amountBMin, - address to, - uint256 deadline - ) external returns (uint256 amountA, uint256 amountB); - - /// @notice Remove liquidity of a token and WETH (returned as ETH) from a Pool - /// @param token . - /// @param stable True if pool is stable, false if volatile - /// @param liquidity Amount of liquidity to remove - /// @param amountTokenMin Minimum amount of token to receive - /// @param amountETHMin Minimum amount of ETH to receive - /// @param to Recipient of liquidity token - /// @param deadline Deadline to receive liquidity - /// @return amountToken Amount of token received - /// @return amountETH Amount of ETH received - function removeLiquidityETH( - address token, - bool stable, - uint256 liquidity, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline - ) external returns (uint256 amountToken, uint256 amountETH); - - /// @notice Remove liquidity of a fee-on-transfer token and WETH (returned as ETH) from a Pool - /// @param token . - /// @param stable True if pool is stable, false if volatile - /// @param liquidity Amount of liquidity to remove - /// @param amountTokenMin Minimum amount of token to receive - /// @param amountETHMin Minimum amount of ETH to receive - /// @param to Recipient of liquidity token - /// @param deadline Deadline to receive liquidity - /// @return amountETH Amount of ETH received - function removeLiquidityETHSupportingFeeOnTransferTokens( - address token, - bool stable, - uint256 liquidity, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline - ) external returns (uint256 amountETH); - - // **** SWAP **** - - /// @notice Swap one token for another - /// @param amountIn Amount of token in - /// @param amountOutMin Minimum amount of desired token received - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - /// @return amounts Array of amounts returned per route - function swapExactTokensForTokens( - uint256 amountIn, - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - /// @notice Swap ETH for a token - /// @param amountOutMin Minimum amount of desired token received - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - /// @return amounts Array of amounts returned per route - function swapExactETHForTokens( - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external payable returns (uint256[] memory amounts); - - /// @notice Swap a token for WETH (returned as ETH) - /// @param amountIn Amount of token in - /// @param amountOutMin Minimum amount of desired ETH - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - /// @return amounts Array of amounts returned per route - function swapExactTokensForETH( - uint256 amountIn, - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - /// @notice Swap one token for another without slippage protection - /// @return amounts Array of amounts to swap per route - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - function UNSAFE_swapExactTokensForTokens( - uint256[] memory amounts, - Route[] calldata routes, - address to, - uint256 deadline - ) external returns (uint256[] memory); - - // **** SWAP (supporting fee-on-transfer tokens) **** - - /// @notice Swap one token for another supporting fee-on-transfer tokens - /// @param amountIn Amount of token in - /// @param amountOutMin Minimum amount of desired token received - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - function swapExactTokensForTokensSupportingFeeOnTransferTokens( - uint256 amountIn, - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external; - - /// @notice Swap ETH for a token supporting fee-on-transfer tokens - /// @param amountOutMin Minimum amount of desired token received - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - function swapExactETHForTokensSupportingFeeOnTransferTokens( - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external payable; - - /// @notice Swap a token for WETH (returned as ETH) supporting fee-on-transfer tokens - /// @param amountIn Amount of token in - /// @param amountOutMin Minimum amount of desired ETH - /// @param routes Array of trade routes used in the swap - /// @param to Recipient of the tokens received - /// @param deadline Deadline to receive tokens - function swapExactTokensForETHSupportingFeeOnTransferTokens( - uint256 amountIn, - uint256 amountOutMin, - Route[] calldata routes, - address to, - uint256 deadline - ) external; - - /// @notice Zap a token A into a pool (B, C). (A can be equal to B or C). - /// Supports standard ERC20 tokens only (i.e. not fee-on-transfer tokens etc). - /// Slippage is required for the initial swap. - /// Additional slippage may be required when adding liquidity as the - /// price of the token may have changed. - /// @param tokenIn Token you are zapping in from (i.e. input token). - /// @param amountInA Amount of input token you wish to send down routesA - /// @param amountInB Amount of input token you wish to send down routesB - /// @param zapInPool Contains zap struct information. See Zap struct. - /// @param routesA Route used to convert input token to tokenA - /// @param routesB Route used to convert input token to tokenB - /// @param to Address you wish to mint liquidity to. - /// @param stake Auto-stake liquidity in corresponding gauge. - /// @return liquidity Amount of LP tokens created from zapping in. - function zapIn( - address tokenIn, - uint256 amountInA, - uint256 amountInB, - Zap calldata zapInPool, - Route[] calldata routesA, - Route[] calldata routesB, - address to, - bool stake - ) external payable returns (uint256 liquidity); - - /// @notice Zap out a pool (B, C) into A. - /// Supports standard ERC20 tokens only (i.e. not fee-on-transfer tokens etc). - /// Slippage is required for the removal of liquidity. - /// Additional slippage may be required on the swap as the - /// price of the token may have changed. - /// @param tokenOut Token you are zapping out to (i.e. output token). - /// @param liquidity Amount of liquidity you wish to remove. - /// @param zapOutPool Contains zap struct information. See Zap struct. - /// @param routesA Route used to convert tokenA into output token. - /// @param routesB Route used to convert tokenB into output token. - function zapOut( - address tokenOut, - uint256 liquidity, - Zap calldata zapOutPool, - Route[] calldata routesA, - Route[] calldata routesB - ) external; - - /// @notice Used to generate params required for zapping in. - /// Zap in => remove liquidity then swap. - /// Apply slippage to expected swap values to account for changes in reserves in between. - /// @dev Output token refers to the token you want to zap in from. - /// @param tokenA . - /// @param tokenB . - /// @param stable . - /// @param _factory . - /// @param amountInA Amount of input token you wish to send down routesA - /// @param amountInB Amount of input token you wish to send down routesB - /// @param routesA Route used to convert input token to tokenA - /// @param routesB Route used to convert input token to tokenB - /// @return amountOutMinA Minimum output expected from swapping input token to tokenA. - /// @return amountOutMinB Minimum output expected from swapping input token to tokenB. - /// @return amountAMin Minimum amount of tokenA expected from depositing liquidity. - /// @return amountBMin Minimum amount of tokenB expected from depositing liquidity. - function generateZapInParams( - address tokenA, - address tokenB, - bool stable, - address _factory, - uint256 amountInA, - uint256 amountInB, - Route[] calldata routesA, - Route[] calldata routesB - ) external view returns (uint256 amountOutMinA, uint256 amountOutMinB, uint256 amountAMin, uint256 amountBMin); - - /// @notice Used to generate params required for zapping out. - /// Zap out => swap then add liquidity. - /// Apply slippage to expected liquidity values to account for changes in reserves in between. - /// @dev Output token refers to the token you want to zap out of. - /// @param tokenA . - /// @param tokenB . - /// @param stable . - /// @param _factory . - /// @param liquidity Amount of liquidity being zapped out of into a given output token. - /// @param routesA Route used to convert tokenA into output token. - /// @param routesB Route used to convert tokenB into output token. - /// @return amountOutMinA Minimum output expected from swapping tokenA into output token. - /// @return amountOutMinB Minimum output expected from swapping tokenB into output token. - /// @return amountAMin Minimum amount of tokenA expected from withdrawing liquidity. - /// @return amountBMin Minimum amount of tokenB expected from withdrawing liquidity. - function generateZapOutParams( - address tokenA, - address tokenB, - bool stable, - address _factory, - uint256 liquidity, - Route[] calldata routesA, - Route[] calldata routesB - ) external view returns (uint256 amountOutMinA, uint256 amountOutMinB, uint256 amountAMin, uint256 amountBMin); - - /// @notice Used by zapper to determine appropriate ratio of A to B to deposit liquidity. Assumes stable pool. - /// @dev Returns stable liquidity ratio of B to (A + B). - /// E.g. if ratio is 0.4, it means there is more of A than there is of B. - /// Therefore you should deposit more of token A than B. - /// @param tokenA tokenA of stable pool you are zapping into. - /// @param tokenB tokenB of stable pool you are zapping into. - /// @param factory Factory that created stable pool. - /// @return ratio Ratio of token0 to token1 required to deposit into zap. - function quoteStableLiquidityRatio( - address tokenA, - address tokenB, - address factory - ) external view returns (uint256 ratio); -} diff --git a/contracts/interfaces/aerodrome/ISlipstreamNonfungiblePositionManager.sol b/contracts/interfaces/aerodrome/ISlipstreamNonfungiblePositionManager.sol new file mode 100644 index 0000000..e07f930 --- /dev/null +++ b/contracts/interfaces/aerodrome/ISlipstreamNonfungiblePositionManager.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.28; + +// Minimal vendored interface for Aerodrome Slipstream's Nonfungible Position +// Manager. Only the surface AerodromeYieldHandler calls is declared. +// +// Load-bearing differences from Uniswap V3's INonfungiblePositionManager: +// - `MintParams` carries `int24 tickSpacing` instead of `uint24 fee`, plus a +// trailing `uint160 sqrtPriceX96` (0 when the pool already exists; non-zero +// to create + initialize atomically). +// - `positions(tokenId)` returns `int24 tickSpacing` where Uniswap returns +// `uint24 fee`. +// - `increaseLiquidity` / `decreaseLiquidity` / `collect` / `burn` are +// identical (keyed by tokenId). +// +// Canonical implementation: +// https://github.com/aerodrome-finance/slipstream/blob/main/contracts/periphery/NonfungiblePositionManager.sol +interface ISlipstreamNonfungiblePositionManager { + struct MintParams { + address token0; + address token1; + int24 tickSpacing; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + uint160 sqrtPriceX96; + } + + 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, + int24 tickSpacing, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); +} diff --git a/contracts/interfaces/aerodrome/ISlipstreamSwapRouter.sol b/contracts/interfaces/aerodrome/ISlipstreamSwapRouter.sol new file mode 100644 index 0000000..0e15f0e --- /dev/null +++ b/contracts/interfaces/aerodrome/ISlipstreamSwapRouter.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.28; + +/** + * @dev Minimal vendored interface for Aerodrome Slipstream's `SwapRouter` + * (the CL swap router — NOT the Aerodrome V2 AMM `IRouter`). + * + * Load-bearing differences from Uniswap V3's SwapRouter02 + * `exactInputSingle`: + * - `ExactInputSingleParams` carries `int24 tickSpacing` instead of + * `uint24 fee`. + * - `deadline` is present (SwapRouter02 had dropped it). + * Both change the function selector, which must be recomputed: + * bytes4(keccak256( + * "exactInputSingle((address,address,int24,address,uint256,uint256,uint256,uint160))")) + * = 0xa026383e + * + * AerodromeYieldHandler builds the swap calldata on-chain from this + * interface via `abi.encodeCall`, so the selector is compiler-derived. + * + * Canonical implementation: + * https://github.com/aerodrome-finance/slipstream/blob/main/contracts/periphery/SwapRouter.sol + */ +interface ISlipstreamSwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + int24 tickSpacing; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); +} diff --git a/contracts/interfaces/aerodrome/IStakePool.sol b/contracts/interfaces/aerodrome/IStakePool.sol new file mode 100644 index 0000000..7bccc87 --- /dev/null +++ b/contracts/interfaces/aerodrome/IStakePool.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @notice Minimal Aerodrome Slipstream stake pool surface. `deposit` stakes a +/// Slipstream LP NFT (pulling it from the caller, who must have +/// approved the stakePool) to earn AERO emissions; `withdraw` unstakes it, +/// returning the NFT to the caller; `getReward` claims the accrued AERO +/// emissions for a staked position to its owner (the caller); +/// `rewardToken` is the emission token those claims pay out (AERO). +interface IStakePool { + function deposit(uint256 tokenId) external; + + function withdraw(uint256 tokenId) external; + + function getReward(uint256 tokenId) external; + + function rewardToken() external view returns (address); +} diff --git a/contracts/interfaces/aerodrome/IVoter.sol b/contracts/interfaces/aerodrome/IVoter.sol new file mode 100644 index 0000000..63c633f --- /dev/null +++ b/contracts/interfaces/aerodrome/IVoter.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @notice Minimal Aerodrome Voter surface: the canonical pool -> stakePool +/// registry. `gauges(pool)` returns the stake pool a Slipstream pool's +/// LP positions can be staked into, or `address(0)` when the pool has +/// no stakePool. +interface IVoter { + function gauges(address pool) external view returns (address stakePool); +} diff --git a/contracts/interfaces/aerodrome/IWETH.sol b/contracts/interfaces/aerodrome/IWETH.sol deleted file mode 100644 index 2e18524..0000000 --- a/contracts/interfaces/aerodrome/IWETH.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -interface IWETH is IERC20 { - function deposit() external payable; - - function withdraw(uint256) external; -} diff --git a/contracts/interfaces/uniswapV3/IApproveAndCall.sol b/contracts/interfaces/uniswapV3/IApproveAndCall.sol deleted file mode 100644 index 2a5bcaf..0000000 --- a/contracts/interfaces/uniswapV3/IApproveAndCall.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -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 - } - - /// @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 - /// @param amount The amount to approve - /// @return The required approval type - function getApprovalType(address token, uint256 amount) external returns (ApprovalType); - - /// @notice Approves a token for the maximum possible amount - /// @param token The token to approve - function approveMax(address token) external payable; - - /// @notice Approves a token for the maximum possible amount minus one - /// @param token The token to approve - function approveMaxMinusOne(address token) external payable; - - /// @notice Approves a token for zero, then the maximum possible amount - /// @param token The token to approve - function approveZeroThenMax(address token) external payable; - - /// @notice Approves a token for zero, then the maximum possible amount minus one - /// @param token The token to approve - function approveZeroThenMaxMinusOne(address token) external payable; - - /// @notice Calls the position manager with arbitrary calldata - /// @param data Calldata to pass along to the position manager - /// @return result The result from the call - function callPositionManager(bytes memory data) external payable returns (bytes memory result); - - struct MintParams { - address token0; - address token1; - uint24 fee; - int24 tickLower; - int24 tickUpper; - uint256 amount0Min; - uint256 amount1Min; - address recipient; - } - - /// @notice Calls the position manager's mint function - /// @param params Calldata to pass along to the position manager - /// @return result The result from the call - function mint(MintParams calldata params) external payable returns (bytes memory result); - - struct IncreaseLiquidityParams { - address token0; - address token1; - uint256 tokenId; - uint256 amount0Min; - uint256 amount1Min; - } - - /// @notice Calls the position manager's increaseLiquidity function - /// @param params Calldata to pass along to the position manager - /// @return result The result from the call - function increaseLiquidity(IncreaseLiquidityParams calldata params) external payable returns (bytes memory result); -} diff --git a/contracts/interfaces/uniswapV3/IMulticallExtended.sol b/contracts/interfaces/uniswapV3/IMulticallExtended.sol deleted file mode 100644 index 28f18c9..0000000 --- a/contracts/interfaces/uniswapV3/IMulticallExtended.sol +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -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 -interface IMulticallExtended is IMulticall { - /// @notice Call multiple functions in the current contract and return the data from all of them if they all succeed - /// @dev The `msg.value` should not be trusted for any method callable from multicall. - /// @param deadline The time by which this function must be called before failing - /// @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(uint256 deadline, bytes[] calldata data) external payable returns (bytes[] memory results); - - /// @notice Call multiple functions in the current contract and return the data from all of them if they all succeed - /// @dev The `msg.value` should not be trusted for any method callable from multicall. - /// @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); -} diff --git a/contracts/interfaces/uniswapV3/ISwapRouter02.sol b/contracts/interfaces/uniswapV3/ISwapRouter02.sol deleted file mode 100644 index 31cc5b2..0000000 --- a/contracts/interfaces/uniswapV3/ISwapRouter02.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -import "@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.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 {} diff --git a/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol b/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol index d80798f..ee579d5 100644 --- a/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol +++ b/contracts/interfaces/uniswapV3/IUniswapV3Pool.sol @@ -46,4 +46,29 @@ interface IUniswapV3Pool { /// @notice The currently in-range liquidity available to the pool. function liquidity() external view returns (uint128); + + /// @notice Cumulative tick and liquidity values at each `secondsAgos`. + /// @dev Reverts (`OLD`) when a requested point predates the oldest stored + /// observation — the fail-closed signal that the window is not backed + /// by recorded history. + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); + + /// @notice A single stored observation. + /// @dev Needed to read the NEWEST observation's timestamp: `observe` + /// values the span from that timestamp to now at the LIVE tick, so + /// the age of this entry is exactly how much of the average is + /// spot rather than history. + function observations( + uint256 index + ) + external + view + returns ( + uint32 blockTimestamp, + int56 tickCumulative, + uint160 secondsPerLiquidityCumulativeX128, + bool initialized + ); } diff --git a/contracts/interfaces/uniswapV3/IV2SwapRouter.sol b/contracts/interfaces/uniswapV3/IV2SwapRouter.sol deleted file mode 100644 index 0d1ded3..0000000 --- a/contracts/interfaces/uniswapV3/IV2SwapRouter.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title Router token swapping functionality -/// @notice Functions for swapping tokens via Uniswap V2 -interface IV2SwapRouter { - /// @notice Swaps `amountIn` of one token for as much as possible of another token - /// @dev Setting `amountIn` to 0 will cause the contract to look up its own balance, - /// and swap the entire amount, enabling contracts to send tokens before calling this function. - /// @param amountIn The amount of token to swap - /// @param amountOutMin The minimum amount of output that must be received - /// @param path The ordered list of tokens to swap through - /// @param to The recipient address - /// @return amountOut The amount of the received token - function swapExactTokensForTokens( - uint256 amountIn, - uint256 amountOutMin, - address[] calldata path, - address to - ) external payable returns (uint256 amountOut); - - /// @notice Swaps as little as possible of one token for an exact amount of another token - /// @param amountOut The amount of token to swap for - /// @param amountInMax The maximum amount of input that the caller will pay - /// @param path The ordered list of tokens to swap through - /// @param to The recipient address - /// @return amountIn The amount of token to pay - function swapTokensForExactTokens( - uint256 amountOut, - uint256 amountInMax, - address[] calldata path, - address to - ) external payable returns (uint256 amountIn); -} diff --git a/contracts/interfaces/uniswapV4/IAllowanceTransfer.sol b/contracts/interfaces/uniswapV4/IAllowanceTransfer.sol new file mode 100644 index 0000000..64622b9 --- /dev/null +++ b/contracts/interfaces/uniswapV4/IAllowanceTransfer.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Minimal surface of Permit2's AllowanceTransfer. Both the V4 +/// PositionManager and the UniversalRouter pull ERC20 input through +/// Permit2, so the Safe's approvals are two-step: ERC20 -> Permit2, +/// then this sub-allowance Permit2 -> spender. The ERC20 allowance to +/// Permit2 is shared across ALL Permit2 spenders, which is why every +/// flow grants exactly what it needs and resets both hops to zero. +interface IAllowanceTransfer { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + + function allowance( + address user, + address token, + address spender + ) external view returns (uint160 amount, uint48 expiration, uint48 nonce); +} diff --git a/contracts/interfaces/uniswapV4/IStateView.sol b/contracts/interfaces/uniswapV4/IStateView.sol new file mode 100644 index 0000000..1a0c5b4 --- /dev/null +++ b/contracts/interfaces/uniswapV4/IStateView.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Minimal surface of the Uniswap V4 StateView lens. V4 pools live +/// inside the singleton PoolManager and have no per-pool contract; +/// all state reads are keyed by PoolId = keccak256(abi.encode(PoolKey)). +interface IStateView { + function getSlot0( + bytes32 poolId + ) external view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); + + function getLiquidity(bytes32 poolId) external view returns (uint128 liquidity); +} diff --git a/contracts/interfaces/uniswapV4/IUniversalRouter.sol b/contracts/interfaces/uniswapV4/IUniversalRouter.sol new file mode 100644 index 0000000..6257d76 --- /dev/null +++ b/contracts/interfaces/uniswapV4/IUniversalRouter.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Minimal surface of the Uniswap UniversalRouter used for V4 swaps. +/// `msg.sender` of `execute` is the swap's payer/recipient identity +/// (MSG_SENDER mapping), so calls are executed BY the Safe via +/// `execTransactionFromModule` — never by the manager. +interface IUniversalRouter { + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +} diff --git a/contracts/interfaces/uniswapV4/IV4PositionManager.sol b/contracts/interfaces/uniswapV4/IV4PositionManager.sol new file mode 100644 index 0000000..5d2b701 --- /dev/null +++ b/contracts/interfaces/uniswapV4/IV4PositionManager.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {PoolKey} from "./V4Types.sol"; + +/// @notice Minimal surface of the Uniswap V4 PositionManager (an ERC-721) +/// used by UniV4YieldHandler. `modifyLiquidities` executes an +/// abi.encode(actions, params) batch and returns nothing — position +/// ids come from reading `nextTokenId` before the mint, amounts from +/// balance deltas. +interface IV4PositionManager { + /// @notice Batched liquidity operations, unlocked against the PoolManager. + /// @param unlockData abi.encode(bytes actions, bytes[] params) + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + + /// @notice Id the NEXT minted position will get (assigned, then incremented). + function nextTokenId() external view returns (uint256); + + /// @return poolKey the pool the position sits in + /// @return info packed PositionInfo (poolId upper bits | tickUpper | tickLower | subscribe flag) + function getPoolAndPositionInfo(uint256 tokenId) external view returns (PoolKey memory poolKey, uint256 info); + + function getPositionLiquidity(uint256 tokenId) external view returns (uint128 liquidity); +} diff --git a/contracts/interfaces/uniswapV4/V4Constants.sol b/contracts/interfaces/uniswapV4/V4Constants.sol new file mode 100644 index 0000000..74fdd40 --- /dev/null +++ b/contracts/interfaces/uniswapV4/V4Constants.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Action bytes decoded by the deployed V4 PositionManager and the +/// UniversalRouter's V4Router. Values copied verbatim from +/// v4-periphery `libraries/Actions.sol` at the deploy-era commit +/// (4d85e047, matching the live Base deployments) and cross-checked +/// against the fork test suite. uint8 so they concatenate with +/// `abi.encodePacked` into the actions byte string. +library V4Actions { + uint8 internal constant DECREASE_LIQUIDITY = 0x01; + uint8 internal constant MINT_POSITION = 0x02; + uint8 internal constant BURN_POSITION = 0x03; + uint8 internal constant SWAP_EXACT_IN_SINGLE = 0x06; + uint8 internal constant SETTLE_ALL = 0x0c; + uint8 internal constant SETTLE_PAIR = 0x0d; + uint8 internal constant TAKE_ALL = 0x0f; + uint8 internal constant TAKE_PAIR = 0x11; + uint8 internal constant SWEEP = 0x14; +} + +/// @notice UniversalRouter command bytes (universal-router `libraries/Commands.sol`). +library V4Commands { + uint8 internal constant V4_SWAP = 0x10; +} diff --git a/contracts/interfaces/uniswapV4/V4Types.sol b/contracts/interfaces/uniswapV4/V4Types.sol new file mode 100644 index 0000000..0fa6cdb --- /dev/null +++ b/contracts/interfaces/uniswapV4/V4Types.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Uniswap V4 pool identifier. ABI-identical to v4-core's PoolKey +/// (whose `Currency`/`IHooks` fields are user-defined value types / +/// interfaces wrapping `address`), declared with plain addresses so +/// the repo needs no v4-core dependency. Native ETH is +/// `currency0 == address(0)`; currencies are sorted ascending, so a +/// native pool always has ETH on the currency0 side. +/// @dev `keccak256(abi.encode(key))` IS the V4 PoolId — the same hash the +/// manager's `allowedPoolKey` allow-list stores for the ABI-encoded +/// pool param, so one allow-listed param pins exactly one pool. +struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; +} + +/// @notice Exact-input single-hop swap params for the V4Router actions +/// decoded by the deployed UniversalRouter (deploy-era shape: no +/// sqrtPriceLimitX96, no per-hop slippage field). +struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; +} diff --git a/contracts/RatehopperUniV3Positions.sol b/contracts/legacy/RatehopperUniV3Positions.sol similarity index 97% rename from contracts/RatehopperUniV3Positions.sol rename to contracts/legacy/RatehopperUniV3Positions.sol index 670930b..44a5e94 100644 --- a/contracts/RatehopperUniV3Positions.sol +++ b/contracts/legacy/RatehopperUniV3Positions.sol @@ -8,12 +8,13 @@ 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 {Address} from "@openzeppelin/contracts/utils/Address.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"; +import {IProtocolRegistry} from "../interfaces/IProtocolRegistry.sol"; /// @title RatehopperUniV3Positions /// @notice Atomic Uniswap V3 WETH/USDC LP lifecycle helper for Gnosis Safes: @@ -620,13 +621,7 @@ contract RatehopperUniV3Positions is AccessControl, ReentrancyGuard { // 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) { + if (!_trySafeTransfer(_onBehalfOf, address(USDC), treasury, feeUsd6)) { emit FeeTransferFailed(_onBehalfOf, tokenId, feeUsd6); feeUsd6 = 0; } @@ -924,6 +919,30 @@ contract RatehopperUniV3Positions is AccessControl, ReentrancyGuard { // Internal helpers // ───────────────────────────────────────────────────────────────────── + /// @dev Accept empty ERC20 return data or the canonical true word only. + /// Malformed/false returndata must waive the fee without blocking an exit. + function _trySafeTransfer( + address _onBehalfOf, + address token, + address recipient, + uint256 amount + ) internal returns (bool) { + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + token, + 0, + abi.encodeCall(IERC20.transfer, (recipient, amount)), + ISafe.Operation.Call + ); + if (!ok) return false; + if (ret.length == 0) return true; + if (ret.length < 32) return false; + uint256 word; + assembly ("memory-safe") { + word := mload(add(ret, 0x20)) + } + return word == 1; + } + /// @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, @@ -1021,18 +1040,14 @@ contract RatehopperUniV3Positions is AccessControl, ReentrancyGuard { ISafe.Operation.Call ); if (!ok) { - if (ret.length > 0) { - assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) - } - } + if (ret.length > 0) Address.verifyCallResult(ok, 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 + /// the inner revert via OpenZeppelin Address 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. @@ -1044,11 +1059,7 @@ contract RatehopperUniV3Positions is AccessControl, ReentrancyGuard { ISafe.Operation.Call ); if (!ok) { - if (ret.length > 0) { - assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) - } - } + if (ret.length > 0) Address.verifyCallResult(ok, ret); revert ModuleCallFailed(step); } } diff --git a/contracts/SafeModuleDebtSwapUpgradeable.sol_excluded b/contracts/legacy/SafeModuleDebtSwapUpgradeable.sol_excluded similarity index 92% rename from contracts/SafeModuleDebtSwapUpgradeable.sol_excluded rename to contracts/legacy/SafeModuleDebtSwapUpgradeable.sol_excluded index f0ad29c..c573303 100644 --- a/contracts/SafeModuleDebtSwapUpgradeable.sol_excluded +++ b/contracts/legacy/SafeModuleDebtSwapUpgradeable.sol_excluded @@ -6,9 +6,9 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import "./Types.sol"; -import "./interfaces/safe/ISafe.sol"; -import {IProtocolHandler} from "./interfaces/IProtocolHandler.sol"; +import "../common/Types.sol"; +import "../interfaces/safe/ISafe.sol"; +import {IDebtHandler} from "../interfaces/IDebtHandler.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -20,11 +20,11 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP uint8 public protocolFee; address public feeBeneficiary; address public operator; - mapping(Protocol => address) public protocolHandlers; + mapping(DebtProtocol => address) public protocolHandlers; struct FlashCallbackData { - Protocol fromProtocol; - Protocol toProtocol; + DebtProtocol fromProtocol; + DebtProtocol toProtocol; address fromAsset; address toAsset; uint256 amount; @@ -38,8 +38,8 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP event DebtSwapped( address indexed onBehalfOf, - Protocol fromProtocol, - Protocol toProtocol, + DebtProtocol fromProtocol, + DebtProtocol toProtocol, address fromAsset, address toAsset, uint256 amount @@ -66,7 +66,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP * @param protocols Array of protocols to register * @param handlers Array of handler addresses corresponding to protocols */ - function initialize(Protocol[] memory protocols, address[] memory handlers) public initializer { + function initialize(DebtProtocol[] memory protocols, address[] memory handlers) public initializer { require(protocols.length == handlers.length, "Protocols and handlers length mismatch"); __Ownable_init(msg.sender); @@ -100,8 +100,8 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP function executeDebtSwap( address _flashloanPool, - Protocol _fromProtocol, - Protocol _toProtocol, + DebtProtocol _fromProtocol, + DebtProtocol _toProtocol, address _fromDebtAsset, address _toDebtAsset, uint256 _amount, @@ -121,7 +121,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP if (_amount == type(uint256).max) { address handler = protocolHandlers[_fromProtocol]; - debtAmount = IProtocolHandler(handler).getDebtAmount(_fromDebtAsset, _onBehalfOf, _extraData[0]); + debtAmount = IDebtHandler(handler).getDebtAmount(_fromDebtAsset, _onBehalfOf, _extraData[0]); } address token0; @@ -178,7 +178,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP (bool success, ) = handler.delegatecall( abi.encodeCall( - IProtocolHandler.switchIn, + IDebtHandler.switchIn, ( decoded.fromAsset, decoded.toAsset, @@ -196,7 +196,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP address fromHandler = protocolHandlers[decoded.fromProtocol]; (bool successFrom, ) = fromHandler.delegatecall( abi.encodeCall( - IProtocolHandler.switchFrom, + IDebtHandler.switchFrom, (decoded.fromAsset, decoded.amount, safe, decoded.collateralAssets, decoded.fromExtraData) ) ); @@ -205,7 +205,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP address toHandler = protocolHandlers[decoded.toProtocol]; (bool successTo, ) = toHandler.delegatecall( abi.encodeCall( - IProtocolHandler.switchTo, + IDebtHandler.switchTo, (decoded.toAsset, amountTotal, safe, decoded.collateralAssets, decoded.toExtraData) ) ); @@ -237,7 +237,7 @@ contract SafeModuleDebtSwapUpgradeable is Initializable, OwnableUpgradeable, UUP address handler = protocolHandlers[decoded.toProtocol]; (bool success, ) = handler.delegatecall( - abi.encodeCall(IProtocolHandler.repay, (decoded.toAsset, remainingBalance, safe, decoded.toExtraData)) + abi.encodeCall(IDebtHandler.repay, (decoded.toAsset, remainingBalance, safe, decoded.toExtraData)) ); require(success, "Repay remainingBalance failed"); diff --git a/contracts/mock/MaliciousContract.sol b/contracts/mock/MaliciousContract.sol deleted file mode 100644 index 1fff719..0000000 --- a/contracts/mock/MaliciousContract.sol +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import "../interfaces/IProtocolHandler.sol"; -import "../Types.sol"; -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 */, bytes calldata /* extraData */) external { - // Try to transfer tokens to malicious address instead of supplying - IERC20(asset).transfer(maliciousAddress, amount); - } - - function borrow( - address asset, - uint256 /* amount */, - address /* onBehalfOf */, - bytes calldata /* extraData */ - ) external { - // Try to transfer any tokens the contract has to malicious address - uint256 balance = IERC20(asset).balanceOf(address(this)); - if (balance > 0) { - IERC20(asset).transfer(maliciousAddress, balance); - } - } - - function switchFrom( - address asset, - uint256 amount, - address /* onBehalfOf */, - CollateralAsset[] calldata /* collateralAssets */, - bytes calldata /* extraData */ - ) external { - // Try to transfer tokens to malicious address - IERC20(asset).transfer(maliciousAddress, amount); - } - - function switchTo( - address asset, - uint256 amount, - address /* onBehalfOf */, - CollateralAsset[] calldata /* collateralAssets */, - bytes calldata /* extraData */ - ) external { - // Try to transfer tokens to malicious address - IERC20(asset).transfer(maliciousAddress, amount); - } - - function switchIn( - address fromAsset, - address toAsset, - uint256 amount, - uint256 amountTotal, - address /* onBehalfOf */, - CollateralAsset[] calldata /* collateralAssets */, - bytes calldata /* fromExtraData */, - bytes calldata /* toExtraData */ - ) external { - // Try to transfer both tokens to malicious address - IERC20(fromAsset).transfer(maliciousAddress, amount); - IERC20(toAsset).transfer(maliciousAddress, amountTotal); - } - - function repay(address asset, uint256 amount, address /* onBehalfOf */, bytes calldata /* extraData */) external { - // Try to transfer tokens to malicious address - IERC20(asset).transfer(maliciousAddress, amount); - } - - function withdraw( - address asset, - uint256 /* amount */, - address /* onBehalfOf */, - bytes calldata /* extraData */ - ) external { - // Try to transfer tokens to malicious address - uint256 balance = IERC20(asset).balanceOf(address(this)); - if (balance > 0) { - IERC20(asset).transfer(maliciousAddress, balance); - } - } -} diff --git a/contracts/mocks/LpMathProbe.sol b/contracts/mocks/LpMathProbe.sol new file mode 100644 index 0000000..c78f08d --- /dev/null +++ b/contracts/mocks/LpMathProbe.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {SqrtPriceMath} from "@uniswap/v4-core/src/libraries/SqrtPriceMath.sol"; +import {LiquidityAmounts} from "@uniswap/v4-periphery/src/libraries/LiquidityAmounts.sol"; + +/// @notice Test-only window onto the pinned Uniswap tick/liquidity libraries. +/// The operations scripts build transaction minima in TypeScript; this +/// probe is what those helpers are diffed against, so "our bigint port +/// matches Uniswap" is asserted against the real library rather than a +/// hand-copied constant table. +contract LpMathProbe { + function sqrtPriceAtTick(int24 tick) external pure returns (uint160) { + return TickMath.getSqrtPriceAtTick(tick); + } + + function liquidityForAmounts( + uint160 sqrtPriceX96, + uint160 sqrtPriceAX96, + uint160 sqrtPriceBX96, + uint256 amount0, + uint256 amount1 + ) external pure returns (uint128) { + return LiquidityAmounts.getLiquidityForAmounts(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, amount0, amount1); + } + + /// @dev Amounts a position of `liquidity` holds at `sqrtPriceX96`, rounded + /// DOWN — the direction a withdrawal actually credits. + function amountsForLiquidity( + uint160 sqrtPriceX96, + uint160 sqrtPriceAX96, + uint160 sqrtPriceBX96, + uint128 liquidity + ) external pure returns (uint256 amount0, uint256 amount1) { + if (sqrtPriceAX96 > sqrtPriceBX96) (sqrtPriceAX96, sqrtPriceBX96) = (sqrtPriceBX96, sqrtPriceAX96); + if (sqrtPriceX96 <= sqrtPriceAX96) { + amount0 = SqrtPriceMath.getAmount0Delta(sqrtPriceAX96, sqrtPriceBX96, liquidity, false); + } else if (sqrtPriceX96 < sqrtPriceBX96) { + amount0 = SqrtPriceMath.getAmount0Delta(sqrtPriceX96, sqrtPriceBX96, liquidity, false); + amount1 = SqrtPriceMath.getAmount1Delta(sqrtPriceAX96, sqrtPriceX96, liquidity, false); + } else { + amount1 = SqrtPriceMath.getAmount1Delta(sqrtPriceAX96, sqrtPriceBX96, liquidity, false); + } + } +} diff --git a/contracts/mock/MimicUniswapV3Contract.sol b/contracts/mocks/MimicUniswapV3Contract.sol similarity index 71% rename from contracts/mock/MimicUniswapV3Contract.sol rename to contracts/mocks/MimicUniswapV3Contract.sol index 444fecc..b3eefca 100644 --- a/contracts/mock/MimicUniswapV3Contract.sol +++ b/contracts/mocks/MimicUniswapV3Contract.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import "../interfaces/IProtocolHandler.sol"; -import "../Types.sol"; +import "../interfaces/IDebtHandler.sol"; +import "../common/Types.sol"; /** * @title MaliciousUniswapV3Pool @@ -30,7 +30,7 @@ contract MaliciousUniswapV3Pool { */ 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"); + IDebtHandler(targetHandler).borrow(asset, amount, onBehalfOf, "0x"); } /** @@ -42,13 +42,6 @@ contract MaliciousUniswapV3Pool { address onBehalfOf, CollateralAsset[] memory collateralAssets ) 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 { - IProtocolHandler(targetHandler).supply(asset, amount, onBehalfOf, "0x"); + IDebtHandler(targetHandler).switchFrom(fromAsset, amount, onBehalfOf, collateralAssets, "0x"); } } diff --git a/contracts/mocks/MockNextYieldHandler.sol b/contracts/mocks/MockNextYieldHandler.sol new file mode 100644 index 0000000..d4acb0b --- /dev/null +++ b/contracts/mocks/MockNextYieldHandler.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IUniswapV3Factory} from "../interfaces/uniswapV3/IUniswapV3Factory.sol"; +import {V3StyleYieldHandler} from "../yield/handlers/V3StyleYieldHandler.sol"; + +/// @dev Test-only "future protocol" handler: V3-shaped mechanics with the +/// protocol id supplied at construction, proving SafeYieldManager +/// accepts ids beyond the canonical YIELD_PROTOCOL_* constants without +/// a redeploy. +contract MockNextYieldHandler is V3StyleYieldHandler { + constructor( + uint8 _protocolId, + address _positionManager, + IERC20 _usdc, + address _swapRouter, + IUniswapV3Factory _factory + ) V3StyleYieldHandler(_protocolId, _positionManager, _usdc, _swapRouter, _factory) {} +} diff --git a/contracts/mocks/RatehopperAerodromeMocks.sol b/contracts/mocks/RatehopperAerodromeMocks.sol new file mode 100644 index 0000000..9b3add1 --- /dev/null +++ b/contracts/mocks/RatehopperAerodromeMocks.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {ISlipstreamNonfungiblePositionManager} from "../interfaces/aerodrome/ISlipstreamNonfungiblePositionManager.sol"; +import {INonfungiblePositionManager} from "../interfaces/uniswapV3/INonfungiblePositionManager.sol"; + +// ───────────────────────────────────────────────────────────────────────── +// Slipstream (Aerodrome CL) mocks for the SafeYieldManager / AerodromeYieldHandler tests. +// +// These mirror the Uniswap mocks in RatehopperMocks.sol but carry the three +// load-bearing Slipstream deltas so the handler's tickSpacing-keyed flow +// exercises end-to-end on a plain Hardhat network: +// 1. pools/mint/positions are keyed by `int24 tickSpacing`, not `uint24 fee`; +// 2. `slot0` has no `feeProtocol` field; +// 3. the swap router's `exactInputSingle` params carry `tickSpacing` + +// `deadline`, matching `ISlipstreamSwapRouter.exactInputSingle`. +// +// The shared MockERC20 / MockRegistry / MockSafeHarness / MockERC721 from +// RatehopperMocks.sol are reused as-is. TEST-ONLY; never deployed. +// ───────────────────────────────────────────────────────────────────────── + +/// @notice Mirrors the Slipstream SwapRouter `exactInputSingle` selector +/// (0xa026383e). 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 MockSlipstreamSwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + int24 tickSpacing; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + uint256 public output; + mapping(address => uint256) public outputFor; + bool public enforceMinOut; + uint256 public callCount; + + function setOutput(uint256 newOutput) external { + output = newOutput; + } + + function setOutputFor(address tokenOut, uint256 newOutput) external { + outputFor[tokenOut] = newOutput; + } + + function setEnforceMinOut(bool value) external { + enforceMinOut = value; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut) { + callCount++; + if (params.amountIn > 0) { + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + } + amountOut = outputFor[params.tokenOut] != 0 ? outputFor[params.tokenOut] : output; + if (enforceMinOut) require(amountOut >= params.amountOutMinimum, "router: too little received"); + if (amountOut > 0) { + IERC20(params.tokenOut).transfer(params.recipient, amountOut); + } + } +} + +/// @notice Configurable Slipstream CL pool stub for `_validatePool`. `slot0` +/// returns 6 fields (no `feeProtocol`) per the Slipstream layout. +contract MockCLPool { + 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, bool) { + return (sqrtPriceX96, 0, 0, 0, 0, true); + } +} + +/// @notice Factory stub returning a single configurable default pool for any +/// lookup, with optional per-(pair, tickSpacing) overrides for +/// multi-pool tests (arbitrary-pair support). +contract MockCLFactory { + address public pool; + mapping(bytes32 => address) public keyedPools; + + function setPool(address newPool) external { + pool = newPool; + } + + function setPoolFor(address token0, address token1, int24 tickSpacing, address newPool) external { + keyedPools[keccak256(abi.encode(token0, token1, tickSpacing))] = newPool; + } + + function getPool(address token0, address token1, int24 tickSpacing) external view returns (address) { + address keyed = keyedPools[keccak256(abi.encode(token0, token1, tickSpacing))]; + return keyed != address(0) ? keyed : pool; + } +} + +/// @notice Faithful-enough Slipstream Nonfungible Position Manager: tracks +/// per-tokenId owner / pair / liquidity / principal / owed, keyed by +/// `int24 tickSpacing` (not fee), and implements mint / positions / +/// ownerOf / collect / decreaseLiquidity / burn so the full LP +/// lifecycle can be driven on a plain network. Also carries just +/// enough ERC721 surface (approve / getApproved / transferFrom) for +/// MockStakePool to move the NFT in the stakePool-staking tests. +contract MockCLNonfungiblePositionManager { + struct Position { + address owner; + address token0; + address token1; + int24 tickSpacing; + uint128 liquidity; + uint128 owed0; + uint128 owed1; + uint128 principal0; + uint128 principal1; + bool exists; + } + + mapping(uint256 => Position) public positionsData; + mapping(uint256 => address) public getApproved; + uint256 public nextId = 1; + + // Config applied to the next `mint`. + uint128 public mintLiquidity = 1_000_000; + uint16 public mintUsageBps = 10_000; + address public mintOwnerOverride; + + function setMintLiquidity(uint128 value) external { + mintLiquidity = value; + } + + function setMintUsageBps(uint16 value) external { + require(value <= 10_000, "usage bps"); + mintUsageBps = value; + } + + function setMintOwnerOverride(address account) external { + mintOwnerOverride = account; + } + + /// @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, + int24 tickSpacing, + uint128 liquidity, + uint128 principal0, + uint128 principal1 + ) external { + positionsData[tokenId] = Position( + owner, + token0, + token1, + tickSpacing, + liquidity, + 0, + 0, + principal0, + principal1, + true + ); + } + + function setOwed(uint256 tokenId, uint128 owed0, uint128 owed1) external { + positionsData[tokenId].owed0 = owed0; + positionsData[tokenId].owed1 = owed1; + } + + function setPrincipal(uint256 tokenId, uint128 principal0, uint128 principal1) external { + positionsData[tokenId].principal0 = principal0; + positionsData[tokenId].principal1 = principal1; + } + + 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( + ISlipstreamNonfungiblePositionManager.MintParams calldata params + ) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) { + tokenId = nextId++; + amount0 = (params.amount0Desired * mintUsageBps) / 10_000; + amount1 = (params.amount1Desired * mintUsageBps) / 10_000; + 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.tickSpacing, + liquidity, + 0, + 0, + uint128(amount0), + uint128(amount1), + true + ); + } + + function positions( + uint256 tokenId + ) + external + view + returns (uint96, address, address, address, int24, int24, int24, uint128, uint256, uint256, uint128, uint128) + { + Position memory p = positionsData[tokenId]; + return ( + 0, + address(0), + p.token0, + p.token1, + p.tickSpacing, + int24(0), + int24(0), + p.liquidity, + 0, + 0, + p.owed0, + p.owed1 + ); + } + + function ownerOf(uint256 tokenId) external view returns (address) { + return positionsData[tokenId].owner; + } + + /// @dev Minimal ERC721 `approve`: only the current owner may set it. + function approve(address to, uint256 tokenId) external { + require(msg.sender == positionsData[tokenId].owner, "not owner"); + getApproved[tokenId] = to; + } + + /// @dev Minimal ERC721 `transferFrom`: caller must be the owner or the + /// approved address, `from` must match the current owner. + function transferFrom(address from, address to, uint256 tokenId) external { + require(from == positionsData[tokenId].owner, "wrong owner"); + require(msg.sender == from || msg.sender == getApproved[tokenId], "not authorized"); + positionsData[tokenId].owner = to; + getApproved[tokenId] = address(0); + } + + 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; + } + require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); + 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 Settable pool -> stakePool registry stub for `IVoter`, driving +/// AerodromeYieldHandler's `_stake` / `_unstakeIfStaked` hooks. +contract MockVoter { + mapping(address => address) private _stakePools; + + function setStakePool(address pool, address stakePool) external { + _stakePools[pool] = stakePool; + } + + function gauges(address pool) external view returns (address) { + return _stakePools[pool]; + } +} + +/// @notice Minimal `IStakePool` stub: `deposit` pulls the NFT from the caller +/// (the Safe, which must have approved this stakePool), `withdraw` sends +/// it back with the ERC721 receive hook, mirroring the real stakePool's +/// `safeTransferFrom` so the Safe-harness `onERC721Received` path is +/// actually exercised. +contract MockStakePool { + address public immutable NFT; + + /// @dev Arms a deposit failure so tests can prove a restake that reverts + /// takes the whole partial close down with it. + bool public depositFails; + + constructor(address _nft) { + NFT = _nft; + } + + function setDepositFails(bool value) external { + depositFails = value; + } + + function deposit(uint256 tokenId) external { + require(!depositFails, "deposit disabled"); + IERC721(NFT).transferFrom(msg.sender, address(this), tokenId); + } + + /// @dev Mirrors a real gauge: withdrawing also pays out everything accrued, + /// so close/switch paths must settle that reward like a collect does. + function withdraw(uint256 tokenId) external { + IERC721(NFT).transferFrom(address(this), msg.sender, tokenId); + if (rewardToken != address(0) && rewardAmount > 0) { + emit RewardClaimed(tokenId, msg.sender); + IERC20(rewardToken).transfer(msg.sender, rewardAmount); + } + if (msg.sender.code.length > 0) { + require( + IERC721Receiver(msg.sender).onERC721Received(address(this), address(this), tokenId, "") == + IERC721Receiver.onERC721Received.selector, + "unsafe recipient" + ); + } + } + + event RewardClaimed(uint256 indexed tokenId, address indexed to); + + address public rewardToken; + uint256 public rewardAmount; + + /// @dev Arm the mock with an emission payout so tests can exercise the + /// claimed-reward → USDC swap path. + function setReward(address token, uint256 amount) external { + rewardToken = token; + rewardAmount = amount; + } + + /// @dev Mock reward claim — records the call so tests can assert collectLp + /// routed a staked position to the stakePool, and pays the configured + /// emission (if armed) like the real stakePool pays AERO. + function getReward(uint256 tokenId) external { + emit RewardClaimed(tokenId, msg.sender); + if (rewardToken != address(0) && rewardAmount > 0) { + IERC20(rewardToken).transfer(msg.sender, rewardAmount); + } + } +} diff --git a/contracts/mocks/RatehopperMocks.sol b/contracts/mocks/RatehopperMocks.sol index 1f08afa..4c61df2 100644 --- a/contracts/mocks/RatehopperMocks.sol +++ b/contracts/mocks/RatehopperMocks.sol @@ -27,6 +27,7 @@ contract MockERC20 is IERC20 { address public falseTransferTo; address public revertTransferTo; + bool public falseApproveZero; constructor(string memory _name, string memory _symbol, uint8 _decimals) { name = _name; @@ -42,6 +43,10 @@ contract MockERC20 is IERC20 { revertTransferTo = account; } + function setFalseApproveZero(bool value) external { + falseApproveZero = value; + } + function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; @@ -49,6 +54,7 @@ contract MockERC20 is IERC20 { } function approve(address spender, uint256 amount) external override returns (bool) { + if (amount == 0 && falseApproveZero) return false; allowance[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); return true; @@ -83,13 +89,43 @@ contract MockERC20 is IERC20 { } } -/// @notice Stand-in for `IProtocolRegistry` exposing only `safeOperator`. +/// @notice WETH9-shaped MockERC20: payable `deposit` mints against received +/// ETH, `withdraw` burns and sends ETH back — the wrap/unwrap surface +/// the V4 handler's in-kind switch legs use. +contract MockWETH is MockERC20 { + constructor() MockERC20("Wrapped Ether", "WETH", 18) {} + + receive() external payable {} + + function deposit() external payable { + balanceOf[msg.sender] += msg.value; + totalSupply += msg.value; + emit Transfer(address(0), msg.sender, msg.value); + } + + function withdraw(uint256 amount) external { + require(balanceOf[msg.sender] >= amount, "WETH: balance"); + balanceOf[msg.sender] -= amount; + totalSupply -= amount; + emit Transfer(msg.sender, address(0), amount); + (bool sent, ) = msg.sender.call{value: amount}(""); + require(sent, "WETH: send"); + } +} + +/// @notice Stand-in for `IProtocolRegistry` exposing `safeOperator` and the +/// token whitelist. contract MockRegistry { address public safeOperator; + mapping(address => bool) public whitelistedTokens; function setOperator(address operator) external { safeOperator = operator; } + + function setWhitelisted(address token, bool whitelisted) external { + whitelistedTokens[token] = whitelisted; + } } /// @notice Minimal Safe module executor. Mirrors @@ -98,12 +134,21 @@ contract MockRegistry { /// 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`. + // 0 = execute normally, 1 = fail with empty returndata, 2 = fail with + // `failData`, 3 = report success with `failData` as returndata without + // executing (drives the returndata-shape branches of `_trySafeTransfer`). mapping(address => uint8) public failMode; bytes public failData; receive() external payable {} + /// @dev Real deployed Safes accept ERC721 transfers via the default + /// fallback handler's `onERC721Received`. Needed so a stakePool's + /// `safeTransferFrom` back to the Safe on unstake succeeds here too. + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return this.onERC721Received.selector; + } + function setFail(address target, uint8 mode) external { failMode[target] = mode; } @@ -121,10 +166,31 @@ contract MockSafeHarness { uint8 mode = failMode[to]; if (mode == 1) return (false, ""); if (mode == 2) return (false, failData); + if (mode == 3) return (true, failData); (success, returnData) = to.call{value: value}(data); } } +/// @notice Handler stand-in whose every entry point reverts with EMPTY +/// returndata, driving SafeYieldManager's `HandlerCallFailed` +/// fallback branch in `_delegateToHandler`. `PROTOCOL()` is real so +/// `setYieldHandler`'s validation accepts it. +contract MockRevertingYieldHandler { + uint8 public immutable PROTOCOL; + + constructor(uint8 _protocol) { + PROTOCOL = _protocol; + } + + function poolTokens(bytes calldata poolParam) external pure returns (address token0, address token1) { + (token0, token1, ) = abi.decode(poolParam, (address, address, uint24)); + } + + fallback() external { + revert(); + } +} + /// @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` @@ -141,24 +207,37 @@ contract MockSwapRouter { } uint256 public output; - bool public pullInput = true; + mapping(address => uint256) public outputFor; + bool public enforceMinOut; + uint256 public callCount; address public callbackTarget; bytes public callbackData; + /// @dev Last min-out the caller actually handed the router — the value a + /// TWAP-floored handler is supposed to have raised. + uint256 public lastAmountOutMinimum; + uint256 public lastAmountIn; function setOutput(uint256 newOutput) external { output = newOutput; } + function setOutputFor(address tokenOut, uint256 newOutput) external { + outputFor[tokenOut] = newOutput; + } + + function setEnforceMinOut(bool value) external { + enforceMinOut = value; + } + function setCallback(address target, bytes calldata data) external { callbackTarget = target; callbackData = data; } - function setPullInput(bool enabled) external { - pullInput = enabled; - } - function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut) { + callCount++; + lastAmountOutMinimum = params.amountOutMinimum; + lastAmountIn = params.amountIn; if (callbackTarget != address(0)) { (bool success, bytes memory ret) = callbackTarget.call(callbackData); if (!success) { @@ -167,10 +246,11 @@ contract MockSwapRouter { } } } - if (pullInput && params.amountIn > 0) { + if (params.amountIn > 0) { IERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); } - amountOut = output; + amountOut = outputFor[params.tokenOut] != 0 ? outputFor[params.tokenOut] : output; + if (enforceMinOut) require(amountOut >= params.amountOutMinimum, "router: too little received"); if (amountOut > 0) { IERC20(params.tokenOut).transfer(params.recipient, amountOut); } @@ -184,6 +264,18 @@ contract MockUniswapV3Pool { uint160 public sqrtPriceX96; uint128 public liquidity; + // Oracle surface. Defaults describe a deep, actively-observed pool whose + // average equals its spot tick, so a fixture that sets a realistic + // `sqrtPriceX96` gets a realistic TWAP without extra wiring. Each + // degradation the real world produces is separately settable. + int24 public twapTick; + int56 public cumulativeDelta; + bool public useCumulativeDelta; + uint16 public observationIndex; + uint16 public observationCardinality = 3000; + uint32 public observationAge; + bool public observeReverts; + constructor(address _token0, address _token1, uint160 _sqrtPriceX96, uint128 _liquidity) { token0 = _token0; token1 = _token1; @@ -191,21 +283,70 @@ contract MockUniswapV3Pool { liquidity = _liquidity; } + function setTwapTick(int24 newTick) external { + twapTick = newTick; + useCumulativeDelta = false; + } + + /// @dev Drive `observe` by raw cumulative delta so a test can produce a + /// non-integer mean and exercise the rounding direction. + function setCumulativeDelta(int56 newDelta) external { + cumulativeDelta = newDelta; + useCumulativeDelta = true; + } + + function setObservationCardinality(uint16 newCardinality) external { + observationCardinality = newCardinality; + } + + /// @dev Seconds by which the newest observation trails now. + function setObservationAge(uint32 newAge) external { + observationAge = newAge; + } + + function setObserveReverts(bool newValue) external { + observeReverts = newValue; + } + function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool) { - return (sqrtPriceX96, 0, 0, 0, 0, 0, true); + return (sqrtPriceX96, twapTick, observationIndex, observationCardinality, observationCardinality, 0, true); + } + + function observations(uint256) external view returns (uint32, int56, uint160, bool) { + return (uint32(block.timestamp) - observationAge, 0, 0, true); + } + + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidity) { + // The real pool reverts `OLD` when the window predates its oldest + // observation. Nothing catches this — that is the point. + require(!observeReverts, "OLD"); + tickCumulatives = new int56[](2); + secondsPerLiquidity = new uint160[](2); + tickCumulatives[0] = 0; + tickCumulatives[1] = useCumulativeDelta ? cumulativeDelta : int56(twapTick) * int56(uint56(secondsAgos[0])); } } -/// @notice Factory stub returning a single configurable pool for any lookup. +/// @notice Factory stub returning a single configurable default pool for any +/// lookup, with optional per-(pair, fee) overrides for multi-pool +/// tests (arbitrary-pair support). contract MockUniswapV3Factory { address public pool; + mapping(bytes32 => address) public keyedPools; function setPool(address newPool) external { pool = newPool; } - function getPool(address, address, uint24) external view returns (address) { - return pool; + function setPoolFor(address token0, address token1, uint24 fee, address newPool) external { + keyedPools[keccak256(abi.encode(token0, token1, fee))] = newPool; + } + + function getPool(address token0, address token1, uint24 fee) external view returns (address) { + address keyed = keyedPools[keccak256(abi.encode(token0, token1, fee))]; + return keyed != address(0) ? keyed : pool; } } @@ -233,7 +374,11 @@ contract MockNonfungiblePositionManager { // Config applied to the next `mint`. uint128 public mintLiquidity = 1_000_000; address public mintOwnerOverride; - bool public pullOnMint = true; + + // Reentrancy vector: called at the top of `decreaseLiquidity` when set, + // mirroring an NFT/pool callback re-entering the manager mid-flow. + address public callbackTarget; + bytes public callbackData; function setMintLiquidity(uint128 value) external { mintLiquidity = value; @@ -243,8 +388,9 @@ contract MockNonfungiblePositionManager { mintOwnerOverride = account; } - function setPullOnMint(bool enabled) external { - pullOnMint = enabled; + function setCallback(address target, bytes calldata data) external { + callbackTarget = target; + callbackData = data; } /// @dev Seed a position directly (for collectLp/closeLp tests that bypass @@ -267,6 +413,11 @@ contract MockNonfungiblePositionManager { positionsData[tokenId].owed1 = owed1; } + function setPrincipal(uint256 tokenId, uint128 principal0, uint128 principal1) external { + positionsData[tokenId].principal0 = principal0; + positionsData[tokenId].principal1 = principal1; + } + function setTokens(uint256 tokenId, address token0, address token1) external { positionsData[tokenId].token0 = token0; positionsData[tokenId].token1 = token1; @@ -276,16 +427,23 @@ contract MockNonfungiblePositionManager { positionsData[tokenId].owner = owner; } + /// @dev Fraction of each desired amount the mint actually consumes, so a + /// test can leave residue behind the way a real range does. + uint16 public mintUsageBps = 10_000; + + function setMintUsageBps(uint16 value) external { + require(value <= 10_000, "usage bps"); + mintUsageBps = value; + } + 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); - } + amount0 = (params.amount0Desired * mintUsageBps) / 10_000; + amount1 = (params.amount1Desired * mintUsageBps) / 10_000; + 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( @@ -332,12 +490,21 @@ contract MockNonfungiblePositionManager { function decreaseLiquidity( INonfungiblePositionManager.DecreaseLiquidityParams calldata params ) external payable returns (uint256 amount0, uint256 amount1) { + if (callbackTarget != address(0)) { + (bool success, bytes memory ret) = callbackTarget.call(callbackData); + if (!success) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + } 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; } + require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); p.principal0 -= uint128(amount0); p.principal1 -= uint128(amount1); p.owed0 += uint128(amount0); @@ -365,3 +532,30 @@ contract MockERC721 { ownerOf[tokenId] = to; } } + +/// @notice Test-only timelock-shaped forwarder. The production manager only +/// accepts a contract exposing a non-zero getMinDelay; tests use this helper +/// to exercise the caller-is-timelock boundary without waiting for wall-clock +/// delay in every setter test. +contract MockTimelockController { + uint256 public immutable minDelay; + + constructor(uint256 delay_) { + require(delay_ > 0, "delay"); + minDelay = delay_; + } + + function getMinDelay() external view returns (uint256) { + return minDelay; + } + + function execute(address target, bytes calldata data) external returns (bytes memory result) { + (bool ok, bytes memory ret) = target.call(data); + if (!ok) { + assembly ("memory-safe") { + revert(add(ret, 0x20), mload(ret)) + } + } + return ret; + } +} diff --git a/contracts/mocks/RatehopperUniV4Mocks.sol b/contracts/mocks/RatehopperUniV4Mocks.sol new file mode 100644 index 0000000..8383ec9 --- /dev/null +++ b/contracts/mocks/RatehopperUniV4Mocks.sol @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PoolKey, ExactInputSingleParams} from "../interfaces/uniswapV4/V4Types.sol"; +import {V4Actions} from "../interfaces/uniswapV4/V4Constants.sol"; + +// ───────────────────────────────────────────────────────────────────────── +// Mocks for UniV4YieldHandler unit/branch-coverage tests. +// +// The shared MockERC20 / MockRegistry / MockSafeHarness from +// RatehopperMocks.sol are reused; this file adds the V4-specific stack: +// Permit2 (two-hop allowance enforcement), the actions-decoding +// PositionManager, the UniversalRouter V4_SWAP stub, and the StateView +// lens. Native ETH (currency == address(0)) is supported end-to-end so the +// native-pool branches run deterministically. TEST-ONLY. +// ───────────────────────────────────────────────────────────────────────── + +/// @notice Faithful-enough Permit2 AllowanceTransfer: records sub-allowances +/// and enforces BOTH hops on `transferFrom` (amount, expiration, and +/// the underlying ERC20 allowance owner -> Permit2), so the handler's +/// grant-exact/reset-to-zero discipline is genuinely exercised. +contract MockPermit2 { + struct PackedAllowance { + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + mapping(address => mapping(address => mapping(address => PackedAllowance))) public allowance; + + function approve(address token, address spender, uint160 amount, uint48 expiration) external { + allowance[msg.sender][token][spender] = PackedAllowance(amount, expiration, 0); + } + + function transferFrom(address from, address to, uint160 amount, address token) external { + PackedAllowance storage allowed = allowance[from][token][msg.sender]; + require(block.timestamp <= allowed.expiration, "permit2: expired"); + require(allowed.amount >= amount, "permit2: insufficient allowance"); + if (allowed.amount != type(uint160).max) { + allowed.amount -= amount; + } + require(IERC20(token).transferFrom(from, to, amount), "permit2: transferFrom"); + } +} + +/// @notice Configurable StateView lens keyed by PoolId. A zero sqrt price is +/// the "pool not initialized" state, thin liquidity drives PoolTooThin. +contract MockStateView { + struct PoolState { + uint160 sqrtPriceX96; + uint128 liquidity; + } + + mapping(bytes32 => PoolState) public pools; + + function setPool(bytes32 poolId, uint160 sqrtPriceX96, uint128 liquidity) external { + pools[poolId] = PoolState(sqrtPriceX96, liquidity); + } + + function getSlot0(bytes32 poolId) external view returns (uint160, int24, uint24, uint24) { + return (pools[poolId].sqrtPriceX96, 0, 0, 0); + } + + function getLiquidity(bytes32 poolId) external view returns (uint128) { + return pools[poolId].liquidity; + } +} + +/// @notice Actions-decoding V4 PositionManager: ERC721-lite with +/// `modifyLiquidities` handling MINT_POSITION / SETTLE_PAIR / SWEEP / +/// DECREASE_LIQUIDITY / BURN_POSITION / TAKE_PAIR the way the deployed +/// contract decodes them. ERC20 settlement pulls through MockPermit2 +/// (msg.sender = the Safe); native settlement consumes call value and +/// SWEEP refunds the remainder. +contract MockV4PositionManager { + struct Position { + address owner; + PoolKey key; + int24 tickLower; + int24 tickUpper; + uint128 liquidity; + uint128 owed0; + uint128 owed1; + uint128 principal0; + uint128 principal1; + bool exists; + } + + MockPermit2 public immutable PERMIT2; + + mapping(uint256 => Position) public positionsData; + uint256 public nextTokenId = 1; + + // Amounts SETTLE_PAIR pulls for the pending mint; type(uint128).max means + // "pull the mint's amount0Max/amount1Max in full". + uint128 public mintUse0 = type(uint128).max; + uint128 public mintUse1 = type(uint128).max; + address public mintOwnerOverride; + + // Transient per-call bookkeeping (single-threaded test usage). + uint256 private pendingSettle0; + uint256 private pendingSettle1; + PoolKey private pendingKey; + uint256 private pendingTake0; + uint256 private pendingTake1; + + constructor(MockPermit2 _permit2) { + PERMIT2 = _permit2; + } + + receive() external payable {} + + function setMintUse(uint128 use0, uint128 use1) external { + mintUse0 = use0; + mintUse1 = use1; + } + + function setMintOwnerOverride(address account) external { + mintOwnerOverride = account; + } + + /// @dev Seed a position directly (for closeLp/collectLp tests that bypass + /// openLp via a storage-overridden basis). + function seedPosition( + uint256 tokenId, + address owner, + PoolKey calldata key, + uint128 liquidity, + uint128 principal0, + uint128 principal1 + ) external { + positionsData[tokenId] = Position(owner, key, 0, 0, liquidity, 0, 0, principal0, principal1, true); + if (tokenId >= nextTokenId) nextTokenId = tokenId + 1; + } + + function setOwed(uint256 tokenId, uint128 owed0, uint128 owed1) external { + positionsData[tokenId].owed0 = owed0; + positionsData[tokenId].owed1 = owed1; + } + + function setOwner(uint256 tokenId, address owner) external { + positionsData[tokenId].owner = owner; + } + + function ownerOf(uint256 tokenId) external view returns (address) { + require(positionsData[tokenId].exists, "ERC721: invalid token"); + return positionsData[tokenId].owner; + } + + function getPoolAndPositionInfo(uint256 tokenId) external view returns (PoolKey memory, uint256) { + return (positionsData[tokenId].key, 0); + } + + function getPositionLiquidity(uint256 tokenId) external view returns (uint128) { + return positionsData[tokenId].liquidity; + } + + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable { + require(block.timestamp <= deadline, "pm: deadline"); + (bytes memory actions, bytes[] memory params) = abi.decode(unlockData, (bytes, bytes[])); + uint256 valueLeft = msg.value; + + for (uint256 i = 0; i < actions.length; i++) { + uint8 action = uint8(actions[i]); + if (action == V4Actions.MINT_POSITION) { + valueLeft = _handleMint(params[i]); + } else if (action == V4Actions.SETTLE_PAIR) { + valueLeft = _handleSettlePair(params[i], valueLeft); + } else if (action == V4Actions.SWEEP) { + (address currency, address to) = abi.decode(params[i], (address, address)); + require(currency == address(0), "pm: sweep erc20"); + if (valueLeft > 0) { + (bool sent, ) = to.call{value: valueLeft}(""); + require(sent, "pm: sweep send"); + valueLeft = 0; + } + } else if (action == V4Actions.DECREASE_LIQUIDITY) { + _handleDecrease(params[i]); + } else if (action == V4Actions.BURN_POSITION) { + _handleBurn(params[i]); + } else if (action == V4Actions.TAKE_PAIR) { + _handleTakePair(params[i]); + } else { + revert("pm: unsupported action"); + } + } + } + + function _handleMint(bytes memory param) internal returns (uint256 valueLeft) { + ( + PoolKey memory key, + int24 tickLower, + int24 tickUpper, + uint256 liquidity, + uint128 amount0Max, + uint128 amount1Max, + address owner /* hookData */, + + ) = abi.decode(param, (PoolKey, int24, int24, uint256, uint128, uint128, address, bytes)); + + uint128 use0 = mintUse0 == type(uint128).max ? amount0Max : mintUse0; + uint128 use1 = mintUse1 == type(uint128).max ? amount1Max : mintUse1; + require(use0 <= amount0Max && use1 <= amount1Max, "pm: max exceeded"); + + uint256 tokenId = nextTokenId++; + address recordedOwner = mintOwnerOverride == address(0) ? owner : mintOwnerOverride; + positionsData[tokenId] = Position( + recordedOwner, + key, + tickLower, + tickUpper, + uint128(liquidity), + 0, + 0, + use0, + use1, + true + ); + pendingKey = key; + pendingSettle0 = use0; + pendingSettle1 = use1; + valueLeft = msg.value; + } + + function _handleSettlePair(bytes memory param, uint256 valueIn) internal returns (uint256 valueLeft) { + (address currency0, address currency1) = abi.decode(param, (address, address)); + valueLeft = valueIn; + if (pendingSettle0 > 0) { + if (currency0 == address(0)) { + require(valueLeft >= pendingSettle0, "pm: insufficient value"); + valueLeft -= pendingSettle0; + } else { + PERMIT2.transferFrom(msg.sender, address(this), uint160(pendingSettle0), currency0); + } + } + if (pendingSettle1 > 0) { + PERMIT2.transferFrom(msg.sender, address(this), uint160(pendingSettle1), currency1); + } + pendingSettle0 = 0; + pendingSettle1 = 0; + } + + function _handleDecrease(bytes memory param) internal { + (uint256 tokenId, uint256 liquidity, uint128 amount0Min, uint128 amount1Min, ) = abi.decode( + param, + (uint256, uint256, uint128, uint128, bytes) + ); + Position storage p = positionsData[tokenId]; + require(p.exists, "pm: unknown token"); + uint256 amount0; + uint256 amount1; + if (liquidity == 0) { + // Fee-only delta: accrued fees become the take amounts. + amount0 = p.owed0; + amount1 = p.owed1; + p.owed0 = 0; + p.owed1 = 0; + } else { + require(liquidity <= p.liquidity, "pm: liquidity"); + amount0 = (uint256(p.principal0) * liquidity) / p.liquidity; + amount1 = (uint256(p.principal1) * liquidity) / p.liquidity; + p.principal0 -= uint128(amount0); + p.principal1 -= uint128(amount1); + p.liquidity -= uint128(liquidity); + } + require(amount0 >= amount0Min && amount1 >= amount1Min, "pm: slippage"); + pendingKey = p.key; + pendingTake0 += amount0; + pendingTake1 += amount1; + } + + function _handleBurn(bytes memory param) internal { + (uint256 tokenId, uint128 amount0Min, uint128 amount1Min, ) = abi.decode( + param, + (uint256, uint128, uint128, bytes) + ); + Position storage p = positionsData[tokenId]; + require(p.exists, "pm: unknown token"); + // BURN auto-decreases all remaining liquidity; any unclaimed fees ride + // along in the final delta (the handler harvests them beforehand). + uint256 amount0 = uint256(p.principal0) + p.owed0; + uint256 amount1 = uint256(p.principal1) + p.owed1; + require(amount0 >= amount0Min && amount1 >= amount1Min, "pm: slippage"); + pendingKey = p.key; + pendingTake0 += amount0; + pendingTake1 += amount1; + delete positionsData[tokenId]; + } + + function _handleTakePair(bytes memory param) internal { + (address currency0, address currency1, address recipient) = abi.decode(param, (address, address, address)); + uint256 amount0 = pendingTake0; + uint256 amount1 = pendingTake1; + pendingTake0 = 0; + pendingTake1 = 0; + if (amount0 > 0) { + if (currency0 == address(0)) { + (bool sent, ) = recipient.call{value: amount0}(""); + require(sent, "pm: take send"); + } else { + require(IERC20(currency0).transfer(recipient, amount0), "pm: take0"); + } + } + if (amount1 > 0) { + require(IERC20(currency1).transfer(recipient, amount1), "pm: take1"); + } + } +} + +/// @notice UniversalRouter V4_SWAP stub: decodes the handler's on-chain-built +/// exact-input-single plan, pulls the input (ERC20 via MockPermit2, +/// native via call value) and pays a configurable output to the +/// caller. `output == 0` drives the SwapFailed branch. +contract MockUniversalRouter { + MockPermit2 public immutable PERMIT2; + + uint256 public output; + mapping(address => uint256) public outputFor; + bool public enforceMinOut; + /// @dev Last min-out the plan actually carried — what a TWAP-floored + /// handler is supposed to have raised it to. + uint256 public lastAmountOutMinimum; + uint256 public lastAmountIn; + uint256 public callCount; + + constructor(MockPermit2 _permit2) { + PERMIT2 = _permit2; + } + + receive() external payable {} + + function setOutput(uint256 newOutput) external { + output = newOutput; + } + + function setOutputFor(address tokenOut, uint256 newOutput) external { + outputFor[tokenOut] = newOutput; + } + + function setEnforceMinOut(bool value) external { + enforceMinOut = value; + } + + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable { + callCount++; + require(block.timestamp <= deadline, "ur: deadline"); + require(commands.length == 1 && uint8(commands[0]) == 0x10, "ur: not V4_SWAP"); + (bytes memory actions, bytes[] memory params) = abi.decode(inputs[0], (bytes, bytes[])); + require(actions.length == 3, "ur: plan shape"); + require(uint8(actions[0]) == V4Actions.SWAP_EXACT_IN_SINGLE, "ur: action0"); + require(uint8(actions[1]) == V4Actions.SETTLE_ALL, "ur: action1"); + require(uint8(actions[2]) == V4Actions.TAKE_ALL, "ur: action2"); + + ExactInputSingleParams memory swap = abi.decode(params[0], (ExactInputSingleParams)); + (address settleCurrency, uint256 settleAmount) = abi.decode(params[1], (address, uint256)); + (address takeCurrency, uint256 takeMin) = abi.decode(params[2], (address, uint256)); + + address tokenIn = swap.zeroForOne ? swap.poolKey.currency0 : swap.poolKey.currency1; + address tokenOut = swap.zeroForOne ? swap.poolKey.currency1 : swap.poolKey.currency0; + require(settleCurrency == tokenIn && takeCurrency == tokenOut, "ur: currency mismatch"); + require(settleAmount == swap.amountIn, "ur: settle amount"); + + if (tokenIn == address(0)) { + require(msg.value == swap.amountIn, "ur: value"); + } else { + PERMIT2.transferFrom(msg.sender, address(this), uint160(uint256(swap.amountIn)), tokenIn); + } + + lastAmountOutMinimum = swap.amountOutMinimum; + lastAmountIn = swap.amountIn; + uint256 amountOut = outputFor[tokenOut] != 0 ? outputFor[tokenOut] : output; + if (enforceMinOut) { + require(amountOut >= takeMin && amountOut >= swap.amountOutMinimum, "ur: too little received"); + } + if (amountOut > 0) { + if (tokenOut == address(0)) { + (bool sent, ) = msg.sender.call{value: amountOut}(""); + require(sent, "ur: send"); + } else { + require(IERC20(tokenOut).transfer(msg.sender, amountOut), "ur: transfer"); + } + } + } +} diff --git a/contracts/mocks/TwapOracleProbe.sol b/contracts/mocks/TwapOracleProbe.sol new file mode 100644 index 0000000..c525f0d --- /dev/null +++ b/contracts/mocks/TwapOracleProbe.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {TwapOracle} from "../yield/libraries/TwapOracle.sol"; +import "../common/Types.sol"; + +/// @notice Test-only window onto TwapOracle. The library is reached in +/// production only through a delegatecalled handler mid-swap, which is +/// a poor place to assert on a price; this exposes it directly so each +/// degradation (thin history, a stale newest observation, a window the +/// pool cannot cover) can be driven on its own. +contract TwapOracleProbe { + function meanTick(address pool, uint32 window, uint16 minCardinality) external view returns (int24) { + return TwapOracle.meanTick(TwapConfig({pool: pool, window: window, minCardinality: minCardinality})); + } + + function quote( + address pool, + uint32 window, + uint16 minCardinality, + address tokenIn, + address tokenOut, + uint256 amountIn + ) external view returns (uint256) { + return + TwapOracle.quote( + TwapConfig({pool: pool, window: window, minCardinality: minCardinality}), + tokenIn, + tokenOut, + amountIn + ); + } + + function maxStalenessDivisor() external pure returns (uint32) { + return TwapOracle.MAX_STALENESS_DIVISOR; + } +} diff --git a/contracts/yield/SafeYieldManager.sol b/contracts/yield/SafeYieldManager.sol new file mode 100644 index 0000000..741e84b --- /dev/null +++ b/contracts/yield/SafeYieldManager.sol @@ -0,0 +1,1031 @@ +// 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 {Pausable} from "@openzeppelin/contracts/utils/Pausable.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 {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ISafe} from "../interfaces/safe/ISafe.sol"; +import {IProtocolRegistry} from "../interfaces/IProtocolRegistry.sol"; +import {IYieldHandler, OpenLpParams, CloseLpParams, CollectLpParams, WithdrawLpParams, OpenLpInKindParams} from "../interfaces/IYieldHandler.sol"; +import {TokenReturnLib} from "./libraries/TokenReturnLib.sol"; +import {TwapOracle} from "./libraries/TwapOracle.sol"; +import {IUniswapV3Pool} from "../interfaces/uniswapV3/IUniswapV3Pool.sol"; +import {YieldStorage} from "./handlers/YieldStorage.sol"; +import "../common/Types.sol"; + +interface ITimelockControllerLike { + function getMinDelay() external view returns (uint256); +} + +/// @notice Parameters for atomically moving a full position to another pool +/// of the SAME token pair (different protocol, fee tier / tick +/// spacing, or any mix). The move is IN KIND: the withdraw leg's +/// token amounts become the open leg's input directly — no swaps, so +/// the only price protection is the decrease minimums (withdraw leg) +/// and the mint minimums (open leg). +struct SwitchLpParams { + address onBehalfOf; + uint256 tokenId; + // withdraw leg (always a full exit) + uint256 decreaseAmount0Min; + uint256 decreaseAmount1Min; + // open leg + int24 tickLower; + int24 tickUpper; + uint256 mintAmount0Min; + uint256 mintAmount1Min; + bytes lpPoolParam; + uint256 deadline; +} + +/// @title SafeYieldManager +/// @notice Single Safe-module entry point for all yield (LP) protocols — +/// the yield-side counterpart of SafeDebtManager. Users enable this +/// one contract as a Safe module; per-protocol mechanics live in +/// stateless handlers (UniV3YieldHandler, AerodromeYieldHandler, …) +/// invoked via delegatecall, so adding a protocol (e.g. Uniswap V4) +/// is a handler deployment + `setYieldHandler`, not a new module +/// every user must enable. +/// @dev Shared mutable state (basis bookkeeping, fees, allow-lists) lives +/// in the ERC-7201 `YieldStorage` namespace so handler delegatecode +/// can never collide with this contract's inherited storage. +/// Coexists with the previously deployed standalone +/// RatehopperUniV3Positions contract: positions opened there have no +/// basis recorded here and are rejected with `UnknownPosition` (and +/// vice versa). +contract SafeYieldManager is AccessControl, ReentrancyGuard, Pausable, YieldStorage { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + IProtocolRegistry public immutable REGISTRY; + IERC20 public immutable USDC; + /// @notice Wrapped native token used to validate and quote the + /// `address(0)` Uniswap V4 native-currency reference key. + IERC20 public immutable WETH; + /// @notice Immutable TimelockController address. Critical setters + /// require `msg.sender == timelock` so a DEFAULT_ADMIN_ROLE + /// holder cannot self-grant CRITICAL_ROLE and bypass the delay. + address public immutable timelock; + uint16 public immutable MAX_FEE_BPS; + + /// @notice Absolute ceiling on what the admin can set `maxSlippageBps` to. + uint16 public constant MAX_SETTABLE_SLIPPAGE_BPS = 1000; + + /// @notice Floors on the price reference that no role can lower. Reference + /// changes are additionally restricted to the critical timelock. + /// @dev 30 minutes at Base's ~2s blocks is ~900 blocks an attacker must + /// hold an off-market price against arbitrage. + uint32 public constant MIN_TWAP_WINDOW = 1800; + /// @dev A pool needs stored observations to have any history at all; the + /// cardinality-1 pools measured on Base answer queries with live spot. + uint16 public constant MIN_TWAP_CARDINALITY = 60; + + address public pauser; + mapping(uint8 => address) public yieldHandlers; + mapping(uint8 => bool) public protocolEnabledForOpen; + mapping(uint8 => bool) public protocolEnabledForClose; + + event YieldHandlerUpdated(uint8 indexed protocol, address indexed oldHandler, address indexed newHandler); + event ProtocolStatusChanged(uint8 indexed protocol, bool indexed forOpen, bool enabled); + 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 PoolParamAllowedUpdated(uint8 indexed protocol, bytes poolParam, bool previousAllowed, bool newAllowed); + event MinPoolLiquidityUpdated(uint8 indexed protocol, uint128 previousValue, uint128 newValue); + event MinPositionLiquidityUpdated(uint8 indexed protocol, uint128 previousValue, uint128 newValue); + event TokenRescued(address indexed token, address indexed recipient, uint256 amount); + event NftRescued(address indexed token, address indexed recipient, uint256 indexed tokenId); + event PauserUpdated(address indexed previousPauser, address indexed newPauser); + + error HandlerNotSet(); + error ProtocolDisabled(); + error LengthMismatch(); + error HandlerCallFailed(); + error InvalidHandler(); + error HandlerProtocolMismatch(uint8 expected, uint8 actual); + error InvalidTimelock(); + error TokenNotWhitelisted(address token); + + /// @notice Allows only the registry operator or the Safe itself. + modifier onlyOperatorOrSafe(address _onBehalfOf) { + if (_onBehalfOf == address(0)) revert ZeroAddress(); + if (msg.sender != _onBehalfOf && msg.sender != REGISTRY.safeOperator()) revert NotAuthorized(); + _; + } + + modifier onlyPauser() { + if (msg.sender != pauser) revert NotAuthorized(); + _; + } + + modifier onlyTimelockCriticalRole() { + if (msg.sender != timelock) revert OnlyTimelock(); + _checkRole(CRITICAL_ROLE); + _; + } + + constructor( + IProtocolRegistry _registry, + IERC20 _usdc, + IERC20 _weth, + uint8[] memory _protocols, + address[] memory _handlers, + bytes[][] memory _allowedPoolParams, + uint128[] memory _minPoolLiquidity, + uint128[] memory _minPositionLiquidity, + TwapSeed[] memory _twapSeeds, + address _treasury, + uint16 _performanceFeeBps, + uint16 _feeCollectBps, + uint16 _maxFeeBps, + address _initialAdmin, + address _timelock, + address _pauser + ) { + if (address(_registry) == address(0)) revert ZeroAddress(); + if (address(_usdc) == address(0)) revert ZeroAddress(); + if (address(_weth) == address(0) || address(_weth) == address(_usdc)) revert ZeroAddress(); + if (_initialAdmin == address(0)) revert ZeroAddress(); + if (_timelock == address(0)) revert ZeroAddress(); + if (_timelock.code.length == 0) revert InvalidTimelock(); + try ITimelockControllerLike(_timelock).getMinDelay() returns (uint256 minDelay) { + if (minDelay == 0) revert InvalidTimelock(); + } catch { + revert InvalidTimelock(); + } + if (_pauser == address(0)) revert ZeroAddress(); + if (_treasury == address(0)) revert InvalidTreasury(); + if (_maxFeeBps > 10_000) revert FeeAboveMax(); + if (_performanceFeeBps > _maxFeeBps) revert FeeAboveMax(); + if (_feeCollectBps > _maxFeeBps) revert FeeAboveMax(); + if ( + _handlers.length != _protocols.length || + _allowedPoolParams.length != _protocols.length || + _minPoolLiquidity.length != _protocols.length || + _minPositionLiquidity.length != _protocols.length + ) revert LengthMismatch(); + + REGISTRY = _registry; + USDC = _usdc; + WETH = _weth; + timelock = _timelock; + MAX_FEE_BPS = _maxFeeBps; + pauser = _pauser; + + YieldLayout storage $ = _yieldStorage(); + $.treasury = _treasury; + $.performanceFeeBps = _performanceFeeBps; + $.feeCollectBps = _feeCollectBps; + $.maxSlippageBps = 300; + + // References are installed BEFORE any protocol is registered, so the + // allow-listing below can hold every pool param to the same standard + // `setPoolParamAllowed` does. Without this the invariant "allow-listed + // implies a live reference" would only start at the first post-deploy + // change, and a fresh deployment would sit open-enabled but unable to + // swap until a timelock proposal executed — days later. + for (uint256 i = 0; i < _twapSeeds.length; i++) { + TwapSeed memory seed = _twapSeeds[i]; + _storeTwapConfig($, seed.token, seed.config.pool, seed.config.window, seed.config.minCardinality); + } + + for (uint256 i = 0; i < _protocols.length; i++) { + _validateHandler(_protocols[i], _handlers[i]); + yieldHandlers[_protocols[i]] = _handlers[i]; + protocolEnabledForOpen[_protocols[i]] = true; + protocolEnabledForClose[_protocols[i]] = true; + emit YieldHandlerUpdated(_protocols[i], address(0), _handlers[i]); + emit ProtocolStatusChanged(_protocols[i], true, true); + emit ProtocolStatusChanged(_protocols[i], false, true); + + $.minPoolLiquidity[_protocols[i]] = _minPoolLiquidity[i]; + $.minPositionLiquidity[_protocols[i]] = _minPositionLiquidity[i]; + emit MinPoolLiquidityUpdated(_protocols[i], 0, _minPoolLiquidity[i]); + emit MinPositionLiquidityUpdated(_protocols[i], 0, _minPositionLiquidity[i]); + + for (uint256 j = 0; j < _allowedPoolParams[i].length; j++) { + _requirePoolParamTwapReferences($, _handlers[i], _allowedPoolParams[i][j]); + _storePoolParamAllowed($, _protocols[i], _allowedPoolParams[i][j], true); + } + } + + _grantRole(DEFAULT_ADMIN_ROLE, _initialAdmin); + _grantRole(CRITICAL_ROLE, _timelock); + // Prevent DEFAULT_ADMIN_ROLE from bypassing timelock-only setters. + _setRoleAdmin(CRITICAL_ROLE, CRITICAL_ROLE); + + emit TreasuryUpdated(address(0), _treasury); + emit PerformanceFeeBpsUpdated(0, _performanceFeeBps); + emit FeeCollectBpsUpdated(0, _feeCollectBps); + emit MaxSlippageBpsUpdated(0, 300); + emit PauserUpdated(address(0), _pauser); + } + + // ───────────────────────────────────────────────────────────────────── + // LP lifecycle + // ───────────────────────────────────────────────────────────────────── + + /// @notice Open an LP position on `protocol`. The Safe must have enabled + /// this contract as a module and hold `params.usdcAmount` USDC. + /// @dev The only lifecycle entry gated by `whenNotPaused`: pausing the + /// contract yields an exit-only mode, never trapping positions. + function openLp( + uint8 protocol, + OpenLpParams calldata params + ) external nonReentrant whenNotPaused onlyOperatorOrSafe(params.onBehalfOf) returns (uint256 tokenId) { + if (block.timestamp > params.deadline) revert DeadlineExpired(); + if (params.usdcAmount == 0) revert InvalidUsdcAmount(); + address handler = yieldHandlers[protocol]; + if (handler == address(0)) revert HandlerNotSet(); + if (!protocolEnabledForOpen[protocol]) revert ProtocolDisabled(); + + _requireWhitelistedPoolTokens(handler, params.lpPoolParam); + + bytes memory ret = _delegateToHandler(handler, abi.encodeCall(IYieldHandler.openLp, (params))); + uint128 basisUsd6; + uint128 used0; + uint128 used1; + (tokenId, basisUsd6, used0, used1) = abi.decode(ret, (uint256, uint128, uint128, uint128)); + + // Persist the open-time basis so closes always price against an + // on-chain value neither the Safe nor the operator can attest, and + // pin the handler so later `setYieldHandler` calls never apply + // retroactively to this position. + YieldLayout storage $ = _yieldStorage(); + $.residualBasisUsd6Of[protocol][tokenId] = basisUsd6; + $.positionHandlerOf[protocol][tokenId] = handler; + + emit PositionOpened(params.onBehalfOf, protocol, tokenId, params.usdcAmount, used0, used1, basisUsd6); + } + + /// @notice Close (partially or fully) an LP position opened through this + /// contract. Charges `performanceFeeBps` on realized profit only. + /// @dev Deliberately NOT `whenNotPaused` and NOT gated on the current + /// `yieldHandlers` registration — exits run through the handler + /// pinned at open time and stay available while the contract is + /// paused (exit-only mode). Only the per-protocol + /// `protocolEnabledForClose` switch can stop them (e.g. a + /// compromised handler). + function closeLp( + uint8 protocol, + CloseLpParams calldata params + ) external nonReentrant onlyOperatorOrSafe(params.onBehalfOf) { + if (block.timestamp > params.deadline) revert DeadlineExpired(); + if (params.exitBps == 0 || params.exitBps > 10_000) revert InvalidExitBps(); + if (!protocolEnabledForClose[protocol]) revert ProtocolDisabled(); + + YieldLayout storage $ = _yieldStorage(); + (uint128 residualBasis, address handler) = _pinnedPosition($, protocol, params.tokenId); + + uint128 basisForExit = Math.mulDiv(uint256(residualBasis), uint256(params.exitBps), 10_000).toUint128(); + // Profit an earlier in-kind switch already handed back leaves with the + // same share of the position it was attached to. + uint128 carry = $.carryProfitUsd6Of[protocol][params.tokenId]; + uint128 carryForExit = Math.mulDiv(uint256(carry), uint256(params.exitBps), 10_000).toUint128(); + if (params.exitBps == 10_000) { + delete $.residualBasisUsd6Of[protocol][params.tokenId]; + delete $.positionHandlerOf[protocol][params.tokenId]; + delete $.carryProfitUsd6Of[protocol][params.tokenId]; + } else { + $.residualBasisUsd6Of[protocol][params.tokenId] = residualBasis - basisForExit; + $.carryProfitUsd6Of[protocol][params.tokenId] = carry - carryForExit; + } + + bytes memory ret = _delegateToHandler(handler, abi.encodeCall(IYieldHandler.closeLp, (params, basisForExit))); + uint128 currentValueUsd6 = abi.decode(ret, (uint128)); + + // Lifecycle profit, not just this exit's proceeds: value realized here + // PLUS value already withdrawn as switch residue, against the cost the + // position still carries. Without the carry term, a user could switch + // repeatedly, take profit out as residue each time, and close a + // zero-profit position paying nothing. + // A failed treasury transfer must never block an exit. + uint128 feeUsd6 = 0; + uint256 realized = uint256(currentValueUsd6) + uint256(carryForExit); + if (realized > basisForExit) { + uint256 profit = realized - uint256(basisForExit); + feeUsd6 = ((profit * $.performanceFeeBps) / 10_000).toUint128(); + if (feeUsd6 > 0 && !_trySafeTransfer(params.onBehalfOf, address(USDC), $.treasury, uint256(feeUsd6))) { + emit FeeTransferFailed(params.onBehalfOf, params.tokenId, feeUsd6); + feeUsd6 = 0; + } + } + + emit PositionClosed( + params.onBehalfOf, + protocol, + params.tokenId, + basisForExit, + currentValueUsd6, + feeUsd6, + params.exitBps, + carryForExit + ); + } + + /// @notice Fully exit a position IN KIND: liquidity and fees come out as + /// the pool's own two tokens and stay on the Safe. No swap, so no + /// router and no price reference are involved. + /// @dev This is the exit that must keep working when nothing else does. + /// `closeLp` sells the position to USDC, which means it depends on + /// the TWAP floor and therefore on a reference pool that could lose + /// depth, lose observation history, or simply be unconfigured for a + /// newly listed token. Without an oracle-free path, any of those + /// would strand a position — so this function deliberately reads + /// NO price, and its only gate is the same `protocolEnabledForClose` + /// switch that guards every other exit. + /// + /// No PERFORMANCE fee is charged, for the same reason `switchLp` + /// charges none: nothing is realized in USDC, so there is no profit + /// to measure. `feeCollectBps` is unaffected and still applies to + /// the fees harvested on the way out. Basis and carry are released + /// rather than carried, because the position ceases to exist. That + /// does hand users a performance-fee-free way out, which changes + /// nothing economically — a Safe owner can already exit around the + /// module entirely (see docs/SECURITY_MODEL.md). + function withdrawLp( + uint8 protocol, + WithdrawLpParams calldata params + ) external nonReentrant onlyOperatorOrSafe(params.onBehalfOf) returns (uint256 amount0, uint256 amount1) { + if (block.timestamp > params.deadline) revert DeadlineExpired(); + if (!protocolEnabledForClose[protocol]) revert ProtocolDisabled(); + + YieldLayout storage $ = _yieldStorage(); + (uint128 residualBasis, address handler) = _pinnedPosition($, protocol, params.tokenId); + uint128 releasedCarry = $.carryProfitUsd6Of[protocol][params.tokenId]; + delete $.residualBasisUsd6Of[protocol][params.tokenId]; + delete $.positionHandlerOf[protocol][params.tokenId]; + delete $.carryProfitUsd6Of[protocol][params.tokenId]; + + bytes memory ret = _delegateToHandler(handler, abi.encodeCall(IYieldHandler.withdrawLp, (params))); + (, , amount0, amount1) = abi.decode(ret, (address, address, uint256, uint256)); + + emit PositionWithdrawn( + params.onBehalfOf, + protocol, + params.tokenId, + residualBasis, + releasedCarry, + amount0, + amount1 + ); + } + + /// @notice Atomically move a full position to another pool of the SAME + /// token pair — a different protocol, a different fee tier / tick + /// spacing, or any mix. The withdraw leg takes the position out + /// IN KIND through the pinned handler (no swaps); the open leg + /// redeploys those exact token amounts through the target + /// protocol's current handler (no swaps). Amounts the destination + /// mint cannot consume stay in the Safe. + /// @dev An in-kind switch charges NO performance fee. Amounts the new + /// position cannot consume are nevertheless value already returned + /// to the Safe, so they repay basis first and any excess is carried + /// as realized profit to the replacement position. The final + /// `closeLp` settles that carry together with the replacement's + /// realized USDC value. + /// A switch opens new exposure, hence `whenNotPaused` (unlike + /// exits) plus BOTH per-protocol switches: + /// `protocolEnabledForClose[from]` and `protocolEnabledForOpen[to]`. + function switchLp( + uint8 fromProtocol, + uint8 toProtocol, + SwitchLpParams calldata params + ) external nonReentrant whenNotPaused onlyOperatorOrSafe(params.onBehalfOf) returns (uint256 newTokenId) { + if (block.timestamp > params.deadline) revert DeadlineExpired(); + if (!protocolEnabledForClose[fromProtocol]) revert ProtocolDisabled(); + address openHandler = yieldHandlers[toProtocol]; + if (openHandler == address(0)) revert HandlerNotSet(); + if (!protocolEnabledForOpen[toProtocol]) revert ProtocolDisabled(); + _requireWhitelistedPoolTokens(openHandler, params.lpPoolParam); + + YieldLayout storage $ = _yieldStorage(); + (uint128 residualBasis, address closeHandler) = _pinnedPosition($, fromProtocol, params.tokenId); + delete $.residualBasisUsd6Of[fromProtocol][params.tokenId]; + delete $.positionHandlerOf[fromProtocol][params.tokenId]; + + (address token0, address token1, uint256 amount0, uint256 amount1) = _switchWithdrawLeg(closeHandler, params); + uint128 used0; + uint128 used1; + (newTokenId, used0, used1) = _switchOpenLeg(openHandler, params, token0, token1, amount0, amount1); + + (uint128 newBasis, uint128 newCarry) = _settleSwitchResidue( + SwitchResidue({ + onBehalfOf: params.onBehalfOf, + protocol: toProtocol, + newTokenId: newTokenId, + token0: token0, + token1: token1, + residual0: amount0 - used0, + residual1: amount1 - used1, + basis: residualBasis, + carry: $.carryProfitUsd6Of[fromProtocol][params.tokenId] + }) + ); + delete $.carryProfitUsd6Of[fromProtocol][params.tokenId]; + + $.residualBasisUsd6Of[toProtocol][newTokenId] = newBasis; + $.carryProfitUsd6Of[toProtocol][newTokenId] = newCarry; + $.positionHandlerOf[toProtocol][newTokenId] = openHandler; + + emit PositionSwitched( + params.onBehalfOf, + fromProtocol, + toProtocol, + params.tokenId, + newTokenId, + newBasis, + amount0, + amount1, + used0, + used1 + ); + } + + /// @dev Inputs to `_settleSwitchResidue`, grouped so the helper takes one + /// argument rather than nine (viaIR stack, same reason as MintArgs). + struct SwitchResidue { + address onBehalfOf; + uint8 protocol; + uint256 newTokenId; + address token0; + address token1; + uint256 residual0; + uint256 residual1; + uint128 basis; + uint128 carry; + } + + /// @dev Account for what an in-kind switch could NOT redeploy. + /// + /// A concentrated-liquidity mint consumes the two sides only in the + /// ratio its range demands, so it stops at whichever side runs out and + /// leaves the other on the Safe. That residue is real money the user + /// now holds — measured on Base at 2.75% of the token0 side when the + /// range is carried over, and 9.55% across protocols. + /// + /// Treated as a withdrawal: it repays cost basis first, and whatever + /// exceeds the basis is profit already taken. Booking that excess as + /// `carry` is what stops a user from switching repeatedly, drawing + /// profit out as residue each time, and closing a position that looks + /// break-even. + /// + /// Valued at the reference TWAP, never at spot: spot would let anyone + /// who can nudge a pool under-report the residue and shrink the fee + /// the final close charges. + function _settleSwitchResidue(SwitchResidue memory r) internal returns (uint128 newBasis, uint128 newCarry) { + uint256 residualUsd6 = _valueInUsd6(r.token0, r.residual0) + _valueInUsd6(r.token1, r.residual1); + + if (residualUsd6 >= uint256(r.basis)) { + newBasis = 0; + newCarry = (uint256(r.carry) + (residualUsd6 - uint256(r.basis))).toUint128(); + } else { + newBasis = r.basis - residualUsd6.toUint128(); + newCarry = r.carry; + } + + emit SwitchResidueSettled( + r.onBehalfOf, + r.protocol, + r.newTokenId, + r.residual0, + r.residual1, + residualUsd6.toUint128(), + newBasis, + newCarry + ); + } + + /// @dev USDC value of a token amount at the reference TWAP. USDC is its own + /// unit, and nothing is valued when there is nothing to value — a + /// switch that redeploys cleanly needs no price at all. + function _valueInUsd6(address token, uint256 amount) internal view returns (uint256) { + if (amount == 0) return 0; + if (token == address(USDC)) return amount; + TwapConfig memory cfg = _yieldStorage().twapConfigOf[token]; + if (cfg.pool == address(0)) revert TwapOracle.TwapNotConfigured(token); + return TwapOracle.quote(cfg, token, address(USDC), amount); + } + + /// @dev Full in-kind withdrawal of the old position via its pinned + /// handler; the pool tokens land on the Safe unswapped (a native + /// side arrives wrapped as its ERC20). + function _switchWithdrawLeg( + address handler, + SwitchLpParams calldata p + ) internal returns (address token0, address token1, uint256 amount0, uint256 amount1) { + bytes memory ret = _delegateToHandler( + handler, + abi.encodeCall( + IYieldHandler.withdrawLp, + ( + WithdrawLpParams({ + onBehalfOf: p.onBehalfOf, + tokenId: p.tokenId, + decreaseAmount0Min: p.decreaseAmount0Min, + decreaseAmount1Min: p.decreaseAmount1Min, + deadline: p.deadline + }) + ) + ) + ); + (token0, token1, amount0, amount1) = abi.decode(ret, (address, address, uint256, uint256)); + } + + /// @dev Open the replacement position through the target protocol's + /// current handler with the exact tokens the withdraw leg delivered. + /// The handler rejects a pair mismatch (`WrongTokenPair`), so a + /// switch can never silently deploy unrelated Safe funds. + function _switchOpenLeg( + address handler, + SwitchLpParams calldata p, + address token0, + address token1, + uint256 amount0, + uint256 amount1 + ) internal returns (uint256 tokenId, uint128 used0, uint128 used1) { + bytes memory ret = _delegateToHandler( + handler, + abi.encodeCall( + IYieldHandler.openLpInKind, + ( + OpenLpInKindParams({ + onBehalfOf: p.onBehalfOf, + token0: token0, + token1: token1, + amount0: amount0, + amount1: amount1, + tickLower: p.tickLower, + tickUpper: p.tickUpper, + mintAmount0Min: p.mintAmount0Min, + mintAmount1Min: p.mintAmount1Min, + lpPoolParam: p.lpPoolParam, + deadline: p.deadline + }) + ) + ) + ); + (tokenId, used0, used1) = abi.decode(ret, (uint256, uint128, uint128)); + } + + /// @dev Registry token whitelist gate on an open-leg pool pair, resolved + /// through the handler's pure `poolTokens` decode (staticcall — the + /// handler never runs in its own storage context). token0 == + /// address(0) is Uniswap V4's native-ETH currency sentinel, not a + /// token — skip it (token1 can never be zero: currencies sort + /// ascending). Applies to openLp and the switchLp open leg only; + /// exits must never brick on a later de-listing. + function _requireWhitelistedPoolTokens(address handler, bytes calldata lpPoolParam) internal view { + (address token0, address token1) = _decodePoolTokens(handler, lpPoolParam); + if (token0 != address(0) && !REGISTRY.whitelistedTokens(token0)) revert TokenNotWhitelisted(token0); + if (!REGISTRY.whitelistedTokens(token1)) revert TokenNotWhitelisted(token1); + } + + function _decodePoolTokens( + address handler, + bytes memory lpPoolParam + ) internal view returns (address token0, address token1) { + // Same revert convention as _delegateToHandler: bubble reasoned + // reverts (e.g. a malformed pool param failing the handler's decode), + // wrap empty ones in HandlerCallFailed. + (bool ok, bytes memory ret) = handler.staticcall(abi.encodeCall(IYieldHandler.poolTokens, (lpPoolParam))); + if (!ok) { + if (ret.length > 0) Address.verifyCallResult(ok, ret); + revert HandlerCallFailed(); + } + return abi.decode(ret, (address, address)); + } + + function _requirePoolParamTwapReferences( + YieldLayout storage $, + address handler, + bytes memory poolParam + ) internal view { + (address token0, address token1) = _decodePoolTokens(handler, poolParam); + _requireTwapReference($, token0); + if (token1 != token0) _requireTwapReference($, token1); + } + + function _requireTwapReference(YieldLayout storage $, address token) internal view { + if (token == address(USDC)) return; + TwapConfig memory cfg = $.twapConfigOf[token]; + if (cfg.pool == address(0)) revert TwapOracle.TwapNotConfigured(token); + TwapOracle.meanTick(cfg); + } + + function _requireProtocolTwapReferences(uint8 protocol) internal view { + YieldLayout storage $ = _yieldStorage(); + address handler = yieldHandlers[protocol]; + if (handler == address(0)) revert HandlerNotSet(); + bytes[] storage poolParams = $.allowedPoolParams[protocol]; + for (uint256 i = 0; i < poolParams.length; i++) { + _requirePoolParamTwapReferences($, handler, poolParams[i]); + } + } + + /// @notice Harvest accrued LP fees of a position opened through this + /// contract without exiting it. + /// @dev Same exit-friendly gating as `closeLp`: runs through the + /// pinned handler, unaffected by pause, stoppable only via + /// `protocolEnabledForClose`. + function collectLp( + uint8 protocol, + CollectLpParams calldata params + ) external nonReentrant onlyOperatorOrSafe(params.onBehalfOf) { + if (!protocolEnabledForClose[protocol]) revert ProtocolDisabled(); + // Only harvest positions this contract manages; otherwise any + // Safe-owned NFT could be routed through to skim feeCollectBps. + (, address handler) = _pinnedPosition(_yieldStorage(), protocol, params.tokenId); + + _delegateToHandler(handler, abi.encodeCall(IYieldHandler.collectLp, (params))); + } + + // ───────────────────────────────────────────────────────────────────── + // Views + // ───────────────────────────────────────────────────────────────────── + + function residualBasisUsd6Of(uint8 protocol, uint256 tokenId) external view returns (uint128) { + return _yieldStorage().residualBasisUsd6Of[protocol][tokenId]; + } + + function positionHandlerOf(uint8 protocol, uint256 tokenId) external view returns (address) { + return _yieldStorage().positionHandlerOf[protocol][tokenId]; + } + + /// @notice Stake pool a position is pinned to, or address(0) when it was never + /// staked. The pin survives the temporary unstake a partial close does, + /// so it also answers "where does this position go back to". + function stakePoolOf(uint8 protocol, uint256 tokenId) external view returns (address) { + return _yieldStorage().stakePoolOf[protocol][tokenId]; + } + + function isPoolParamAllowed(uint8 protocol, bytes calldata poolParam) external view returns (bool) { + return _yieldStorage().allowedPoolKey[protocol][keccak256(poolParam)]; + } + + function allowedPoolParamCount(uint8 protocol) external view returns (uint256) { + return _yieldStorage().allowedPoolParams[protocol].length; + } + + function allowedPoolParamAt(uint8 protocol, uint256 index) external view returns (bytes memory) { + return _yieldStorage().allowedPoolParams[protocol][index]; + } + + /// @notice Profit an in-kind switch already handed back to the Safe that + /// this position still owes a performance fee on. Charged by + /// `closeLp`, prorated with `exitBps`. + function carryProfitUsd6Of(uint8 protocol, uint256 tokenId) external view returns (uint128) { + return _yieldStorage().carryProfitUsd6Of[protocol][tokenId]; + } + + /// @notice Price reference configured for `token`, or an all-zero config. + function twapConfigOf(address token) external view returns (TwapConfig memory) { + return _yieldStorage().twapConfigOf[token]; + } + + /// @notice What the reference TWAP says `amountIn` of `tokenIn` is worth in + /// `tokenOut`, using the non-USDC side's configured pool. + /// @dev Exposed so operators can size `amountOutMin` against the very + /// value the contract will check it against, instead of against a + /// quote the contract has no way to verify. Reverts exactly where + /// the swap-time check would. + function twapQuote(address tokenIn, address tokenOut, uint256 amountIn) external view returns (uint256) { + return _twapQuote(tokenIn, tokenOut, amountIn); + } + + /// @notice Effective independent minimum before applying a tighter caller + /// minimum. Native ETH is represented by `address(0)` and quoted + /// through WETH while retaining its own reference key. + function twapMinimumOut( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint16 slippageBps + ) external view returns (uint256) { + if (slippageBps == 0) revert SlippageTooLow(); + if (slippageBps > _yieldStorage().maxSlippageBps) revert SlippageAboveMax(); + return Math.mulDiv(_twapQuote(tokenIn, tokenOut, amountIn), 10_000 - slippageBps, 10_000); + } + + function _twapQuote(address tokenIn, address tokenOut, uint256 amountIn) internal view returns (uint256) { + address token = tokenIn == address(USDC) ? tokenOut : tokenIn; + TwapConfig memory cfg = _yieldStorage().twapConfigOf[token]; + if (cfg.pool == address(0)) revert TwapOracle.TwapNotConfigured(token); + address quoteTokenIn = tokenIn == address(0) ? address(WETH) : tokenIn; + address quoteTokenOut = tokenOut == address(0) ? address(WETH) : tokenOut; + return TwapOracle.quote(cfg, quoteTokenIn, quoteTokenOut, amountIn); + } + + function treasury() external view returns (address) { + return _yieldStorage().treasury; + } + + function performanceFeeBps() external view returns (uint16) { + return _yieldStorage().performanceFeeBps; + } + + function feeCollectBps() external view returns (uint16) { + return _yieldStorage().feeCollectBps; + } + + function maxSlippageBps() external view returns (uint16) { + return _yieldStorage().maxSlippageBps; + } + + function minPoolLiquidity(uint8 protocol) external view returns (uint128) { + return _yieldStorage().minPoolLiquidity[protocol]; + } + + function minPositionLiquidity(uint8 protocol) external view returns (uint128) { + return _yieldStorage().minPositionLiquidity[protocol]; + } + + // ───────────────────────────────────────────────────────────────────── + // Timelocked critical setters + // ───────────────────────────────────────────────────────────────────── + + function setTreasury(address newTreasury) external onlyTimelockCriticalRole { + if (newTreasury == address(0)) revert InvalidTreasury(); + YieldLayout storage $ = _yieldStorage(); + emit TreasuryUpdated($.treasury, newTreasury); + $.treasury = newTreasury; + } + + function setPerformanceFeeBps(uint16 newPerformanceFeeBps) external onlyTimelockCriticalRole { + if (newPerformanceFeeBps > MAX_FEE_BPS) revert FeeAboveMax(); + YieldLayout storage $ = _yieldStorage(); + emit PerformanceFeeBpsUpdated($.performanceFeeBps, newPerformanceFeeBps); + $.performanceFeeBps = newPerformanceFeeBps; + } + + function setFeeCollectBps(uint16 newFeeCollectBps) external onlyTimelockCriticalRole { + if (newFeeCollectBps > MAX_FEE_BPS) revert FeeAboveMax(); + YieldLayout storage $ = _yieldStorage(); + emit FeeCollectBpsUpdated($.feeCollectBps, newFeeCollectBps); + $.feeCollectBps = newFeeCollectBps; + } + + /// @notice Register or replace the handler for a protocol. Handler code + /// runs via delegatecall with full access to this contract's + /// context, hence the timelock gate. + /// @dev Affects NEW positions only: close/collect always run through + /// the handler pinned per position at open time, so replacing a + /// handler (or a bad registration) can never strand existing + /// positions on an incompatible implementation. + function setYieldHandler(uint8 protocol, address handler) external onlyTimelockCriticalRole { + _validateHandler(protocol, handler); + address oldHandler = yieldHandlers[protocol]; + yieldHandlers[protocol] = handler; + emit YieldHandlerUpdated(protocol, oldHandler, handler); + } + + /// @dev The code-length pre-check is load-bearing: for a codeless address + /// `PROTOCOL()` returns empty data, and RETURN-DATA DECODING errors + /// are NOT caught by try/catch — they revert reason-less in this + /// contract instead of landing in the catch below. + function _validateHandler(uint8 protocol, address handler) internal view { + if (handler.code.length == 0) revert InvalidHandler(); + try IYieldHandler(handler).PROTOCOL() returns (uint8 handlerProtocol) { + if (handlerProtocol != protocol) revert HandlerProtocolMismatch(protocol, handlerProtocol); + } catch { + revert InvalidHandler(); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Admin setters + // ───────────────────────────────────────────────────────────────────── + + function setMaxSlippageBps(uint16 newMaxSlippageBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newMaxSlippageBps > MAX_SETTABLE_SLIPPAGE_BPS) revert SlippageAboveMax(); + YieldLayout storage $ = _yieldStorage(); + emit MaxSlippageBpsUpdated($.maxSlippageBps, newMaxSlippageBps); + $.maxSlippageBps = newMaxSlippageBps; + } + + /// @notice Allow or disallow a protocol-specific pool param (ABI-encoded + /// feeTier / tickSpacing / future pool key). + function setPoolParamAllowed( + uint8 protocol, + bytes calldata poolParam, + bool allowed + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + YieldLayout storage $ = _yieldStorage(); + if (allowed) { + address handler = yieldHandlers[protocol]; + if (handler == address(0)) revert HandlerNotSet(); + _requirePoolParamTwapReferences($, handler, poolParam); + } + _storePoolParamAllowed($, protocol, poolParam, allowed); + } + + function _storePoolParamAllowed( + YieldLayout storage $, + uint8 protocol, + bytes memory poolParam, + bool allowed + ) internal { + bytes32 key = keccak256(poolParam); + bool previousAllowed = $.allowedPoolKey[protocol][key]; + emit PoolParamAllowedUpdated(protocol, poolParam, previousAllowed, allowed); + if (previousAllowed == allowed) return; + + $.allowedPoolKey[protocol][key] = allowed; + if (allowed) { + $.allowedPoolParams[protocol].push(poolParam); + $.allowedPoolParamIndexPlusOne[protocol][key] = $.allowedPoolParams[protocol].length; + return; + } + + uint256 index = $.allowedPoolParamIndexPlusOne[protocol][key] - 1; + uint256 lastIndex = $.allowedPoolParams[protocol].length - 1; + if (index != lastIndex) { + bytes memory lastParam = $.allowedPoolParams[protocol][lastIndex]; + $.allowedPoolParams[protocol][index] = lastParam; + $.allowedPoolParamIndexPlusOne[protocol][keccak256(lastParam)] = index + 1; + } + $.allowedPoolParams[protocol].pop(); + delete $.allowedPoolParamIndexPlusOne[protocol][key]; + } + + /// @notice Point `token`'s price reference at a Uniswap V3 pool. This is + /// the floor under every router call that trades `token`, so an + /// unconfigured token cannot be swapped at all. + /// @dev Restricted to the configured timelock holding CRITICAL_ROLE. + /// A DEFAULT_ADMIN_ROLE holder cannot repoint or clear a reference + /// directly. `withdrawLp` remains available regardless of what is + /// configured here. + /// @param token ERC20 reference key, or address(0) for native ETH. The + /// native key must point to a WETH/USDC reference pool. + /// @param pool A validated replacement pool. An active reference cannot be + /// cleared; replacement must be atomic. + function setTwapConfig( + address token, + address pool, + uint32 window, + uint16 minCardinality + ) external onlyTimelockCriticalRole { + if (pool == address(0)) revert TwapReferenceRemovalNotAllowed(token); + _storeTwapConfig(_yieldStorage(), token, pool, window, minCardinality); + } + + /// @dev Validate and install one reference. Shared by the constructor and + /// the timelocked setter so a seeded reference is held to exactly the + /// same standard as a replaced one — there is no weaker path in. + function _storeTwapConfig( + YieldLayout storage $, + address token, + address pool, + uint32 window, + uint16 minCardinality + ) internal { + if (window < MIN_TWAP_WINDOW) revert TwapWindowTooShort(); + if (minCardinality < MIN_TWAP_CARDINALITY) revert TwapCardinalityBelowFloor(); + if (pool.code.length == 0) revert InvalidTwapReferencePool(pool); + + // Reference validation: the pool must actually trade this pair. Both + // tokens are immutable on a V3 pool, so checking once here is binding + // forever and keeps the per-swap read cheap. + address referenceToken = token == address(0) ? address(WETH) : token; + (address expect0, address expect1) = referenceToken < address(USDC) + ? (referenceToken, address(USDC)) + : (address(USDC), referenceToken); + IUniswapV3Pool v3Pool = IUniswapV3Pool(pool); + if (v3Pool.token0() != expect0 || v3Pool.token1() != expect1) revert TwapPoolPairMismatch(); + (uint160 sqrtPriceX96, , , , , , ) = v3Pool.slot0(); + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + + TwapConfig memory cfg = TwapConfig({pool: pool, window: window, minCardinality: minCardinality}); + // Prove the reference answers TODAY rather than discovering at the + // first exit that it never could. Reverts on low cardinality, a stale + // newest observation, or a window the pool cannot cover. + TwapOracle.meanTick(cfg); + + $.twapConfigOf[token] = cfg; + emit TwapConfigUpdated(token, pool, window, minCardinality); + } + + function setMinPoolLiquidity(uint8 protocol, uint128 newValue) external onlyRole(DEFAULT_ADMIN_ROLE) { + YieldLayout storage $ = _yieldStorage(); + emit MinPoolLiquidityUpdated(protocol, $.minPoolLiquidity[protocol], newValue); + $.minPoolLiquidity[protocol] = newValue; + } + + function setMinPositionLiquidity(uint8 protocol, uint128 newValue) external onlyRole(DEFAULT_ADMIN_ROLE) { + YieldLayout storage $ = _yieldStorage(); + emit MinPositionLiquidityUpdated(protocol, $.minPositionLiquidity[protocol], newValue); + $.minPositionLiquidity[protocol] = newValue; + } + + function setPauser(address newPauser) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newPauser == address(0)) revert ZeroAddress(); + emit PauserUpdated(pauser, newPauser); + pauser = newPauser; + } + + /// @notice Recover ERC20s held by this contract, not by a Safe. + 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 ERC721s held by this contract, not by a Safe. + 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); + } + + // ───────────────────────────────────────────────────────────────────── + // Pauser controls + // ───────────────────────────────────────────────────────────────────── + + /// @notice Emergency per-protocol disable of NEW position opens (mirrors + /// SafeDebtManager's switchFrom/switchTo split). Never affects + /// exits. + function setProtocolEnabledForOpen(uint8 protocol, bool enabled) external onlyPauser { + if (yieldHandlers[protocol] == address(0)) revert HandlerNotSet(); + if (enabled) _requireProtocolTwapReferences(protocol); + protocolEnabledForOpen[protocol] = enabled; + emit ProtocolStatusChanged(protocol, true, enabled); + } + + /// @notice Emergency per-protocol disable of close/collect — the ONLY + /// switch that can stop exits, reserved for a compromised or + /// malfunctioning handler. Keep opens disabled too when using it. + function setProtocolEnabledForClose(uint8 protocol, bool enabled) external onlyPauser { + if (yieldHandlers[protocol] == address(0)) revert HandlerNotSet(); + protocolEnabledForClose[protocol] = enabled; + emit ProtocolStatusChanged(protocol, false, enabled); + } + + /// @notice Pause NEW position opens. closeLp/collectLp stay available — + /// pausing yields an exit-only mode and never traps positions. + function pause() external onlyPauser { + _pause(); + } + + function unpause() external onlyPauser { + _unpause(); + } + + // ───────────────────────────────────────────────────────────────────── + // Internals + // ───────────────────────────────────────────────────────────────────── + + /// @dev Module-mediated ERC20 transfer that accepts empty return data or + /// the canonical true word only, and never reverts on malformed + /// returndata — a failed treasury transfer must waive the fee + /// instead of blocking an exit. Mirrors RatehopperUniV3Positions. + function _trySafeTransfer( + address _onBehalfOf, + address token, + address recipient, + uint256 amount + ) internal returns (bool) { + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + token, + 0, + abi.encodeCall(IERC20.transfer, (recipient, amount)), + ISafe.Operation.Call + ); + if (!ok) return false; + return TokenReturnLib.returnedTrue(ret); + } + + /// @dev Basis and open-time handler of a position this contract manages. + /// The handler (not the basis) is the managed-position sentinel so a + /// position whose recorded basis is zero stays manageable. + function _pinnedPosition( + YieldLayout storage $, + uint8 protocol, + uint256 tokenId + ) internal view returns (uint128 residualBasis, address handler) { + handler = $.positionHandlerOf[protocol][tokenId]; + residualBasis = $.residualBasisUsd6Of[protocol][tokenId]; + if (handler == address(0)) { + if (residualBasis == 0) revert UnknownPosition(); + revert HandlerNotSet(); + } + } + + /// @dev Delegatecall into a handler, bubbling its revert data. + function _delegateToHandler(address handler, bytes memory data) internal returns (bytes memory) { + (bool ok, bytes memory ret) = handler.delegatecall(data); + if (!ok) { + if (ret.length > 0) Address.verifyCallResult(ok, ret); + revert HandlerCallFailed(); + } + return ret; + } +} diff --git a/contracts/yield/handlers/AerodromeYieldHandler.sol b/contracts/yield/handlers/AerodromeYieldHandler.sol new file mode 100644 index 0000000..2dd76ab --- /dev/null +++ b/contracts/yield/handlers/AerodromeYieldHandler.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {ISlipstreamNonfungiblePositionManager} from "../../interfaces/aerodrome/ISlipstreamNonfungiblePositionManager.sol"; +import {ICLFactory} from "../../interfaces/aerodrome/ICLFactory.sol"; +import {ICLPool} from "../../interfaces/aerodrome/ICLPool.sol"; +import {IStakePool} from "../../interfaces/aerodrome/IStakePool.sol"; +import {IVoter} from "../../interfaces/aerodrome/IVoter.sol"; +import {ISlipstreamSwapRouter} from "../../interfaces/aerodrome/ISlipstreamSwapRouter.sol"; +import {BaseYieldHandler} from "./BaseYieldHandler.sol"; +import "../../common/Types.sol"; + +/// @title AerodromeYieldHandler +/// @notice Aerodrome Slipstream adapter for SafeYieldManager. Pool params +/// are `abi.encode(token0, token1, int24 tickSpacing)`. Executed via +/// delegatecall from the manager and records each position's stakePool. +contract AerodromeYieldHandler is BaseYieldHandler { + ICLFactory public immutable CL_FACTORY; + /// @notice Aerodrome Voter — the canonical pool -> stake pool registry used to + /// resolve where a position is staked/unstaked. + IVoter public immutable VOTER; + + constructor( + address _positionManager, + IERC20 _usdc, + address _swapRouter, + ICLFactory _clFactory, + IVoter _voter + ) BaseYieldHandler(YIELD_PROTOCOL_AERODROME, _positionManager, _usdc, _swapRouter) { + if (address(_clFactory) == address(0)) revert ZeroAddress(); + if (address(_voter) == address(0)) revert ZeroAddress(); + CL_FACTORY = _clFactory; + VOTER = _voter; + } + + /// @dev Stake the minted Slipstream NFT into its pool's stake pool (resolved + /// via the Voter) for AERO emissions: the Safe approves the stakePool for + /// the NFT, then deposits. Reverts if the pool has no stakePool. + function _stake(address _onBehalfOf, uint256 tokenId, bytes memory lpPoolParam) internal override { + address stakePool = VOTER.gauges(_getPool(lpPoolParam)); + if (stakePool == address(0)) revert StakingNotSupported(); + _restakeInto(_onBehalfOf, tokenId, stakePool); + _yieldStorage().stakePoolOf[PROTOCOL][tokenId] = stakePool; + } + + /// @dev Deposit `tokenId` into `stakePool`. Shared by the initial stake and by + /// the partial-close restake, which passes the PINNED pool rather than + /// re-reading the Voter — the mapping is governance-controlled and a + /// rotation must not silently move a user's position to another gauge. + function _restakeInto(address _onBehalfOf, uint256 tokenId, address stakePool) internal override { + _safeExec(_onBehalfOf, POSITION_MANAGER, abi.encodeCall(IERC721.approve, (stakePool, tokenId)), 28); + _safeExec(_onBehalfOf, stakePool, abi.encodeCall(IStakePool.deposit, (tokenId)), 29); + } + + /// @dev The pool stakePool currently holding `tokenId`, or address(0) when the + /// position is unstaked or its pool has no stakePool — the single + /// definition of "staked" shared by the hooks below. + function _stakePoolOf(uint256 tokenId) internal view returns (address) { + address stakePool = _yieldStorage().stakePoolOf[PROTOCOL][tokenId]; + if (stakePool == address(0) || IERC721(POSITION_MANAGER).ownerOf(tokenId) != stakePool) return address(0); + return stakePool; + } + + /// @dev Withdraw `tokenId` from its pool's stakePool back to the Safe when it is + /// staked (stakePool owns the NFT); a no-op otherwise so unstaked and + /// no-stakePool positions close normally. Returns the pool it came out of + /// so a surviving partial position is restaked into that same pool; the + /// pin itself survives here and is cleared only by a full close. + function _unstakeIfStaked(address _onBehalfOf, uint256 tokenId) internal override returns (address stakePool) { + stakePool = _stakePoolOf(tokenId); + if (stakePool == address(0)) return address(0); + + // Withdrawing from a gauge pays out everything accrued so far. That is the + // same emission yield an explicit collect claims, so it is settled through + // the same fee path here — otherwise a close or a switch would be a way to + // take emissions without paying feeCollectBps. Callers run this BEFORE + // their own balance snapshots, so the reward can never leak into the close + // swap delta or the performance-fee valuation. + // A gauge that reports no reward token gets no snapshot: an exit must not + // brick on a misbehaving gauge, and `_settleStakedReward` no-ops on zero. + address rewardToken = IStakePool(stakePool).rewardToken(); + uint256 rewardBefore = rewardToken == address(0) ? 0 : IERC20(rewardToken).balanceOf(_onBehalfOf); + _safeExec(_onBehalfOf, stakePool, abi.encodeCall(IStakePool.withdraw, (tokenId)), 30); + _settleStakedReward(_onBehalfOf, tokenId, rewardToken, rewardBefore); + } + + /// @dev When `tokenId` is staked in its pool's stakePool, claim its accrued AERO + /// emissions to the Safe. Staked liquidity earns emissions instead of + /// trading fees, so this claim is the position's complete harvest. With + /// `captureReward`, snapshot the Safe's AERO balance before the claim so + /// the base can swap exactly the claimed amount to USDC. + function _collectStakedRewardIfStaked( + address _onBehalfOf, + uint256 tokenId + ) internal override returns (bool wasStaked, address rewardToken, uint256 rewardBalanceBefore) { + address stakePool = _stakePoolOf(tokenId); + if (stakePool == address(0)) return (false, address(0), 0); + // Snapshot unconditionally: the claim is fee-bearing even when the caller + // does not want it swapped, so the amount must always be measurable. A + // gauge reporting no reward token yields no snapshot and no settlement. + rewardToken = IStakePool(stakePool).rewardToken(); + rewardBalanceBefore = rewardToken == address(0) ? 0 : IERC20(rewardToken).balanceOf(_onBehalfOf); + _safeExec(_onBehalfOf, stakePool, abi.encodeCall(IStakePool.getReward, (tokenId)), 37); + return (true, rewardToken, rewardBalanceBefore); + } + + function _decodePoolParam( + bytes memory poolParam + ) internal pure returns (address token0, address token1, int24 tickSpacing) { + (token0, token1, tickSpacing) = abi.decode(poolParam, (address, address, int24)); + } + + function _poolTokens(bytes memory poolParam) internal pure override returns (address token0, address token1) { + (token0, token1, ) = _decodePoolParam(poolParam); + } + + function _getPool(bytes memory poolParam) internal view override returns (address) { + (address token0, address token1, int24 tickSpacing) = _decodePoolParam(poolParam); + return CL_FACTORY.getPool(token0, token1, tickSpacing); + } + + function _poolSqrtPriceX96(address pool) internal view override returns (uint160 sqrtPriceX96) { + (sqrtPriceX96, , , , , ) = ICLPool(pool).slot0(); + } + + function _buildSwapCalldata( + address tokenIn, + address tokenOut, + bytes memory poolParam, + address recipient, + uint256 amountIn, + uint256 amountOutMin, + uint256 deadline + ) internal pure override returns (bytes memory) { + (, , int24 tickSpacing) = _decodePoolParam(poolParam); + return + abi.encodeCall( + ISlipstreamSwapRouter.exactInputSingle, + ( + ISlipstreamSwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + tickSpacing: tickSpacing, + recipient: recipient, + deadline: deadline, + amountIn: amountIn, + amountOutMinimum: amountOutMin, + sqrtPriceLimitX96: 0 + }) + ) + ); + } + + function _buildMintCalldata( + bytes memory lpPoolParam, + int24 tickLower, + int24 tickUpper, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address recipient, + uint256 deadline + ) internal pure override returns (bytes memory) { + (address token0, address token1, int24 tickSpacing) = _decodePoolParam(lpPoolParam); + return + abi.encodeCall( + ISlipstreamNonfungiblePositionManager.mint, + ( + ISlipstreamNonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + tickSpacing: tickSpacing, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: amount0Desired, + amount1Desired: amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + recipient: recipient, + deadline: deadline, + sqrtPriceX96: 0 + }) + ) + ); + } + + function _position( + uint256 tokenId + ) internal view override returns (address token0, address token1, bytes memory lpPoolParam, uint128 liquidity) { + int24 tickSpacing; + (, , token0, token1, tickSpacing, , , liquidity, , , , ) = ISlipstreamNonfungiblePositionManager( + POSITION_MANAGER + ).positions(tokenId); + lpPoolParam = abi.encode(token0, token1, tickSpacing); + } +} diff --git a/contracts/yield/handlers/BaseYieldHandler.sol b/contracts/yield/handlers/BaseYieldHandler.sol new file mode 100644 index 0000000..94311b5 --- /dev/null +++ b/contracts/yield/handlers/BaseYieldHandler.sol @@ -0,0 +1,848 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +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 {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ISafe} from "../../interfaces/safe/ISafe.sol"; +import {INonfungiblePositionManager} from "../../interfaces/uniswapV3/INonfungiblePositionManager.sol"; +import {IYieldHandler, OpenLpParams, CloseLpParams, CollectLpParams, WithdrawLpParams, OpenLpInKindParams, SwapLeg} from "../../interfaces/IYieldHandler.sol"; +import {TokenReturnLib} from "../libraries/TokenReturnLib.sol"; +import {TwapOracle} from "../libraries/TwapOracle.sol"; +import {YieldStorage} from "./YieldStorage.sol"; +import "../../common/Types.sol"; + +/// @dev Surface shared by Uniswap V3 pools and Aerodrome Slipstream CL pools. +/// `slot0` is NOT here — its return arity differs, so reading the sqrt +/// price goes through the `_poolSqrtPriceX96` hook. +interface IPoolMinimal { + function token0() external view returns (address); + + function token1() external view returns (address); + + function liquidity() external view returns (uint128); +} + +/// @title BaseYieldHandler +/// @notice Shared open/close/collect flow for LP positions of ANY token pair +/// on V3-style concentrated-liquidity protocols, executed via +/// delegatecall from SafeYieldManager. The pair is part of the pool +/// identity carried in the ABI-encoded pool params; USDC stays the +/// sole funding and accounting currency — each non-USDC side is +/// acquired/realized through its own USDC swap leg (a side that IS +/// USDC needs no swap). Protocol differences are isolated in virtual +/// hooks (pool resolution, pair decoding, sqrt-price read, swap +/// calldata, mint calldata, position decoding). A protocol whose +/// mechanics don't fit this shape (e.g. Uniswap V4) can bypass this +/// base entirely and implement IYieldHandler directly. +/// @dev STATELESS: this contract and its children MUST NOT declare +/// storage variables. All mutable state is read from the ERC-7201 +/// `YieldStorage` namespace so delegatecall from the manager is +/// layout-safe. Immutables are fine (they live in code). +/// @dev `decreaseLiquidity` / `collect` / `burn` are ABI-identical across +/// Uniswap V3 and Slipstream position managers, so they are encoded +/// here against the Uniswap interface for both. +abstract contract BaseYieldHandler is IYieldHandler, YieldStorage { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + /// @notice Protocol id (canonical ids are the YIELD_PROTOCOL_* constants + /// in Types.sol; plain uint8 so the manager stays extensible). + uint8 public immutable PROTOCOL; + address public immutable POSITION_MANAGER; + IERC20 public immutable USDC; + address public immutable SWAP_ROUTER; + + /// @dev Own deployment address, captured at construction to enforce + /// delegatecall-only entry (a direct call would run against the + /// handler's empty storage). + address private immutable __self = address(this); + + error OnlyDelegatecall(); + error TokenApprovalFailed(address token); + /// @notice Thrown when `OpenLpParams.stake` is set for a protocol + /// whose handler has no stakePool (e.g. Uniswap V3). + error StakingNotSupported(); + + /// @dev Inputs for `_mintFromAmounts`, the mint half shared by `openLp` + /// and `openLpInKind`. Grouped in a struct so the shared helper takes + /// three arguments instead of eleven — the flat form pushes both + /// callers past the stack limit once coverage instrumentation is + /// layered on (viaIR, see .solcover.js). + struct MintArgs { + address onBehalfOf; + bytes lpPoolParam; + int24 tickLower; + int24 tickUpper; + uint256 amount0; + uint256 amount1; + uint256 mintAmount0Min; + uint256 mintAmount1Min; + uint256 deadline; + } + + modifier onlyDelegatecall() { + if (address(this) == __self) revert OnlyDelegatecall(); + _; + } + + constructor(uint8 _protocol, address _positionManager, IERC20 _usdc, address _swapRouter) { + if (_positionManager == address(0)) revert ZeroAddress(); + if (address(_usdc) == address(0)) revert ZeroAddress(); + if (_swapRouter == address(0)) revert ZeroAddress(); + + PROTOCOL = _protocol; + POSITION_MANAGER = _positionManager; + USDC = _usdc; + SWAP_ROUTER = _swapRouter; + } + + // ───────────────────────────────────────────────────────────────────── + // Protocol hooks + // ───────────────────────────────────────────────────────────────────── + + /// @dev Resolve the pool for an ABI-encoded pool param. + function _getPool(bytes memory poolParam) internal view virtual returns (address); + + /// @dev Decode the (token0, token1) pair declared by a pool param. + function _poolTokens(bytes memory poolParam) internal pure virtual returns (address token0, address token1); + + /// @inheritdoc IYieldHandler + function poolTokens(bytes calldata lpPoolParam) external pure returns (address token0, address token1) { + return _poolTokens(lpPoolParam); + } + + /// @dev Read `sqrtPriceX96` from a pool (slot0 arity differs per protocol). + function _poolSqrtPriceX96(address pool) internal view virtual returns (uint160); + + /// @dev Build exact-input-single swap calldata for the pinned router. + function _buildSwapCalldata( + address tokenIn, + address tokenOut, + bytes memory poolParam, + address recipient, + uint256 amountIn, + uint256 amountOutMin, + uint256 deadline + ) internal view virtual returns (bytes memory); + + /// @dev Build NPM mint calldata (MintParams shapes differ per protocol). + function _buildMintCalldata( + bytes memory lpPoolParam, + int24 tickLower, + int24 tickUpper, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address recipient, + uint256 deadline + ) internal view virtual returns (bytes memory); + + /// @dev Decode `positions(tokenId)` (fee vs tickSpacing slot differs). + function _position( + uint256 tokenId + ) internal view virtual returns (address token0, address token1, bytes memory lpPoolParam, uint128 liquidity); + + /// @dev Stake the just-minted NFT into the protocol's stakePool. Default: the + /// protocol has no stakePool, so opting in reverts. Aerodrome overrides it. + function _stake(address /* _onBehalfOf */, uint256 /* tokenId */, bytes memory /* lpPoolParam */) internal virtual { + revert StakingNotSupported(); + } + + /// @dev Unstake `tokenId` from the protocol's stakePool when it is staked, so + /// the close flow's `ownerOf == Safe` guard holds. Returns the pool the + /// NFT came OUT of (address(0) when it was not staked) so a surviving + /// partial position can go back into that exact pool. The stake pin is + /// deliberately preserved here — only a full close clears it, via + /// `_clearStakePin`. Default: no stakePool → no-op. Aerodrome overrides + /// it; idempotent for unstaked positions. + function _unstakeIfStaked(address /* _onBehalfOf */, uint256 /* tokenId */) internal virtual returns (address) { + return address(0); + } + + /// @dev Put `tokenId` back into `stakePool` — the pool it was just unstaked + /// from, NEVER a freshly resolved one: the protocol's gauge mapping is + /// governance-controlled and may have rotated since the position was + /// staked. Only reached for protocols that staked in the first place. + function _restakeInto(address /* _onBehalfOf */, uint256 /* tokenId */, address /* stakePool */) internal virtual { + revert StakingNotSupported(); + } + + /// @dev Forget which pool a position was staked in. Full close only — the NFT + /// is gone, so the pin would otherwise outlive the position. + function _clearStakePin(uint256 tokenId) internal { + delete _yieldStorage().stakePoolOf[PROTOCOL][tokenId]; + } + + /// @dev Harvest stakePool rewards for a STAKED `tokenId` to the Safe and report + /// whether the position was staked, plus the reward token and the Safe's + /// pre-claim balance of it. The snapshot is taken UNCONDITIONALLY: the + /// claimed delta is fee-bearing yield whether or not the caller asked to + /// swap it. Default: no stakePool → false. Aerodrome overrides it. + function _collectStakedRewardIfStaked( + address /* _onBehalfOf */, + uint256 /* tokenId */ + ) internal virtual returns (bool wasStaked, address rewardToken, uint256 rewardBalanceBefore) { + return (false, address(0), 0); + } + + /// @dev Charge `feeCollectBps` on emissions newly credited to the Safe since + /// `balanceBefore`, and report what was claimed and what was actually + /// paid. Every claim route funnels through here — an explicit collect and + /// the gauge withdrawal inside a close or switch — so emissions can never + /// reach a user untaxed. Measuring a delta (not a balance) leaves any + /// reward the Safe already held alone. + /// + /// A failed treasury transfer waives the fee instead of blocking the + /// collect or exit, mirroring the LP-fee and performance-fee semantics; + /// the emitted `feePaid` is then zero, so the event always states what + /// really moved. + function _settleStakedReward( + address _onBehalfOf, + uint256 tokenId, + address rewardToken, + uint256 balanceBefore + ) internal returns (uint256 grossReward, uint256 feePaid) { + if (rewardToken == address(0)) return (0, 0); + grossReward = IERC20(rewardToken).balanceOf(_onBehalfOf) - balanceBefore; + if (grossReward == 0) return (0, 0); + + YieldLayout storage $ = _yieldStorage(); + uint256 fee = (grossReward * $.feeCollectBps) / 10_000; + if (fee > 0) { + if (_trySafeTokenTransfer(_onBehalfOf, rewardToken, $.treasury, fee)) { + feePaid = fee; + } else { + emit CollectFeeTransferFailed(_onBehalfOf, tokenId, rewardToken, fee); + } + } + emit StakedRewardCollected(_onBehalfOf, PROTOCOL, tokenId, rewardToken, grossReward, feePaid); + } + + /// @dev Module-mediated ERC20 transfer OUT of the Safe that reports failure + /// instead of reverting: accepts empty or canonical-true returndata and + /// treats a false return, malformed returndata, or an inner revert as a + /// failed transfer the caller must waive. + function _trySafeTokenTransfer( + address _onBehalfOf, + address token, + address to, + uint256 amount + ) internal returns (bool) { + (bool ok, bytes memory ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + token, + 0, + abi.encodeCall(IERC20.transfer, (to, amount)), + ISafe.Operation.Call + ); + if (!ok) return false; + return TokenReturnLib.returnedTrue(ret); + } + + // ───────────────────────────────────────────────────────────────────── + // IYieldHandler + // ───────────────────────────────────────────────────────────────────── + + /// @inheritdoc IYieldHandler + function openLp( + OpenLpParams calldata p + ) external onlyDelegatecall returns (uint256 tokenId, uint128 basisUsd6, uint128 used0, uint128 used1) { + _validatePoolParamAllowed(p.lpPoolParam); + (address token0, address token1) = _poolTokens(p.lpPoolParam); + _validatePool(_getPool(p.lpPoolParam), token0, token1); + + uint256 half0 = p.usdcAmount / 2; + uint256 half1 = p.usdcAmount - half0; + + uint256 desired0 = _acquireSide(p, token0, half0, p.swap0, 20, 3, 21); + uint256 desired1 = _acquireSide(p, token1, half1, p.swap1, 31, 32, 33); + + (tokenId, used0, used1) = _mintFromAmounts( + token0, + token1, + MintArgs({ + onBehalfOf: p.onBehalfOf, + lpPoolParam: p.lpPoolParam, + tickLower: p.tickLower, + tickUpper: p.tickUpper, + amount0: desired0, + amount1: desired1, + mintAmount0Min: p.mintAmount0Min, + mintAmount1Min: p.mintAmount1Min, + deadline: p.deadline + }) + ); + + // Value each leg at its just-executed swap rate (identity for USDC). + basisUsd6 = (_legValueUsdc(token0, used0, half0, desired0) + _legValueUsdc(token1, used1, half1, desired1)) + .toUint128(); + + // Opt-in stakePool stake. Runs AFTER the ownerOf==Safe check and basis so + // the mint accounting is unaffected; staking then moves the NFT to the + // stakePool (closeLp unstakes it first). + if (p.stake) { + _stake(p.onBehalfOf, tokenId, p.lpPoolParam); + } + } + + /// @inheritdoc IYieldHandler + function closeLp( + CloseLpParams calldata p, + uint128 basisForExit + ) external onlyDelegatecall returns (uint128 currentValueUsd6) { + (address token0, address token1, , uint128 liquidity) = _position(p.tokenId); + _validateSwapLeg(token0, p.swap0, p.slippageBps); + _validateSwapLeg(token1, p.swap1, p.slippageBps); + // A staked position is owned by the stakePool; unstake it back to the Safe + // first so the ownership guard and the existing decrease/collect/burn/swap + // flow run unchanged. No-op for non-stakePool protocols or an unstaked NFT. + address unstakedFrom = _unstakeIfStaked(p.onBehalfOf, p.tokenId); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + // Measure only deltas from this close (USDC sides need no snapshot — + // their swap leg is skipped and the USDC delta is measured below). + uint256 t0Before = token0 == address(USDC) ? 0 : IERC20(token0).balanceOf(p.onBehalfOf); + uint256 t1Before = token1 == address(USDC) ? 0 : IERC20(token1).balanceOf(p.onBehalfOf); + uint256 usdcBefore = USDC.balanceOf(p.onBehalfOf); + + // Harvest fees before principal so feeCollectBps does not tax capital. + _collectLpFees(p.onBehalfOf, p.tokenId, token0, token1); + + // On full close, remove exact liquidity so burn can succeed. + uint128 liquidityToRemove = p.exitBps == 10_000 + ? liquidity + : Math.mulDiv(uint256(liquidity), uint256(p.exitBps), 10_000).toUint128(); + + // Do not let the manager's basis decrement stand when rounding + // removes zero liquidity (basis would shrink while principal stays). + if (p.exitBps != 10_000 && basisForExit > 0 && liquidityToRemove == 0) { + revert InvalidExitBps(); + } + + if (liquidityToRemove > 0) { + _safeExec( + p.onBehalfOf, + POSITION_MANAGER, + abi.encodeCall( + INonfungiblePositionManager.decreaseLiquidity, + ( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: p.tokenId, + liquidity: liquidityToRemove, + amount0Min: p.decreaseAmount0Min, + amount1Min: p.decreaseAmount1Min, + deadline: p.deadline + }) + ) + ), + 7 + ); + + // Collect principal straight to the Safe. No fee on capital. + _collectToRecipient(p.onBehalfOf, p.tokenId, p.onBehalfOf, 8); + } + + // Burn only on a full close. + if (p.exitBps == 10_000) { + _safeExec(p.onBehalfOf, POSITION_MANAGER, abi.encodeCall(INonfungiblePositionManager.burn, (p.tokenId)), 9); + } + + // Staking continuity: a position that was staked goes back into the SAME + // pool when it survives this close, so emissions resume and the pin keeps + // matching reality. A full close burned the NFT, so the pin is dropped. + // A restake failure reverts the whole close — a half-closed, unstaked + // position with a live pin is exactly the divergence this guards against. + if (unstakedFrom != address(0)) { + if (p.exitBps == 10_000) { + _clearStakePin(p.tokenId); + } else { + _restakeInto(p.onBehalfOf, p.tokenId, unstakedFrom); + } + } + + // Swap the non-USDC legs this close produced back to USDC. + _swapDeltaToUsdc(p.onBehalfOf, token0, t0Before, p.swap0, p.deadline, p.slippageBps, 26, 10, 27, false); + _swapDeltaToUsdc(p.onBehalfOf, token1, t1Before, p.swap1, p.deadline, p.slippageBps, 34, 35, 36, false); + + currentValueUsd6 = (USDC.balanceOf(p.onBehalfOf) - usdcBefore).toUint128(); + // Caller's final-value guard on gross realized USDC. + if (uint256(currentValueUsd6) < p.minUsdcOut) revert MinUsdcOutNotMet(); + } + + /// @inheritdoc IYieldHandler + /// @dev In-kind close leg of a switch: decrease-all + collect + burn with + /// NO swaps — both pool tokens land on the Safe as-is. Fees are + /// harvested first through the manager so `feeCollectBps` applies to + /// fees only, exactly as in closeLp. + function withdrawLp( + WithdrawLpParams calldata p + ) external onlyDelegatecall returns (address token0, address token1, uint256 amount0, uint256 amount1) { + uint128 liquidity; + (token0, token1, , liquidity) = _position(p.tokenId); + // A staked position is owned by the stakePool; unstake it back to the + // Safe first so the ownership guard and decrease/collect/burn hold. + address unstakedFrom = _unstakeIfStaked(p.onBehalfOf, p.tokenId); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + uint256 t0Before = IERC20(token0).balanceOf(p.onBehalfOf); + uint256 t1Before = IERC20(token1).balanceOf(p.onBehalfOf); + + // Harvest fees before principal so feeCollectBps does not tax capital. + _collectLpFees(p.onBehalfOf, p.tokenId, token0, token1); + + if (liquidity > 0) { + _safeExec( + p.onBehalfOf, + POSITION_MANAGER, + abi.encodeCall( + INonfungiblePositionManager.decreaseLiquidity, + ( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: p.tokenId, + liquidity: liquidity, + amount0Min: p.decreaseAmount0Min, + amount1Min: p.decreaseAmount1Min, + deadline: p.deadline + }) + ) + ), + 7 + ); + + // Collect principal straight to the Safe. No fee on capital. + _collectToRecipient(p.onBehalfOf, p.tokenId, p.onBehalfOf, 8); + } + + _safeExec(p.onBehalfOf, POSITION_MANAGER, abi.encodeCall(INonfungiblePositionManager.burn, (p.tokenId)), 9); + + // The NFT is gone: a withdraw is always a full exit, so the pin goes too. + if (unstakedFrom != address(0)) _clearStakePin(p.tokenId); + + amount0 = IERC20(token0).balanceOf(p.onBehalfOf) - t0Before; + amount1 = IERC20(token1).balanceOf(p.onBehalfOf) - t1Before; + } + + /// @inheritdoc IYieldHandler + /// @dev In-kind open leg of a switch: mint straight from the withdrawn + /// token amounts — no swaps, so the only price protection is the + /// mint minimums (the withdraw leg's decrease minimums bound the + /// input side). Never staked: staking stays an explicit openLp + /// opt-in. + function openLpInKind( + OpenLpInKindParams calldata p + ) external onlyDelegatecall returns (uint256 tokenId, uint128 used0, uint128 used1) { + _validatePoolParamAllowed(p.lpPoolParam); + (address token0, address token1) = _poolTokens(p.lpPoolParam); + if (token0 != p.token0 || token1 != p.token1) revert WrongTokenPair(); + _validatePool(_getPool(p.lpPoolParam), token0, token1); + + (tokenId, used0, used1) = _mintFromAmounts( + token0, + token1, + MintArgs({ + onBehalfOf: p.onBehalfOf, + lpPoolParam: p.lpPoolParam, + tickLower: p.tickLower, + tickUpper: p.tickUpper, + amount0: p.amount0, + amount1: p.amount1, + mintAmount0Min: p.mintAmount0Min, + mintAmount1Min: p.mintAmount1Min, + deadline: p.deadline + }) + ); + } + + /// @inheritdoc IYieldHandler + function collectLp(CollectLpParams calldata p) external onlyDelegatecall { + // A staked position earns stakePool emissions INSTEAD of trading fees — + // the pool redirects its fees away from staked liquidity — so claiming + // the emissions IS the complete harvest and the NFT never leaves the + // stakePool. Fees from unstaked periods are still collected on close. + // Opt-in: swap the claimed reward (e.g. AERO) to USDC through its leg. + (bool wasStaked, address rewardToken, uint256 rewardBefore) = _collectStakedRewardIfStaked( + p.onBehalfOf, + p.tokenId + ); + if (wasStaked) { + // The claim is yield, so it pays feeCollectBps whether or not it is + // swapped. Charging first also means the swap below moves only the + // NET reward: `_swapDeltaToUsdc` measures the delta from the same + // pre-claim snapshot, which the fee transfer has already reduced. + _settleStakedReward(p.onBehalfOf, p.tokenId, rewardToken, rewardBefore); + if (p.swapRewardToUsdc) { + if (block.timestamp > p.deadline) revert DeadlineExpired(); + _validateSwapLeg(rewardToken, p.rewardSwap, p.slippageBps); + _swapDeltaToUsdc( + p.onBehalfOf, + rewardToken, + rewardBefore, + p.rewardSwap, + p.deadline, + p.slippageBps, + 38, + 39, + 40, + true + ); + } + return; + } + + (address token0, address token1, , ) = _position(p.tokenId); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + // Swap params are validated only on the swap path; the no-swap + // path intentionally ignores them. + uint256 t0Before; + uint256 t1Before; + if (p.swapFeesToUsdc) { + if (block.timestamp > p.deadline) revert DeadlineExpired(); + _validateSwapLeg(token0, p.swap0, p.slippageBps); + _validateSwapLeg(token1, p.swap1, p.slippageBps); + t0Before = token0 == address(USDC) ? 0 : IERC20(token0).balanceOf(p.onBehalfOf); + t1Before = token1 == address(USDC) ? 0 : IERC20(token1).balanceOf(p.onBehalfOf); + } + + _collectLpFees(p.onBehalfOf, p.tokenId, token0, token1); + if (p.swapFeesToUsdc) { + _swapDeltaToUsdc(p.onBehalfOf, token0, t0Before, p.swap0, p.deadline, p.slippageBps, 26, 10, 27, true); + _swapDeltaToUsdc(p.onBehalfOf, token1, t1Before, p.swap1, p.deadline, p.slippageBps, 34, 35, 36, true); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Shared internals + // ───────────────────────────────────────────────────────────────────── + + /// @dev Acquire one mint side from its USDC half: identity for USDC, a + /// leg-validated swap for any other token. Returns the amount now + /// available for the mint. + function _acquireSide( + OpenLpParams calldata p, + address token, + uint256 halfUsdc, + SwapLeg calldata leg, + uint8 approveStep, + uint8 execStep, + uint8 resetStep + ) internal returns (uint256 received) { + if (token == address(USDC)) return halfUsdc; + + _validateSwapLeg(token, leg, p.slippageBps); + // Only consume tokens produced by this call, never pre-existing ones. + uint256 balanceBefore = IERC20(token).balanceOf(p.onBehalfOf); + _swapViaSafe( + p.onBehalfOf, + address(USDC), + token, + leg.poolParam, + halfUsdc, + leg.amountOutMin, + p.deadline, + p.slippageBps, + approveStep, + execStep, + resetStep, + false + ); + received = IERC20(token).balanceOf(p.onBehalfOf) - balanceBefore; + // Avoid accidental one-sided mints after a zero-output swap. + if (received == 0) revert SwapFailed(); + } + + /// @dev USDC value of a mint leg: the used amount itself for USDC, else + /// the used amount priced at the leg's just-executed swap rate + /// (`halfUsdc` bought `received`, the mint consumed `used` of it). + function _legValueUsdc( + address token, + uint128 used, + uint256 halfUsdc, + uint256 received + ) internal view returns (uint256) { + if (token == address(USDC)) return uint256(used); + return Math.mulDiv(uint256(used), halfUsdc, received); + } + + /// @dev Require the Safe to own `tokenId` on the position manager. + function _requireOwnedBy(address _onBehalfOf, uint256 tokenId) internal view { + if (IERC721(POSITION_MANAGER).ownerOf(tokenId) != _onBehalfOf) revert LpNotOnSafe(); + } + + /// @dev Collect accrued fees through the manager so `feeCollectBps` can + /// be skimmed before forwarding the remainder to the Safe. + function _collectLpFees(address _onBehalfOf, uint256 tokenId, address token0, address token1) internal { + uint256 t0Before = IERC20(token0).balanceOf(address(this)); + uint256 t1Before = IERC20(token1).balanceOf(address(this)); + + _collectToRecipient(_onBehalfOf, tokenId, address(this), 6); + + uint256 collected0 = IERC20(token0).balanceOf(address(this)) - t0Before; + uint256 collected1 = IERC20(token1).balanceOf(address(this)) - t1Before; + + uint256 fee0 = _chargeCollectFee(token0, collected0, _onBehalfOf, tokenId); + uint256 fee1 = _chargeCollectFee(token1, collected1, _onBehalfOf, tokenId); + + emit FeesCollected(_onBehalfOf, PROTOCOL, tokenId, token0, collected0, fee0, token1, collected1, fee1); + } + + /// @dev Module-mediated NPM collect of the full owed balance. + function _collectToRecipient(address _onBehalfOf, uint256 tokenId, address recipient, uint8 step) internal { + _safeExec( + _onBehalfOf, + POSITION_MANAGER, + abi.encodeCall( + INonfungiblePositionManager.collect, + ( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: recipient, + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ) + ), + step + ); + } + + /// @dev Skim `feeCollectBps` to the treasury, forward the rest to the + /// Safe. Treasury transfer failure waives the fee instead of + /// blocking users (e.g. USDC blacklist on the treasury). + function _chargeCollectFee( + address token, + uint256 amount, + address _onBehalfOf, + uint256 tokenId + ) internal returns (uint256 fee) { + if (amount == 0) return 0; + YieldLayout storage $ = _yieldStorage(); + 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); + } + + /// @dev Module-mediated exact-input swap on the pinned router; calldata + /// is built on-chain so callers cannot inject alternative routes. + function _swapViaSafe( + address _onBehalfOf, + address tokenIn, + address tokenOut, + bytes memory poolParam, + uint256 amountIn, + uint256 amountOutMin, + uint256 deadline, + uint16 slippageBps, + uint8 approveStep, + uint8 execStep, + uint8 resetStep, + bool leaveZeroFloorInKind + ) internal { + // Every router call in this handler funnels through here, so the floor + // is enforced structurally rather than by remembering to call it. + amountOutMin = _twapMinOut(tokenIn, tokenOut, amountIn, amountOutMin, slippageBps); + // Dynamic harvest/reward deltas can be non-zero while their quoted + // output rounds to zero in raw token units. There is no enforceable + // price boundary in that case, so keep the dust on the Safe instead of + // either making an unprotected router call or reverting the harvest. + // Known-input swaps (open legs) pass false and remain fail-closed. + if (amountOutMin == 0) { + if (leaveZeroFloorInKind) return; + revert InvalidSwapAmountOutMin(); + } + bytes memory swapData = _buildSwapCalldata( + tokenIn, + tokenOut, + poolParam, + _onBehalfOf, + amountIn, + amountOutMin, + deadline + ); + _safeApprove(_onBehalfOf, tokenIn, SWAP_ROUTER, amountIn, approveStep); + _safeExec(_onBehalfOf, SWAP_ROUTER, swapData, execStep); + _safeApprove(_onBehalfOf, tokenIn, SWAP_ROUTER, 0, resetStep); + } + + /// @dev Swap the `token` the Safe accrued since `balanceBefore` back to + /// USDC. No-op for the USDC side itself — its "delta" IS the realized + /// output, measured by the caller. + function _swapDeltaToUsdc( + address _onBehalfOf, + address token, + uint256 balanceBefore, + SwapLeg calldata leg, + uint256 deadline, + uint16 slippageBps, + uint8 approveStep, + uint8 execStep, + uint8 resetStep, + bool leaveZeroFloorInKind + ) internal { + if (token == address(USDC)) return; + uint256 delta = IERC20(token).balanceOf(_onBehalfOf) - balanceBefore; + if (delta > 0) { + _swapViaSafe( + _onBehalfOf, + token, + address(USDC), + leg.poolParam, + delta, + leg.amountOutMin, + deadline, + slippageBps, + approveStep, + execStep, + resetStep, + leaveZeroFloorInKind + ); + } + } + + /// @dev Route checks only: the leg's pool param must be allow-listed and + /// must resolve to a pool that actually trades {token, USDC}. The USDC + /// side of a pair has no swap, so its (ignored) leg is not validated. + /// The PRICE check is not here — it needs the input amount, which is + /// only known at the swap itself, so it lives in `_swapViaSafe`. + function _validateSwapLeg(address token, SwapLeg calldata leg, uint16 slippageBps) internal view { + if (token == address(USDC)) return; + if (slippageBps == 0) revert SlippageTooLow(); + if (slippageBps > _yieldStorage().maxSlippageBps) revert SlippageAboveMax(); + _validatePoolParamAllowed(leg.poolParam); + (address expect0, address expect1) = token < address(USDC) ? (token, address(USDC)) : (address(USDC), token); + _validatePool(_getPool(leg.poolParam), expect0, expect1); + } + + /// @dev Min-out actually handed to the router: what the reference TWAP says + /// `amountIn` is worth, less `slippageBps`, and never less than what + /// the caller asked for. The caller may TIGHTEN the bound; it can no + /// longer loosen it, which is the whole point — the previous floor was + /// checked against a number the same caller supplied. + /// + /// Deriving rather than merely validating also fixes the case that has + /// no honest answer otherwise: `collectLp` swaps fees whose size is + /// unknown until the collect executes, so no caller-supplied absolute + /// minimum can be right. Such callers pass 0 and get the floor. + /// + /// Reverts when the token has no reference configured — swapping a + /// token nobody has priced is exactly the case that must not proceed, + /// and `withdrawLp` still exits such a position in kind. + function _twapMinOut( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 callerMinOut, + uint16 slippageBps + ) internal view returns (uint256) { + address token = tokenIn == address(USDC) ? tokenOut : tokenIn; + TwapConfig memory cfg = _yieldStorage().twapConfigOf[token]; + if (cfg.pool == address(0)) revert TwapOracle.TwapNotConfigured(token); + uint256 floor = Math.mulDiv(TwapOracle.quote(cfg, tokenIn, tokenOut, amountIn), 10_000 - slippageBps, 10_000); + return callerMinOut > floor ? callerMinOut : floor; + } + + function _validatePoolParamAllowed(bytes memory poolParam) internal view { + if (!_yieldStorage().allowedPoolKey[PROTOCOL][keccak256(poolParam)]) revert PoolParamNotAllowed(); + } + + /// @dev Validate a resolved pool: exists, trades the expected pair, + /// initialized, and above the per-protocol liquidity floor. Returns + /// the sqrt price so valuation callers don't re-read slot0. + function _validatePool( + address pool, + address expectedToken0, + address expectedToken1 + ) internal view returns (uint160 sqrtPriceX96) { + if (pool == address(0)) revert PoolDoesNotExist(); + if (IPoolMinimal(pool).token0() != expectedToken0 || IPoolMinimal(pool).token1() != expectedToken1) { + revert WrongTokenPair(); + } + sqrtPriceX96 = _poolSqrtPriceX96(pool); + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + uint128 floor = _yieldStorage().minPoolLiquidity[PROTOCOL]; + if (floor > 0 && IPoolMinimal(pool).liquidity() < floor) revert PoolTooThin(); + } + + /// @dev Module-mediated ERC20 approve from the Safe. Supports both + /// standard bool-returning tokens and no-return tokens, and rejects a + /// false return so a failed zero-reset cannot leave router allowance + /// live after an otherwise-successful operation. + function _safeApprove(address _onBehalfOf, address token, address spender, uint256 amount, uint8 step) internal { + bytes memory ret = _safeExec(_onBehalfOf, token, abi.encodeCall(IERC20.approve, (spender, amount)), step); + if (!TokenReturnLib.returnedTrue(ret)) revert TokenApprovalFailed(token); + } + + /// @dev Module-mediated Safe call with inner-revert bubbling. + function _safeExec( + address _onBehalfOf, + address target, + bytes memory data, + uint8 step + ) internal returns (bytes memory ret) { + bool ok; + (ok, ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData(target, 0, data, ISafe.Operation.Call); + if (!ok) { + if (ret.length > 0) Address.verifyCallResult(ok, ret); + revert ModuleCallFailed(step); + } + } + + /// @dev The mint half shared by `openLp` and `openLpInKind`: approve the + /// position manager for both sides, mint through the Safe, enforce the + /// per-protocol liquidity floor, reset both allowances, and confirm the + /// Safe owns the new NFT. The (tokenId, liquidity, amount0, amount1) + /// mint return shape is shared by both position managers. + function _mintFromAmounts( + address token0, + address token1, + MintArgs memory a + ) internal returns (uint256 tokenId, uint128 used0, uint128 used1) { + _safeApprove(a.onBehalfOf, token0, POSITION_MANAGER, a.amount0, 22); + _safeApprove(a.onBehalfOf, token1, POSITION_MANAGER, a.amount1, 23); + + bytes memory mintCall = _buildMintCalldata( + a.lpPoolParam, + a.tickLower, + a.tickUpper, + a.amount0, + a.amount1, + a.mintAmount0Min, + a.mintAmount1Min, + a.onBehalfOf, + a.deadline + ); + bytes memory ret = _safeExec(a.onBehalfOf, POSITION_MANAGER, mintCall, 4); + + uint128 liquidityMinted; + { + uint256 amount0Out; + uint256 amount1Out; + (tokenId, liquidityMinted, amount0Out, amount1Out) = abi.decode(ret, (uint256, uint128, uint256, uint256)); + used0 = amount0Out.toUint128(); + used1 = amount1Out.toUint128(); + } + if (liquidityMinted < _yieldStorage().minPositionLiquidity[PROTOCOL]) revert PositionLiquidityTooLow(); + + _safeApprove(a.onBehalfOf, token0, POSITION_MANAGER, 0, 24); + _safeApprove(a.onBehalfOf, token1, POSITION_MANAGER, 0, 25); + + _requireOwnedBy(a.onBehalfOf, tokenId); + } +} diff --git a/contracts/yield/handlers/UniV3YieldHandler.sol b/contracts/yield/handlers/UniV3YieldHandler.sol new file mode 100644 index 0000000..5b033bf --- /dev/null +++ b/contracts/yield/handlers/UniV3YieldHandler.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IUniswapV3Factory} from "../../interfaces/uniswapV3/IUniswapV3Factory.sol"; +import {V3StyleYieldHandler} from "./V3StyleYieldHandler.sol"; +import "../../common/Types.sol"; + +/// @title UniV3YieldHandler +/// @notice Uniswap V3 adapter for SafeYieldManager. Pool params are +/// `abi.encode(token0, token1, uint24 feeTier)`. Stateless — +/// executed via delegatecall from the manager. All hook mechanics +/// live in V3StyleYieldHandler; this contract only pins the +/// canonical id. +contract UniV3YieldHandler is V3StyleYieldHandler { + constructor( + address _positionManager, + IERC20 _usdc, + address _swapRouter, + IUniswapV3Factory _uniswapV3Factory + ) V3StyleYieldHandler(YIELD_PROTOCOL_UNISWAP_V3, _positionManager, _usdc, _swapRouter, _uniswapV3Factory) {} +} diff --git a/contracts/yield/handlers/UniV4YieldHandler.sol b/contracts/yield/handlers/UniV4YieldHandler.sol new file mode 100644 index 0000000..2d7cc85 --- /dev/null +++ b/contracts/yield/handlers/UniV4YieldHandler.sol @@ -0,0 +1,871 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ISafe} from "../../interfaces/safe/ISafe.sol"; +import {IWETH9} from "../../interfaces/IWETH9.sol"; +import {IYieldHandler, OpenLpParams, CloseLpParams, CollectLpParams, WithdrawLpParams, OpenLpInKindParams, SwapLeg} from "../../interfaces/IYieldHandler.sol"; +import {PoolKey, ExactInputSingleParams} from "../../interfaces/uniswapV4/V4Types.sol"; +import {IV4PositionManager} from "../../interfaces/uniswapV4/IV4PositionManager.sol"; +import {IStateView} from "../../interfaces/uniswapV4/IStateView.sol"; +import {IUniversalRouter} from "../../interfaces/uniswapV4/IUniversalRouter.sol"; +import {IAllowanceTransfer} from "../../interfaces/uniswapV4/IAllowanceTransfer.sol"; +import {V4Actions, V4Commands} from "../../interfaces/uniswapV4/V4Constants.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {LiquidityAmounts} from "@uniswap/v4-periphery/src/libraries/LiquidityAmounts.sol"; +import {TokenReturnLib} from "../libraries/TokenReturnLib.sol"; +import {TwapOracle} from "../libraries/TwapOracle.sol"; +import {YieldStorage} from "./YieldStorage.sol"; +import "../../common/Types.sol"; + +/// @title UniV4YieldHandler +/// @notice Uniswap V4 adapter for SafeYieldManager, implementing IYieldHandler +/// directly (the V4 singleton/actions architecture does not fit +/// BaseYieldHandler's V3-shaped hooks). Pool params are the full +/// PoolKey tuple `abi.encode(currency0, currency1, fee, tickSpacing, +/// hooks)`, so `keccak256(poolParam)` IS the V4 PoolId and the +/// manager's `allowedPoolKey` allow-list pins exactly one pool per +/// param. Native ETH pools (`currency0 == address(0)`) are supported; +/// hooked pools are gated exclusively by the admin allow-list. +/// @dev STATELESS: MUST NOT declare storage variables — all mutable state +/// lives in the ERC-7201 `YieldStorage` namespace (delegatecall from +/// the manager is layout-safe). Immutables are fine. +/// @dev V4 differences absorbed here rather than in the manager: +/// - No pool contracts: state reads go through StateView by PoolId. +/// - `modifyLiquidities` returns nothing: the token id is read from +/// `nextTokenId()` before the mint (atomic within the tx), amounts +/// are measured as Safe balance deltas. +/// - Mint takes a LIQUIDITY amount (computed on-chain from the +/// post-swap sqrt price) with amount0Max/amount1Max settle caps; +/// the caller's V3-shaped mintAmount0Min/1Min are enforced post-hoc. +/// - ERC20 input reaches the PositionManager / UniversalRouter via +/// Permit2, so approvals are two-step and both hops are reset to +/// zero in the same flow. Native ETH needs no approvals; it rides +/// as call value and excess mint value is swept back to the Safe. +/// - The live manager cannot receive native ETH, so harvested fees +/// are taken straight to the Safe and `feeCollectBps` is skimmed +/// from the Safe by module call (waive-on-failure, mirroring the +/// manager's performance-fee semantics). +contract UniV4YieldHandler is IYieldHandler, YieldStorage { + using SafeCast for uint256; + + /// @notice Native ETH sentinel (V4 `currency0` for native pools). + address internal constant NATIVE = address(0); + + /// @notice Protocol id (canonical ids are the YIELD_PROTOCOL_* constants + /// in Types.sol). A constant — this handler is V4-only. + uint8 public constant PROTOCOL = YIELD_PROTOCOL_UNISWAP_V4; + + IV4PositionManager public immutable POSITION_MANAGER; + address public immutable UNIVERSAL_ROUTER; + IAllowanceTransfer public immutable PERMIT2; + IStateView public immutable STATE_VIEW; + IERC20 public immutable USDC; + /// @notice Wrapped native token. In-kind switch legs deal in ERC20s only, + /// so a native pool side is wrapped on withdraw / unwrapped on + /// open (1:1, no price exposure). + IWETH9 public immutable WETH; + + /// @dev Own deployment address, captured at construction to enforce + /// delegatecall-only entry (a direct call would run against the + /// handler's empty storage). + address private immutable __self = address(this); + + error OnlyDelegatecall(); + error TokenApprovalFailed(address token); + /// @notice V4 has no stakePool; `OpenLpParams.stake` must be false. + error StakingNotSupported(); + /// @notice V4 mint has no native amount minimums (only settle caps), so + /// the caller's mintAmount0Min/1Min are enforced after the mint + /// against the amounts actually consumed. + error MintAmountBelowMin(); + + /// @dev Inputs for `_mintFromAmounts`, the mint half shared by `openLp` + /// and `openLpInKind`. Grouped in a struct so the shared helper takes + /// three arguments instead of ten — the flat form pushes both callers + /// past the stack limit once coverage instrumentation is layered on. + struct MintArgs { + address onBehalfOf; + int24 tickLower; + int24 tickUpper; + uint256 amount0; + uint256 amount1; + uint256 mintAmount0Min; + uint256 mintAmount1Min; + uint256 deadline; + } + + /// @dev The five module-call step codes a Permit2 swap reports failures + /// under. Grouped for the same reason as `MintArgs`: passing them flat + /// alongside the swap's own arguments overruns the stack under viaIR. + struct SwapSteps { + uint8 approveStep; + uint8 permit2Step; + uint8 execStep; + uint8 permit2ResetStep; + uint8 resetStep; + } + + modifier onlyDelegatecall() { + if (address(this) == __self) revert OnlyDelegatecall(); + _; + } + + constructor( + IV4PositionManager _positionManager, + address _universalRouter, + IAllowanceTransfer _permit2, + IStateView _stateView, + IERC20 _usdc, + IWETH9 _weth + ) { + if (address(_positionManager) == address(0)) revert ZeroAddress(); + if (_universalRouter == address(0)) revert ZeroAddress(); + if (address(_permit2) == address(0)) revert ZeroAddress(); + if (address(_stateView) == address(0)) revert ZeroAddress(); + if (address(_usdc) == address(0)) revert ZeroAddress(); + if (address(_weth) == address(0)) revert ZeroAddress(); + + POSITION_MANAGER = _positionManager; + UNIVERSAL_ROUTER = _universalRouter; + PERMIT2 = _permit2; + STATE_VIEW = _stateView; + USDC = _usdc; + WETH = _weth; + } + + // ───────────────────────────────────────────────────────────────────── + // IYieldHandler + // ───────────────────────────────────────────────────────────────────── + + /// @inheritdoc IYieldHandler + function poolTokens(bytes calldata lpPoolParam) external pure returns (address token0, address token1) { + PoolKey memory key = _decodePoolParam(lpPoolParam); + return (key.currency0, key.currency1); + } + + /// @inheritdoc IYieldHandler + function openLp( + OpenLpParams calldata p + ) external onlyDelegatecall returns (uint256 tokenId, uint128 basisUsd6, uint128 used0, uint128 used1) { + if (p.stake) revert StakingNotSupported(); + _validatePoolParamAllowed(p.lpPoolParam); + (PoolKey memory key, ) = _validatePoolReady(p.lpPoolParam); + + uint256 half0 = p.usdcAmount / 2; + uint256 half1 = p.usdcAmount - half0; + + uint256 desired0 = _acquireSide(p, key.currency0, half0, p.swap0, SwapSteps(41, 42, 43, 44, 45)); + uint256 desired1 = _acquireSide(p, key.currency1, half1, p.swap1, SwapSteps(46, 47, 48, 49, 50)); + + // The leg swaps above moved the pool price, so the mint must price + // against a FRESH read — not the one `_validatePoolReady` returned. + (uint160 sqrtPriceX96, , , ) = STATE_VIEW.getSlot0(keccak256(p.lpPoolParam)); + (tokenId, used0, used1) = _mintFromAmounts( + key, + sqrtPriceX96, + MintArgs({ + onBehalfOf: p.onBehalfOf, + tickLower: p.tickLower, + tickUpper: p.tickUpper, + amount0: desired0, + amount1: desired1, + mintAmount0Min: p.mintAmount0Min, + mintAmount1Min: p.mintAmount1Min, + deadline: p.deadline + }) + ); + + // Value each leg at its just-executed swap rate (identity for USDC). + basisUsd6 = (_legValueUsdc(key.currency0, used0, half0, desired0) + + _legValueUsdc(key.currency1, used1, half1, desired1)).toUint128(); + } + + /// @inheritdoc IYieldHandler + function closeLp( + CloseLpParams calldata p, + uint128 basisForExit + ) external onlyDelegatecall returns (uint128 currentValueUsd6) { + (PoolKey memory key, ) = POSITION_MANAGER.getPoolAndPositionInfo(p.tokenId); + uint128 liquidity = POSITION_MANAGER.getPositionLiquidity(p.tokenId); + _validateSwapLeg(key.currency0, p.swap0, p.slippageBps); + _validateSwapLeg(key.currency1, p.swap1, p.slippageBps); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + // Measure only deltas from this close (USDC sides need no snapshot — + // their swap leg is skipped and the USDC delta is measured below). + uint256 t0Before = key.currency0 == address(USDC) ? 0 : _balanceOf(key.currency0, p.onBehalfOf); + uint256 t1Before = key.currency1 == address(USDC) ? 0 : _balanceOf(key.currency1, p.onBehalfOf); + uint256 usdcBefore = USDC.balanceOf(p.onBehalfOf); + + // Harvest fees before principal so feeCollectBps does not tax capital. + _collectV4Fees(p.onBehalfOf, p.tokenId, key); + + // On full close, BURN_POSITION auto-decreases all remaining liquidity. + uint128 liquidityToRemove = p.exitBps == 10_000 + ? liquidity + : Math.mulDiv(uint256(liquidity), uint256(p.exitBps), 10_000).toUint128(); + + // Do not let the manager's basis decrement stand when rounding + // removes zero liquidity (basis would shrink while principal stays). + if (p.exitBps != 10_000 && basisForExit > 0 && liquidityToRemove == 0) { + revert InvalidExitBps(); + } + + if (p.exitBps == 10_000) { + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + p.tokenId, + p.decreaseAmount0Min.toUint128(), + p.decreaseAmount1Min.toUint128(), + bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1, p.onBehalfOf); + _safeModifyLiquidities( + p.onBehalfOf, + abi.encodePacked(V4Actions.BURN_POSITION, V4Actions.TAKE_PAIR), + params, + p.deadline, + 62 + ); + } else if (liquidityToRemove > 0) { + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + p.tokenId, + uint256(liquidityToRemove), + p.decreaseAmount0Min.toUint128(), + p.decreaseAmount1Min.toUint128(), + bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1, p.onBehalfOf); + _safeModifyLiquidities( + p.onBehalfOf, + abi.encodePacked(V4Actions.DECREASE_LIQUIDITY, V4Actions.TAKE_PAIR), + params, + p.deadline, + 61 + ); + } + + // Swap the non-USDC legs this close produced back to USDC. + _swapDeltaToUsdc( + p.onBehalfOf, + key.currency0, + t0Before, + p.swap0, + p.deadline, + p.slippageBps, + SwapSteps(63, 64, 65, 66, 67), + false + ); + _swapDeltaToUsdc( + p.onBehalfOf, + key.currency1, + t1Before, + p.swap1, + p.deadline, + p.slippageBps, + SwapSteps(68, 69, 70, 71, 72), + false + ); + + currentValueUsd6 = (USDC.balanceOf(p.onBehalfOf) - usdcBefore).toUint128(); + // Caller's final-value guard on gross realized USDC. + if (uint256(currentValueUsd6) < p.minUsdcOut) revert MinUsdcOutNotMet(); + } + + /// @inheritdoc IYieldHandler + /// @dev In-kind close leg of a switch: fee harvest + full BURN_POSITION + /// with NO swaps. A native side is wrapped to WETH so the caller + /// always receives ERC20 addresses and amounts. + function withdrawLp( + WithdrawLpParams calldata p + ) external onlyDelegatecall returns (address token0, address token1, uint256 amount0, uint256 amount1) { + (PoolKey memory key, ) = POSITION_MANAGER.getPoolAndPositionInfo(p.tokenId); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + uint256 c0Before = _balanceOf(key.currency0, p.onBehalfOf); + uint256 c1Before = _balanceOf(key.currency1, p.onBehalfOf); + + // Harvest fees before principal so feeCollectBps does not tax capital. + _collectV4Fees(p.onBehalfOf, p.tokenId, key); + + // BURN_POSITION auto-decreases all remaining liquidity. + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + p.tokenId, + p.decreaseAmount0Min.toUint128(), + p.decreaseAmount1Min.toUint128(), + bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1, p.onBehalfOf); + _safeModifyLiquidities( + p.onBehalfOf, + abi.encodePacked(V4Actions.BURN_POSITION, V4Actions.TAKE_PAIR), + params, + p.deadline, + 62 + ); + + amount0 = _balanceOf(key.currency0, p.onBehalfOf) - c0Before; + amount1 = _balanceOf(key.currency1, p.onBehalfOf) - c1Before; + + token0 = key.currency0; + token1 = key.currency1; + if (key.currency0 == NATIVE) { + if (amount0 > 0) { + _safeExecValue(p.onBehalfOf, address(WETH), amount0, abi.encodeCall(IWETH9.deposit, ()), 73); + } + token0 = address(WETH); + } + } + + /// @inheritdoc IYieldHandler + /// @dev In-kind open leg of a switch: mint straight from the withdrawn + /// token amounts — no swaps. A native pool side settles in ETH, so + /// the provided WETH is unwrapped first (1:1, no price exposure); + /// SWEEP returns any unconsumed ETH to the Safe. + function openLpInKind( + OpenLpInKindParams calldata p + ) external onlyDelegatecall returns (uint256 tokenId, uint128 used0, uint128 used1) { + _validatePoolParamAllowed(p.lpPoolParam); + (PoolKey memory key, uint160 sqrtPriceX96) = _validatePoolReady(p.lpPoolParam); + bool native0 = key.currency0 == NATIVE; + address want0 = native0 ? address(WETH) : key.currency0; + if (p.token0 != want0 || p.token1 != key.currency1) revert WrongTokenPair(); + + if (native0 && p.amount0 > 0) { + _safeExecValue(p.onBehalfOf, address(WETH), 0, abi.encodeCall(IWETH9.withdraw, (p.amount0)), 74); + } + + // No swaps ran, so the price `_validatePoolReady` read is still live. + (tokenId, used0, used1) = _mintFromAmounts( + key, + sqrtPriceX96, + MintArgs({ + onBehalfOf: p.onBehalfOf, + tickLower: p.tickLower, + tickUpper: p.tickUpper, + amount0: p.amount0, + amount1: p.amount1, + mintAmount0Min: p.mintAmount0Min, + mintAmount1Min: p.mintAmount1Min, + deadline: p.deadline + }) + ); + } + + /// @dev The mint half shared by `openLp` and `openLpInKind`. Derives the + /// liquidity for `amount0/amount1` at `sqrtPriceX96`, grants the + /// two-step Permit2 allowances (a native side rides as call value and + /// needs none), mints, resets BOTH allowance hops, and reports what + /// the mint actually consumed. `modifyLiquidities` returns nothing, so + /// the token id comes from `nextTokenId()` before the mint (atomic + /// within this tx) and consumption is measured as Safe balance deltas. + function _mintFromAmounts( + PoolKey memory key, + uint160 sqrtPriceX96, + MintArgs memory a + ) internal returns (uint256 tokenId, uint128 used0, uint128 used1) { + uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(a.tickLower), + TickMath.getSqrtPriceAtTick(a.tickUpper), + a.amount0, + a.amount1 + ); + if (liquidity == 0 || liquidity < _yieldStorage().minPositionLiquidity[PROTOCOL]) { + revert PositionLiquidityTooLow(); + } + + bool native0 = key.currency0 == NATIVE; + if (!native0) { + _safeApprove(a.onBehalfOf, key.currency0, address(PERMIT2), a.amount0, 51); + _permit2Approve(a.onBehalfOf, key.currency0, address(POSITION_MANAGER), a.amount0, a.deadline, 52); + } + _safeApprove(a.onBehalfOf, key.currency1, address(PERMIT2), a.amount1, 53); + _permit2Approve(a.onBehalfOf, key.currency1, address(POSITION_MANAGER), a.amount1, a.deadline, 54); + + tokenId = POSITION_MANAGER.nextTokenId(); + uint256 bal0Before = _balanceOf(key.currency0, a.onBehalfOf); + uint256 bal1Before = _balanceOf(key.currency1, a.onBehalfOf); + + _safeMintLp(a.onBehalfOf, key, a.tickLower, a.tickUpper, liquidity, a.amount0, a.amount1, a.deadline); + + if (!native0) { + _permit2Approve(a.onBehalfOf, key.currency0, address(POSITION_MANAGER), 0, 0, 56); + _safeApprove(a.onBehalfOf, key.currency0, address(PERMIT2), 0, 57); + } + _permit2Approve(a.onBehalfOf, key.currency1, address(POSITION_MANAGER), 0, 0, 58); + _safeApprove(a.onBehalfOf, key.currency1, address(PERMIT2), 0, 59); + + used0 = (bal0Before - _balanceOf(key.currency0, a.onBehalfOf)).toUint128(); + used1 = (bal1Before - _balanceOf(key.currency1, a.onBehalfOf)).toUint128(); + if (used0 < a.mintAmount0Min || used1 < a.mintAmount1Min) revert MintAmountBelowMin(); + + _requireOwnedBy(a.onBehalfOf, tokenId); + } + + /// @inheritdoc IYieldHandler + function collectLp(CollectLpParams calldata p) external onlyDelegatecall { + // V4 has no stakePool: `swapRewardToUsdc` / `rewardSwap` are ignored, + // matching how unstaked positions behave on the staking protocols. + (PoolKey memory key, ) = POSITION_MANAGER.getPoolAndPositionInfo(p.tokenId); + _requireOwnedBy(p.onBehalfOf, p.tokenId); + + // Swap params are validated only on the swap path; the no-swap + // path intentionally ignores them. + uint256 t0Before; + uint256 t1Before; + if (p.swapFeesToUsdc) { + if (block.timestamp > p.deadline) revert DeadlineExpired(); + _validateSwapLeg(key.currency0, p.swap0, p.slippageBps); + _validateSwapLeg(key.currency1, p.swap1, p.slippageBps); + t0Before = key.currency0 == address(USDC) ? 0 : _balanceOf(key.currency0, p.onBehalfOf); + t1Before = key.currency1 == address(USDC) ? 0 : _balanceOf(key.currency1, p.onBehalfOf); + } + + _collectV4Fees(p.onBehalfOf, p.tokenId, key); + if (p.swapFeesToUsdc) { + _swapDeltaToUsdc( + p.onBehalfOf, + key.currency0, + t0Before, + p.swap0, + p.deadline, + p.slippageBps, + SwapSteps(63, 64, 65, 66, 67), + true + ); + _swapDeltaToUsdc( + p.onBehalfOf, + key.currency1, + t1Before, + p.swap1, + p.deadline, + p.slippageBps, + SwapSteps(68, 69, 70, 71, 72), + true + ); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Open internals + // ───────────────────────────────────────────────────────────────────── + + /// @dev Acquire one mint side from its USDC half: identity for USDC, a + /// leg-validated swap for any other currency (incl. native ETH). + /// The swap input is always USDC, so all five module steps apply. + function _acquireSide( + OpenLpParams calldata p, + address currency, + uint256 halfUsdc, + SwapLeg calldata leg, + SwapSteps memory steps + ) internal returns (uint256 received) { + if (currency == address(USDC)) return halfUsdc; + + _validateSwapLeg(currency, leg, p.slippageBps); + // Only consume tokens produced by this call, never pre-existing ones. + uint256 balanceBefore = _balanceOf(currency, p.onBehalfOf); + _swapV4ViaSafe( + p.onBehalfOf, + address(USDC), + currency, + leg.poolParam, + halfUsdc, + leg.amountOutMin, + p.deadline, + p.slippageBps, + steps, + false + ); + received = _balanceOf(currency, p.onBehalfOf) - balanceBefore; + // Avoid accidental one-sided mints after a zero-output swap. + if (received == 0) revert SwapFailed(); + } + + /// @dev Module-mediated V4 mint: MINT_POSITION + SETTLE_PAIR, plus a + /// SWEEP back to the Safe on native pools (the mint's call value is + /// the acquired ETH; SETTLE consumes only what the liquidity needs). + function _safeMintLp( + address _onBehalfOf, + PoolKey memory key, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 desired0, + uint256 desired1, + uint256 deadline + ) internal { + bool native = key.currency0 == NATIVE; + bytes memory actions = native + ? abi.encodePacked(V4Actions.MINT_POSITION, V4Actions.SETTLE_PAIR, V4Actions.SWEEP) + : abi.encodePacked(V4Actions.MINT_POSITION, V4Actions.SETTLE_PAIR); + bytes[] memory params = new bytes[](native ? 3 : 2); + params[0] = abi.encode( + key, + tickLower, + tickUpper, + uint256(liquidity), + desired0.toUint128(), + desired1.toUint128(), + _onBehalfOf, + bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1); + if (native) params[2] = abi.encode(key.currency0, _onBehalfOf); + + _safeExecValue( + _onBehalfOf, + address(POSITION_MANAGER), + native ? desired0 : 0, + abi.encodeCall(IV4PositionManager.modifyLiquidities, (abi.encode(actions, params), deadline)), + 55 + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Fee collection + // ───────────────────────────────────────────────────────────────────── + + /// @dev Harvest accrued fees with the canonical V4 pattern (decrease of 0 + /// liquidity + TAKE_PAIR). Fees are taken straight to the SAFE — the + /// live manager has no receive() and cannot hold native ETH — and + /// `feeCollectBps` is then skimmed from the Safe by module call. + function _collectV4Fees(address _onBehalfOf, uint256 tokenId, PoolKey memory key) internal { + uint256 c0Before = _balanceOf(key.currency0, _onBehalfOf); + uint256 c1Before = _balanceOf(key.currency1, _onBehalfOf); + + bytes[] memory params = new bytes[](2); + params[0] = abi.encode(tokenId, uint256(0), uint128(0), uint128(0), bytes("")); + params[1] = abi.encode(key.currency0, key.currency1, _onBehalfOf); + _safeModifyLiquidities( + _onBehalfOf, + abi.encodePacked(V4Actions.DECREASE_LIQUIDITY, V4Actions.TAKE_PAIR), + params, + // Fee harvest carries no price exposure; the flows' own deadline + // checks (manager openLp/closeLp, collectLp swap path) still gate. + block.timestamp, + 60 + ); + + uint256 collected0 = _balanceOf(key.currency0, _onBehalfOf) - c0Before; + uint256 collected1 = _balanceOf(key.currency1, _onBehalfOf) - c1Before; + + uint256 fee0 = _chargeCollectFee(key.currency0, collected0, _onBehalfOf, tokenId); + uint256 fee1 = _chargeCollectFee(key.currency1, collected1, _onBehalfOf, tokenId); + + emit FeesCollected( + _onBehalfOf, + PROTOCOL, + tokenId, + key.currency0, + collected0, + fee0, + key.currency1, + collected1, + fee1 + ); + } + + /// @dev Skim `feeCollectBps` from the Safe to the treasury by module + /// call (the harvested amount already sits on the Safe). A failed + /// transfer waives the fee instead of blocking users — the same + /// semantics as the base handlers and the manager's performance fee. + function _chargeCollectFee( + address currency, + uint256 amount, + address _onBehalfOf, + uint256 tokenId + ) internal returns (uint256 fee) { + if (amount == 0) return 0; + YieldLayout storage $ = _yieldStorage(); + fee = (amount * $.feeCollectBps) / 10_000; + if (fee == 0) return 0; + + bool ok; + bytes memory ret; + if (currency == NATIVE) { + (ok, ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + $.treasury, + fee, + "", + ISafe.Operation.Call + ); + } else { + (ok, ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData( + currency, + 0, + abi.encodeCall(IERC20.transfer, ($.treasury, fee)), + ISafe.Operation.Call + ); + if (ok) ok = TokenReturnLib.returnedTrue(ret); + } + if (!ok) { + emit CollectFeeTransferFailed(_onBehalfOf, tokenId, currency, fee); + fee = 0; + } + } + + // ───────────────────────────────────────────────────────────────────── + // Swaps + // ───────────────────────────────────────────────────────────────────── + + /// @dev Module-mediated exact-input single V4 swap on the UniversalRouter; + /// calldata is built on-chain so callers cannot inject alternative + /// routes. ERC20 input goes through the two-step Permit2 approval + /// (both hops reset after); native ETH input rides as call value and + /// needs no approvals. `msg.sender` of `execute` is the Safe, so + /// SETTLE_ALL pulls from and TAKE_ALL pays to the Safe directly. + function _swapV4ViaSafe( + address _onBehalfOf, + address currencyIn, + address currencyOut, + bytes memory poolParam, + uint256 amountIn, + uint256 amountOutMin, + uint256 deadline, + uint16 slippageBps, + SwapSteps memory steps, + bool leaveZeroFloorInKind + ) internal { + // Single funnel for every UniversalRouter call in this handler. + amountOutMin = _twapMinOut(currencyIn, currencyOut, amountIn, amountOutMin, slippageBps); + if (amountOutMin == 0) { + if (leaveZeroFloorInKind) return; + revert InvalidSwapAmountOutMin(); + } + PoolKey memory key = _decodePoolParam(poolParam); + + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + ExactInputSingleParams({ + poolKey: key, + zeroForOne: currencyIn == key.currency0, + amountIn: amountIn.toUint128(), + amountOutMinimum: amountOutMin.toUint128(), + hookData: bytes("") + }) + ); + params[1] = abi.encode(currencyIn, amountIn); // SETTLE_ALL: pay input from the Safe + params[2] = abi.encode(currencyOut, amountOutMin); // TAKE_ALL: output to the Safe, router-level floor + + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode( + abi.encodePacked(V4Actions.SWAP_EXACT_IN_SINGLE, V4Actions.SETTLE_ALL, V4Actions.TAKE_ALL), + params + ); + bytes memory execData = abi.encodeCall( + IUniversalRouter.execute, + (abi.encodePacked(V4Commands.V4_SWAP), inputs, deadline) + ); + + if (currencyIn == NATIVE) { + _safeExecValue(_onBehalfOf, UNIVERSAL_ROUTER, amountIn, execData, steps.execStep); + } else { + _safeApprove(_onBehalfOf, currencyIn, address(PERMIT2), amountIn, steps.approveStep); + _permit2Approve(_onBehalfOf, currencyIn, UNIVERSAL_ROUTER, amountIn, deadline, steps.permit2Step); + _safeExecValue(_onBehalfOf, UNIVERSAL_ROUTER, 0, execData, steps.execStep); + _permit2Approve(_onBehalfOf, currencyIn, UNIVERSAL_ROUTER, 0, 0, steps.permit2ResetStep); + _safeApprove(_onBehalfOf, currencyIn, address(PERMIT2), 0, steps.resetStep); + } + } + + /// @dev Swap the `currency` the Safe accrued since `balanceBefore` back + /// to USDC. No-op for the USDC side itself — its "delta" IS the + /// realized output, measured by the caller. + function _swapDeltaToUsdc( + address _onBehalfOf, + address currency, + uint256 balanceBefore, + SwapLeg calldata leg, + uint256 deadline, + uint16 slippageBps, + SwapSteps memory steps, + bool leaveZeroFloorInKind + ) internal { + if (currency == address(USDC)) return; + uint256 delta = _balanceOf(currency, _onBehalfOf) - balanceBefore; + if (delta > 0) { + _swapV4ViaSafe( + _onBehalfOf, + currency, + address(USDC), + leg.poolParam, + delta, + leg.amountOutMin, + deadline, + slippageBps, + steps, + leaveZeroFloorInKind + ); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Validation + // ───────────────────────────────────────────────────────────────────── + + function _decodePoolParam(bytes memory poolParam) internal pure returns (PoolKey memory) { + return abi.decode(poolParam, (PoolKey)); + } + + function _validatePoolParamAllowed(bytes memory poolParam) internal view { + if (!_yieldStorage().allowedPoolKey[PROTOCOL][keccak256(poolParam)]) revert PoolParamNotAllowed(); + } + + /// @dev V4 analogue of the base's `_validatePool`: the pool has no + /// contract, so "exists" collapses into "initialized" (V4 pools are + /// lazily created; an uninitialized PoolId reads a zero sqrt price). + function _validatePoolReady( + bytes memory poolParam + ) internal view returns (PoolKey memory key, uint160 sqrtPriceX96) { + key = _decodePoolParam(poolParam); + bytes32 poolId = keccak256(poolParam); + (sqrtPriceX96, , , ) = STATE_VIEW.getSlot0(poolId); + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + uint128 floor = _yieldStorage().minPoolLiquidity[PROTOCOL]; + if (floor > 0 && STATE_VIEW.getLiquidity(poolId) < floor) revert PoolTooThin(); + } + + /// @dev Route checks only: allow-listed pool param, resolving to a pool + /// that actually trades {currency, USDC} (native ETH sorts first: + /// address(0) < any token). The USDC side of a pair has no swap, so + /// its (ignored) leg is not validated. The PRICE check needs the input + /// amount and so lives in `_swapV4ViaSafe`. + function _validateSwapLeg(address currency, SwapLeg calldata leg, uint16 slippageBps) internal view { + if (currency == address(USDC)) return; + if (slippageBps == 0) revert SlippageTooLow(); + if (slippageBps > _yieldStorage().maxSlippageBps) revert SlippageAboveMax(); + _validatePoolParamAllowed(leg.poolParam); + (address expect0, address expect1) = currency < address(USDC) + ? (currency, address(USDC)) + : (address(USDC), currency); + (PoolKey memory key, ) = _validatePoolReady(leg.poolParam); + if (key.currency0 != expect0 || key.currency1 != expect1) revert WrongTokenPair(); + } + + /// @dev Min-out for a UniversalRouter call, read from the SAME Uniswap V3 + /// reference history the V3 and Aerodrome handlers use — the reference + /// prices a token, not a venue. Native ETH uses the address(0) config + /// key, while quote orientation substitutes WETH so the sentinel is + /// never passed to tick math. Caller may tighten, never loosen. + function _twapMinOut( + address currencyIn, + address currencyOut, + uint256 amountIn, + uint256 callerMinOut, + uint16 slippageBps + ) internal view returns (uint256) { + address currency = currencyIn == address(USDC) ? currencyOut : currencyIn; + TwapConfig memory cfg = _yieldStorage().twapConfigOf[currency]; + if (cfg.pool == address(0)) revert TwapOracle.TwapNotConfigured(currency); + address tokenIn = currencyIn == NATIVE ? address(WETH) : currencyIn; + address tokenOut = currencyOut == NATIVE ? address(WETH) : currencyOut; + uint256 floor = Math.mulDiv(TwapOracle.quote(cfg, tokenIn, tokenOut, amountIn), 10_000 - slippageBps, 10_000); + return callerMinOut > floor ? callerMinOut : floor; + } + + // ───────────────────────────────────────────────────────────────────── + // Shared internals + // ───────────────────────────────────────────────────────────────────── + + /// @dev Balance read that treats address(0) as native ETH. + function _balanceOf(address currency, address account) internal view returns (uint256) { + return currency == NATIVE ? account.balance : IERC20(currency).balanceOf(account); + } + + /// @dev USDC value of a mint leg: the used amount itself for USDC, else + /// the used amount priced at the leg's just-executed swap rate + /// (`halfUsdc` bought `received`, the mint consumed `used` of it). + function _legValueUsdc( + address currency, + uint128 used, + uint256 halfUsdc, + uint256 received + ) internal view returns (uint256) { + if (currency == address(USDC)) return uint256(used); + return Math.mulDiv(uint256(used), halfUsdc, received); + } + + /// @dev Require the Safe to own `tokenId` on the position manager. + function _requireOwnedBy(address _onBehalfOf, uint256 tokenId) internal view { + if (IERC721(address(POSITION_MANAGER)).ownerOf(tokenId) != _onBehalfOf) revert LpNotOnSafe(); + } + + /// @dev Module-mediated `modifyLiquidities` batch (no call value — the + /// only value-bearing batch is the native mint in `_safeMintLp`). + function _safeModifyLiquidities( + address _onBehalfOf, + bytes memory actions, + bytes[] memory params, + uint256 deadline, + uint8 step + ) internal { + _safeExecValue( + _onBehalfOf, + address(POSITION_MANAGER), + 0, + abi.encodeCall(IV4PositionManager.modifyLiquidities, (abi.encode(actions, params), deadline)), + step + ); + } + + /// @dev Module-mediated Permit2 sub-allowance update. Grants expire at + /// the flow's deadline so even a missed reset dies on its own. + function _permit2Approve( + address _onBehalfOf, + address token, + address spender, + uint256 amount, + uint256 expiration, + uint8 step + ) internal { + _safeExecValue( + _onBehalfOf, + address(PERMIT2), + 0, + abi.encodeCall(IAllowanceTransfer.approve, (token, spender, amount.toUint160(), uint48(expiration))), + step + ); + } + + /// @dev Module-mediated ERC20 approve from the Safe. Supports both + /// standard bool-returning tokens and no-return tokens, and rejects a + /// false return so a failed zero-reset cannot leave Permit2 allowance + /// live after an otherwise-successful operation. + function _safeApprove(address _onBehalfOf, address token, address spender, uint256 amount, uint8 step) internal { + bytes memory ret = _safeExecValue( + _onBehalfOf, + token, + 0, + abi.encodeCall(IERC20.approve, (spender, amount)), + step + ); + if (!TokenReturnLib.returnedTrue(ret)) revert TokenApprovalFailed(token); + } + + /// @dev Module-mediated Safe call with inner-revert bubbling and native + /// call value (the base's `_safeExec` hardcodes value 0; native V4 + /// settlement needs it). + function _safeExecValue( + address _onBehalfOf, + address target, + uint256 value, + bytes memory data, + uint8 step + ) internal returns (bytes memory ret) { + bool ok; + (ok, ret) = ISafe(_onBehalfOf).execTransactionFromModuleReturnData(target, value, data, ISafe.Operation.Call); + if (!ok) { + if (ret.length > 0) Address.verifyCallResult(ok, ret); + revert ModuleCallFailed(step); + } + } +} diff --git a/contracts/yield/handlers/V3StyleYieldHandler.sol b/contracts/yield/handlers/V3StyleYieldHandler.sol new file mode 100644 index 0000000..5821663 --- /dev/null +++ b/contracts/yield/handlers/V3StyleYieldHandler.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {INonfungiblePositionManager} from "../../interfaces/uniswapV3/INonfungiblePositionManager.sol"; +import {IUniswapV3Factory} from "../../interfaces/uniswapV3/IUniswapV3Factory.sol"; +import {IUniswapV3Pool} from "../../interfaces/uniswapV3/IUniswapV3Pool.sol"; +import {IV3SwapRouter} from "../../interfaces/uniswapV3/IV3SwapRouter.sol"; +import {BaseYieldHandler} from "./BaseYieldHandler.sol"; + +/// @title V3StyleYieldHandler +/// @notice BaseYieldHandler hooks implemented against the canonical Uniswap +/// V3 interfaces (factory getPool by `uint24 feeTier`, 7-field +/// `slot0`, SwapRouter02 `exactInputSingle` without a deadline). +/// Pool params are `abi.encode(token0, token1, uint24 feeTier)`. +/// The protocol id is a constructor argument so production handlers +/// pin a canonical id while tests can register V3-shaped handlers +/// under new ids without duplicating the hook bodies. +abstract contract V3StyleYieldHandler is BaseYieldHandler { + IUniswapV3Factory public immutable FACTORY; + + constructor( + uint8 _protocol, + address _positionManager, + IERC20 _usdc, + address _swapRouter, + IUniswapV3Factory _factory + ) BaseYieldHandler(_protocol, _positionManager, _usdc, _swapRouter) { + if (address(_factory) == address(0)) revert ZeroAddress(); + FACTORY = _factory; + } + + function _decodePoolParam( + bytes memory poolParam + ) internal pure returns (address token0, address token1, uint24 feeTier) { + (token0, token1, feeTier) = abi.decode(poolParam, (address, address, uint24)); + } + + function _poolTokens(bytes memory poolParam) internal pure override returns (address token0, address token1) { + (token0, token1, ) = _decodePoolParam(poolParam); + } + + function _getPool(bytes memory poolParam) internal view override returns (address) { + (address token0, address token1, uint24 feeTier) = _decodePoolParam(poolParam); + return FACTORY.getPool(token0, token1, feeTier); + } + + function _poolSqrtPriceX96(address pool) internal view override returns (uint160 sqrtPriceX96) { + (sqrtPriceX96, , , , , , ) = IUniswapV3Pool(pool).slot0(); + } + + function _buildSwapCalldata( + address tokenIn, + address tokenOut, + bytes memory poolParam, + address recipient, + uint256 amountIn, + uint256 amountOutMin, + uint256 /* deadline — SwapRouter02 has no deadline field */ + ) internal pure override returns (bytes memory) { + (, , uint24 feeTier) = _decodePoolParam(poolParam); + return + abi.encodeCall( + IV3SwapRouter.exactInputSingle, + ( + IV3SwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: feeTier, + recipient: recipient, + amountIn: amountIn, + amountOutMinimum: amountOutMin, + sqrtPriceLimitX96: 0 + }) + ) + ); + } + + function _buildMintCalldata( + bytes memory lpPoolParam, + int24 tickLower, + int24 tickUpper, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address recipient, + uint256 deadline + ) internal pure override returns (bytes memory) { + (address token0, address token1, uint24 feeTier) = _decodePoolParam(lpPoolParam); + return + abi.encodeCall( + INonfungiblePositionManager.mint, + ( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: feeTier, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: amount0Desired, + amount1Desired: amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + recipient: recipient, + deadline: deadline + }) + ) + ); + } + + function _position( + uint256 tokenId + ) internal view override returns (address token0, address token1, bytes memory lpPoolParam, uint128 liquidity) { + uint24 feeTier; + (, , token0, token1, feeTier, , , liquidity, , , , ) = INonfungiblePositionManager(POSITION_MANAGER).positions( + tokenId + ); + lpPoolParam = abi.encode(token0, token1, feeTier); + } +} diff --git a/contracts/yield/handlers/YieldStorage.sol b/contracts/yield/handlers/YieldStorage.sol new file mode 100644 index 0000000..c3cc04c --- /dev/null +++ b/contracts/yield/handlers/YieldStorage.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import "../../common/Types.sol"; + +/// @title YieldStorage +/// @notice ERC-7201 namespaced storage shared by SafeYieldManager and its +/// delegatecalled yield handlers, plus the events/errors both sides +/// emit. Handlers MUST NOT declare regular storage variables — all +/// mutable state they touch lives in this namespaced struct, so the +/// manager's inherited storage (AccessControl, ReentrancyGuard, +/// Pausable, manager-local mappings) can never collide with handler +/// code regardless of inheritance order. +abstract contract YieldStorage { + /// @custom:storage-location erc7201:ratehopper.storage.yield + struct YieldLayout { + address treasury; + uint16 performanceFeeBps; + uint16 feeCollectBps; + /// @dev Ceiling on caller-supplied `slippageBps`. + uint16 maxSlippageBps; + /// @dev Remaining USDC cost basis per (protocol id, tokenId). Zero + /// means unmanaged or fully closed. Keyed by protocol because + /// tokenIds from different position managers can collide. + /// Protocol ids are plain uint8 (not a Solidity enum) so new + /// protocols can be registered on the deployed manager without + /// a redeploy; the YIELD_PROTOCOL_* constants in Types.sol + /// document the canonical ids. + mapping(uint8 protocolId => mapping(uint256 tokenId => uint128)) residualBasisUsd6Of; + /// @dev Allow-list of pool parameters, keyed by keccak256 of the + /// ABI-encoded protocol-specific pool param (feeTier / + /// tickSpacing / future V4 PoolKey). Generic on purpose so new + /// protocols need no new storage. + mapping(uint8 protocolId => mapping(bytes32 poolKey => bool)) allowedPoolKey; + /// @dev Minimum pool liquidity for spot-price reads. Zero disables. + mapping(uint8 protocolId => uint128) minPoolLiquidity; + /// @dev Minimum liquidity returned by NPM mint. Zero disables. + mapping(uint8 protocolId => uint128) minPositionLiquidity; + /// @dev Handler pinned at openLp per position. close/collect always + /// run through this address so `setYieldHandler` never applies + /// retroactively to positions opened under an older handler. + /// Deleted on full close together with the basis. + mapping(uint8 protocolId => mapping(uint256 tokenId => address)) positionHandlerOf; + /// @dev Aerodrome stake pool pinned when a position is staked. The + /// Voter mapping is governance-controlled and may rotate later; + /// exits must use the pool that actually owns the NFT. + mapping(uint8 protocolId => mapping(uint256 tokenId => address)) stakePoolOf; + /// @dev Reference price source per token, keyed by the NON-USDC side of + /// the pair. Not keyed by protocol: the reference is the token's + /// price, not a venue, so an Aerodrome or Uniswap V4 swap is + /// floored by the same Uniswap V3 observation history. Native ETH + /// is keyed by address(0), with a WETH/USDC reference pool. + mapping(address token => TwapConfig) twapConfigOf; + /// @dev Profit already taken OUT of a position but not yet charged a + /// performance fee, in USDC 6dp. An in-kind switch redeploys only + /// what the destination range can consume; the rest lands on the + /// Safe. That residue first repays cost basis, and anything beyond + /// the basis is realized profit the eventual `closeLp` would + /// otherwise never see — measured at 2.75%-9.55% of the token0 + /// side on Base, so it is not roundable away. Carried onto the + /// replacement position and prorated on partial exits exactly + /// like the basis it mirrors. + mapping(uint8 protocolId => mapping(uint256 tokenId => uint128)) carryProfitUsd6Of; + /// @dev Enumerable copy of the active allow-list. Appended after all + /// pre-existing fields to preserve the namespaced storage layout. + /// The manager uses it when a pauser re-enables a protocol to + /// prove every pool pair still has a live price reference. + mapping(uint8 protocolId => bytes[]) allowedPoolParams; + /// @dev One-based index into `allowedPoolParams`; zero means absent. + mapping(uint8 protocolId => mapping(bytes32 poolKey => uint256)) allowedPoolParamIndexPlusOne; + } + + // keccak256(abi.encode(uint256(keccak256("ratehopper.storage.yield")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant YIELD_STORAGE_SLOT = 0x53ba738b9a2829dfda910cf4e864fcd3f84e03854b49244f3d159a473ee6a400; + + function _yieldStorage() internal pure returns (YieldLayout storage $) { + assembly ("memory-safe") { + $.slot := YIELD_STORAGE_SLOT + } + } + + // ───────────────────────────────────────────────────────────────────── + // Shared events (emitted by the manager and, via delegatecall, by + // handlers — always from the manager's address) + // ───────────────────────────────────────────────────────────────────── + + event PositionOpened( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed tokenId, + uint256 usdcInput, + uint128 amount0ToLp, + uint128 amount1ToLp, + uint128 currentValueUsd6 + ); + event PositionSwitched( + address indexed onBehalfOf, + uint8 indexed fromProtocol, + uint8 indexed toProtocol, + uint256 oldTokenId, + uint256 newTokenId, + uint128 carriedBasisUsd6, + uint256 withdrawn0, + uint256 withdrawn1, + uint128 used0, + uint128 used1 + ); + /// @dev `carryForExitUsd6` is the share of previously-withdrawn switch + /// residue this exit accounts for. The fee is charged on + /// `currentValueUsd6 + carryForExitUsd6 - basisUsd6`, so without this + /// field the emitted numbers would not explain the emitted fee. + event PositionClosed( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed tokenId, + uint128 basisUsd6, + uint128 currentValueUsd6, + uint128 feeUsd6, + uint16 exitBps, + uint128 carryForExitUsd6 + ); + event FeesCollected( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed tokenId, + address token0, + uint256 collected0, + uint256 fee0, + address token1, + uint256 collected1, + uint256 fee1 + ); + /// @notice A stake-pool emission claim credited to the Safe, with the + /// collect fee actually paid on it. Emitted wherever a claim can + /// happen: an explicit collect, and the gauge withdrawal a close or + /// switch performs. `feePaid` is zero when the fee rounded to zero + /// or the treasury transfer failed (see CollectFeeTransferFailed). + event StakedRewardCollected( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed tokenId, + address rewardToken, + uint256 grossReward, + uint256 feePaid + ); + event FeeTransferFailed(address indexed onBehalfOf, uint256 indexed tokenId, uint128 feeUsd6); + event CollectFeeTransferFailed( + address indexed onBehalfOf, + uint256 indexed tokenId, + address indexed token, + uint256 attemptedFee + ); + event TwapConfigUpdated(address indexed token, address indexed pool, uint32 window, uint16 minCardinality); + /// @notice Residue an in-kind switch left on the Safe, valued at the + /// reference TWAP, and the basis/carry it produced. + event SwitchResidueSettled( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed newTokenId, + uint256 residual0, + uint256 residual1, + uint128 residualUsd6, + uint128 newBasisUsd6, + uint128 newCarryUsd6 + ); + /// @notice An in-kind exit: liquidity out, no swap, no price reference. + /// @dev No PERFORMANCE fee is taken — `feeCollectBps` still applies to + /// the fees harvested on the way out, exactly as it does on every + /// other harvest. `releasedCarryUsd6` is switch residue `closeLp` + /// would have charged a performance fee on and this path does not, + /// reported so that waiver is visible rather than silent. + event PositionWithdrawn( + address indexed onBehalfOf, + uint8 indexed protocol, + uint256 indexed tokenId, + uint128 releasedBasisUsd6, + uint128 releasedCarryUsd6, + uint256 amount0, + uint256 amount1 + ); + + // ───────────────────────────────────────────────────────────────────── + // Shared errors + // ───────────────────────────────────────────────────────────────────── + + error InvalidTreasury(); + error FeeAboveMax(); + error SwapFailed(); + error ZeroAddress(); + error InvalidUsdcAmount(); + error InvalidExitBps(); + error SlippageAboveMax(); + error PoolParamNotAllowed(); + error UnknownPosition(); + 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 TwapWindowTooShort(); + error TwapCardinalityBelowFloor(); + error TwapPoolPairMismatch(); + error InvalidTwapReferencePool(address pool); + error TwapReferenceRemovalNotAllowed(address token); +} diff --git a/contracts/yield/libraries/TokenReturnLib.sol b/contracts/yield/libraries/TokenReturnLib.sol new file mode 100644 index 0000000..d36e705 --- /dev/null +++ b/contracts/yield/libraries/TokenReturnLib.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @notice Interprets the optional bool returndata of module-mediated ERC20 +/// calls (transfer/approve), where SafeERC20 cannot be used because +/// the token call happens inside the Safe, not from this contract. +library TokenReturnLib { + /// @dev Empty returndata counts as success (no-return tokens like USDT), + /// anything shorter than a word is malformed, and a full word must be + /// the canonical true — the same strictness as SafeERC20. + function returnedTrue(bytes memory ret) internal pure returns (bool) { + if (ret.length == 0) return true; + if (ret.length < 32) return false; + uint256 word; + assembly ("memory-safe") { + word := mload(add(ret, 0x20)) + } + return word == 1; + } +} diff --git a/contracts/yield/libraries/TwapOracle.sol b/contracts/yield/libraries/TwapOracle.sol new file mode 100644 index 0000000..c9ebe17 --- /dev/null +++ b/contracts/yield/libraries/TwapOracle.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {IUniswapV3Pool} from "../../interfaces/uniswapV3/IUniswapV3Pool.sol"; +import "../../common/Types.sol"; + +/// @title TwapOracle +/// @notice Manipulation-resistant token/USDC quotes from a Uniswap V3 pool's +/// observation history. +/// @dev Used as the floor under every router call in the yield handlers. +/// Callers supply `amountOutMin`; this library answers "what is that +/// amount actually worth", so a caller can no longer certify its own +/// expectation. +/// +/// Fail-closed everywhere. There is no fallback to `slot0` when the +/// history is unusable: a fallback IS the attack, because an attacker +/// who can degrade the oracle would choose the degraded path. +library TwapOracle { + error TwapNotConfigured(address token); + error TwapCardinalityTooLow(address pool, uint16 have, uint16 need); + error TwapObservationStale(address pool, uint32 age, uint32 maxAge); + error TwapWindowZero(); + + /// @dev Rejects an ABANDONED reference. `observe` values the span from the + /// newest observation to `now` at the live tick, so a pool nobody has + /// traded reports its last tick no matter how far the market has moved + /// since. That is the loss case: a reference stuck BELOW the true + /// price lets a swap clear a floor beneath what the input is worth. + /// + /// It is deliberately not framed as manipulation resistance. Moving a + /// pool's tick writes an observation carrying the PRE-move tick, so an + /// attacker's own trade contributes nothing to the average in that + /// block and only ~one block per block afterwards. Freshness is about + /// the price being current, not about who moved it. + /// + /// This is also the check a bare `observe()` call cannot replace. A + /// pool with `observationCardinality == 1` that has been idle longer + /// than the window does NOT revert: every point in the window resolves + /// after its single stored observation, so the "average" collapses to + /// exactly the current tick, and the call looks perfectly healthy. + /// Measured on Base at block 50197687: the Aerodrome WETH/USDC + /// tickSpacing-200 pool, and three Uniswap V3 AERO pools (one of them + /// holding zero liquidity), all sit at cardinality 1 and answer a + /// 1800-second query without reverting. + uint32 internal constant MAX_STALENESS_DIVISOR = 4; + + /// @notice USDC-denominated value of `amountIn` of `tokenIn`, or the + /// token-denominated value of USDC when swapping the other way. + /// @param cfg Reference pool for the non-USDC side of the pair. + /// @param tokenIn Token being sold. + /// @param tokenOut Token being bought. + /// @param amountIn Amount of `tokenIn` being sold. + function quote( + TwapConfig memory cfg, + address tokenIn, + address tokenOut, + uint256 amountIn + ) internal view returns (uint256 amountOut) { + return _quoteAtTick(meanTick(cfg), amountIn, tokenIn, tokenOut); + } + + /// @notice Arithmetic mean tick of `cfg.pool` over `cfg.window`. + /// @dev Reverts unless the window is genuinely backed by history. + function meanTick(TwapConfig memory cfg) internal view returns (int24) { + if (cfg.pool == address(0)) revert TwapNotConfigured(cfg.pool); + if (cfg.window == 0) revert TwapWindowZero(); + + IUniswapV3Pool pool = IUniswapV3Pool(cfg.pool); + (, , uint16 observationIndex, uint16 observationCardinality, , , ) = pool.slot0(); + if (observationCardinality < cfg.minCardinality) { + revert TwapCardinalityTooLow(cfg.pool, observationCardinality, cfg.minCardinality); + } + + (uint32 newestTimestamp, , , ) = pool.observations(observationIndex); + uint32 age = uint32(block.timestamp) - newestTimestamp; + uint32 maxAge = cfg.window / MAX_STALENESS_DIVISOR; + if (age > maxAge) revert TwapObservationStale(cfg.pool, age, maxAge); + + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = cfg.window; + secondsAgos[1] = 0; + // Reverts `OLD` when the window predates the oldest observation. Left + // to propagate on purpose — that is the fail-closed path. + (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); + + int56 delta = tickCumulatives[1] - tickCumulatives[0]; + int56 window = int56(uint56(cfg.window)); + int24 tick = int24(delta / window); + // Round toward negative infinity, matching Uniswap's OracleLibrary, so + // the mean never rounds in favour of the side being quoted. + if (delta < 0 && delta % window != 0) tick--; + return tick; + } + + /// @dev Port of Uniswap's `OracleLibrary.getQuoteAtTick`. The two branches + /// exist because `sqrtPriceX96 ** 2` overflows 256 bits once the price + /// is high enough; above that threshold the ratio is carried in Q128 + /// instead of Q192. + function _quoteAtTick( + int24 tick, + uint256 amountIn, + address baseToken, + address quoteToken + ) private pure returns (uint256 quoteAmount) { + uint160 sqrtPriceX96 = TickMath.getSqrtPriceAtTick(tick); + if (sqrtPriceX96 <= type(uint128).max) { + uint256 ratioX192 = uint256(sqrtPriceX96) * sqrtPriceX96; + quoteAmount = baseToken < quoteToken + ? Math.mulDiv(ratioX192, amountIn, 1 << 192) + : Math.mulDiv(1 << 192, amountIn, ratioX192); + } else { + uint256 ratioX128 = Math.mulDiv(sqrtPriceX96, sqrtPriceX96, 1 << 64); + quoteAmount = baseToken < quoteToken + ? Math.mulDiv(ratioX128, amountIn, 1 << 128) + : Math.mulDiv(1 << 128, amountIn, ratioX128); + } + } +} diff --git a/deployments/base.json b/deployments/base.json new file mode 100644 index 0000000..9216e3a --- /dev/null +++ b/deployments/base.json @@ -0,0 +1,10 @@ +{ + "chainId": 8453, + "network": "base", + "contracts": { + "DeployYieldManager#AerodromeYieldHandler": "0x942c5A46570F31236a6560aDE8Fd740A5163e303", + "DeployYieldManager#UniV3YieldHandler": "0x057790539471b103012df78e5A923E0b41d86168", + "DeployYieldManager#UniV4YieldHandler": "0x748b064A61f30E41b0904f91e5874f59714777d3", + "DeployYieldManager#SafeYieldManager": "0x93281e160181B8EBdEB8a8d9493BcD34dE5fA1b8" + } +} diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md new file mode 100644 index 0000000..16b3b39 --- /dev/null +++ b/docs/SECURITY_MODEL.md @@ -0,0 +1,255 @@ +# Security model: custody, fees, and emergency powers + +RateHopper's managers are Safe **modules**, not custodians. Every position and +every token belongs to the user's Safe; the module is an executor the Safe has +chosen to authorize. Most of what follows is a consequence of that one fact. + +This document records the accepted properties of that design (audit items H-01, +I-01, I-02 and I-03) and the decisions behind them. + +## I-01 — The performance fee is cooperative, by construction + +`SafeYieldManager.closeLp` charges `performanceFeeBps` on realized profit only, +and collects it by asking the Safe to send USDC to the treasury: + +```solidity +if (feeUsd6 > 0 && !_trySafeTransfer(params.onBehalfOf, address(USDC), $.treasury, uint256(feeUsd6))) { + emit FeeTransferFailed(params.onBehalfOf, params.tokenId, feeUsd6); + feeUsd6 = 0; +} +``` + +The fee is therefore avoidable in three distinct ways, none of which is a bug: + +1. **Exit around the module.** The LP NFT is owned by the Safe. Safe owners can + call the position manager directly — `decreaseLiquidity`, `collect`, `burn` — + and realize the same profit without the module ever running. +2. **Disable the module.** A Safe owner can remove the module at any time. +3. **Hold no USDC.** The transfer is best-effort on purpose: a failed treasury + transfer must never block an exit. If it fails, the fee is waived and + `FeeTransferFailed` is emitted. + +No contract change can close these while the Safe remains non-custodial, and +making the fee enforceable would mean taking custody — a far worse trade. Fee +revenue should be modelled as **cooperative**: it is collected from users who +route through the product, not extracted from users who hold positions. + +Path 3 is the one worth monitoring. `FeeTransferFailed` is the signal; a Safe +that repeatedly emits it is realizing profit without paying, and that is a +product/onboarding question (keep a USDC balance) rather than a contract one. + +## I-02 — Repayment residue is returned to the Safe (decided: refund) + +A debt handler is allowed to decline part of a repayment, and each protocol +declines for its own reason: + +| Handler | Declines when | Reason | +| -------- | ------------------------------------------ | ------------------------------------- | +| Aave V3 | `amount <= 1` | Aave reverts with `InvalidBurnAmount` | +| Fluid | `repayAmount > -10000` (partial repay) | `Vault__InvalidOperateAmount` | +| Moonwell | caps at `borrowBalanceCurrent` | repaying above the debt underflows | +| Morpho | never | — | +| Compound | never (`supplyTo` absorbs the full amount) | — | + +The audit offered a choice: document a bounded maximum, or refund the residue. +**We refund.** Documenting a bound is the weaker option because there is no +single bound to document — Moonwell's residue is `amount - actualDebt`, which is +not bounded by a constant — and because residue left in the module is not inert: +the next operation reads `balanceOf(address(this))` and would sweep a previous +user's leftovers into _that_ user's position. + +`SafeDebtManager._executeDebtSwap` and `LeveragedPosition._handleCreateCallback` +now transfer any post-repayment `toAsset` / `debtAsset` balance to +`decoded.onBehalfOf`, matching what `_handleCloseCallback` already did. The +resulting invariant is much easier to audit than a table of per-protocol dust +ceilings: + +> After any debt operation, the module holds zero of every asset it touched. + +`test/debt/debtSwapBySafe.ts` asserts this in `afterEach` for every case in the +suite, across all five protocols. + +## H-01 — Swap floors come from a reference TWAP, never from the caller + +`SwapLeg` used to carry an `expectedOut` that the handler checked +`amountOutMin` against. That is not a floor: the same caller supplies both, and +`{amountOutMin: 1, expectedOut: 1}` satisfies it — which is exactly what +`scripts/collectLpBySafe.ts` was doing, because the fee amounts a collect swaps +are not known until it executes. + +The handlers now derive the router minimum themselves: + +``` +minOut = max(caller's amountOutMin, twapQuote(amountIn) * (10_000 - slippageBps) / 10_000) +``` + +A caller may tighten the bound and can no longer loosen it, and a swap whose +size is only known at execution time simply passes 0. The check sits in +`_swapViaSafe` / `_swapV4ViaSafe`, the single funnel each handler routes every +call through, so it holds structurally rather than by convention. + +### The reference is a pool, chosen for history rather than venue + +`TwapConfig` maps a token to a Uniswap V3 pool, a window, and a required +`observationCardinality`. It is deliberately NOT the pool a swap executes in: +one reference prices a token everywhere, so an Aerodrome or Uniswap V4 swap is +floored by the same Uniswap V3 observation history. Native ETH has its own +`address(0)` configuration key, whose pool is required to trade WETH/USDC; +quote orientation substitutes WETH before tick math sees the pair. + +`TwapOracle` fails closed on every degradation and never falls back to spot — a +fallback IS the attack, since anyone able to degrade the oracle would choose the +degraded path. Three things are checked: + +1. **`observationCardinality >= minCardinality`.** +2. **The window is backed by history** — the pool's own `OLD` revert propagates. +3. **The newest observation is recent** (within `window / 4`). + +Check 3 is the one that is easy to omit and cannot be replaced by "did +`observe()` revert". A pool with cardinality 1 that has been idle longer than +the window answers happily, because every point in the window resolves after +its single stored observation — so the returned "average" is exactly the live +tick. Measured on Base at block 50197687, four pools behave this way: the +Aerodrome WETH/USDC tickSpacing-200 pool and three Uniswap V3 AERO pools, one +of which holds no liquidity at all. + +What check 3 defends is **staleness, not manipulation**. Moving a pool's tick +writes an observation carrying the pre-move tick, so an attacker's own trade +contributes nothing to the average in that block. An abandoned reference is the +real hazard: stuck below the true price, it lets a swap clear a floor beneath +what the input is worth. + +### Why `setTwapConfig` is timelock-critical + +Repointing a reference changes the price boundary for every managed Safe, so +`setTwapConfig` requires both `msg.sender == timelock` and +`CRITICAL_ROLE`. A `DEFAULT_ADMIN_ROLE` holder cannot make the change directly. + +The INITIAL references are seeded by the constructor instead, through the same +`_storeTwapConfig` the setter uses — same floors, same pair check, same live +oracle read, so there is no weaker path in. Seeding is what makes the timelock +affordable: configuring after deployment would leave a fresh manager +open-enabled but unable to swap until a proposal executed days later. It also +lets the constructor hold its own allow-listed pool params to the reference +requirement `setPoolParamAllowed` enforces, so **"allow-listed implies a live +reference" holds from block one** rather than starting at the first post-deploy +change. +The delay gives operators and Safe owners time to inspect a proposed reference, +while `MIN_TWAP_WINDOW` (1800s), `MIN_TWAP_CARDINALITY` (60), immutable-pair +validation, and a live oracle read prevent the timelock from installing a weak +or unusable configuration. An active key can never be cleared to zero; a new +validated pool replaces it atomically. New pool parameters cannot be +allow-listed, and a protocol's open side cannot be re-enabled, unless every +non-USDC currency decoded by its registered handler has a live reference. +Native ETH checks the `address(0)` key. The close-side emergency switch remains +oracle-independent so it can re-enable `withdrawLp`; close/collect swap paths +still fail closed inside the handlers. If an active reference fails before a +replacement is executed, `withdrawLp` remains available. `twapMinimumOut` +exposes the exact contract-derived floor for operations and deployment +verification. + +### `withdrawLp`: the exit that reads no price + +Flooring `closeLp` on an oracle would otherwise mean a broken reference strands +a position. `withdrawLp` takes a position out in kind — liquidity and fees as +the pool's own two tokens, no router, no price read — and is gated only by +`protocolEnabledForClose`. It charges no performance fee, for the same reason +`switchLp` charges none: nothing is realized in USDC, so there is no profit to +measure. That does hand users a fee-free exit, which changes nothing +economically given I-01 above. + +## M-02 — Switch residue is valued and carried, not forgotten + +A concentrated-liquidity mint consumes its two sides only in the ratio the +range demands, so it stops at whichever side runs out. An in-kind `switchLp` +therefore always hands some of the withdrawal back to the Safe. Measured on a +Base fork, that residue is **4.8%–5.3% of the position's basis** (9.55% of the +token0 side across protocols, 2.75% when the range is carried over) — money, +not dust. + +Left unaccounted, it is a fee leak with a repeatable exploit: switch, take +profit out as residue, switch again, and close a position that looks +break-even. So the residue is treated as a withdrawal: + +``` +U = TWAP value of the residue, in USDC 6dp +newBasis = max(basis - U, 0) +newCarry = carry + max(U - basis, 0) +close : profit = max(currentValue + carryForExit - basisForExit, 0) +``` + +Basis is repaid first; only what exceeds it is profit, and that is carried onto +the replacement position in `carryProfitUsd6Of`. Partial closes prorate carry by +`exitBps` exactly as they prorate basis. The invariant the fork tests assert is +conservation — `newBasis + U == previousBasis` whenever the residue is smaller +than the basis. + +The valuation uses the H-01 reference TWAP, never spot: spot would let anyone +able to nudge a pool under-report the residue and shrink the fee the eventual +close charges. A switch that redeploys everything reads no price at all, and a +switch that cannot price its residue reverts rather than guessing. + +`withdrawLp` releases any carry without charging it, and says so in +`PositionWithdrawn.releasedCarryUsd6`. That is the same fee-free-exit property +as I-01, made visible rather than silent. + +## M-01 — No router call is left without a floor + +M-01 named the collect fee swap, which shipped `amountOutMin: 1` because the +amount collected is unknowable before the collect runs. H-01's derived floor +removes the whole category: `_swapViaSafe` and `_swapV4ViaSafe` are the only +two places in the handlers that reach a router, both compute the minimum from +the amount actually being swapped, and a caller passing 1 — or 0 — simply gets +the floor. + +## I-03 — The pauser can disable exits, but cannot trap funds + +The pauser is a single address, set by `DEFAULT_ADMIN_ROLE` via `setPauser`. It +holds `pause`/`unpause` and the two per-protocol switches. + +Pausing is deliberately **exit-only**, not a freeze. `closeLp`, `withdrawLp` and +`collectLp` carry no `whenNotPaused` modifier and do not consult the current +`yieldHandlers` registration — they run through the handler pinned at open +time, so a paused or re-registered protocol still lets positions out. + +One switch is the exception: + +```solidity +function setProtocolEnabledForClose(uint8 protocol, bool enabled) external onlyPauser +``` + +`closeLp`, `switchLp` and `collectLp` all check `protocolEnabledForClose`. A +pauser can therefore stop module-mediated exits for a protocol. This is +intentional and is reserved for one scenario: **a compromised or malfunctioning +handler**, where letting exits continue would route user funds through code we +no longer trust. Withholding it would mean having no answer at all to a bad +handler. + +The power is bounded in the way that matters — it delays exits, it cannot +prevent them. Because the Safe owns the position NFT (the same property behind +I-01), users can always exit directly through the protocol's own position +manager while the switch is off. **No configuration of any role can trap a +position.** + +### Operational requirements + +- The pauser must be a multisig, not an EOA. It is a liveness risk, not a + custody risk, but a lost or hostile pauser can still disrupt the product. +- When disabling closes for a protocol, disable opens for it too — otherwise new + positions enter a protocol they cannot leave through the module. +- Every flip emits `ProtocolStatusChanged`; alert on it. + +### Recovery + +1. `DEFAULT_ADMIN_ROLE` calls `setPauser` to replace a lost or hostile pauser. + This does not need the timelock, so recovery is immediate. +2. The new pauser calls `setProtocolEnabledForClose(protocol, true)`. +3. If a handler was genuinely compromised, register a fixed handler before + re-enabling. Existing positions still exit through their pinned handler, so + re-registration does not disturb them. +4. In the worst case, `rescueERC721` (`DEFAULT_ADMIN_ROLE`) returns a stranded + NFT held by the module itself. + +`CRITICAL_ROLE` is the timelock and is its own role admin +(`_setRoleAdmin(CRITICAL_ROLE, CRITICAL_ROLE)`), so `DEFAULT_ADMIN_ROLE` cannot +self-grant it and skip the 2-day delay on timelock-only setters. diff --git a/hardhat.config.ts b/hardhat.config.ts index 2ddac45..71cc556 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -8,6 +8,9 @@ require("hardhat-tracer"); require("@openzeppelin/hardhat-upgrades"); const baseUrl = process.env.BASE_RPC_URL || "https://mainnet.base.org"; +const baseForkBlockNumber = process.env.BASE_FORK_BLOCK_NUMBER + ? Number(process.env.BASE_FORK_BLOCK_NUMBER) + : undefined; // Only configure signing accounts when a deployer key is present. Hardhat rejects // `[undefined]`, which breaks `compile`/`coverage` in CI where no key is set. @@ -81,6 +84,7 @@ const config: HardhatUserConfig = { }, forking: { url: baseUrl, + ...(baseForkBlockNumber !== undefined ? { blockNumber: baseForkBlockNumber } : {}), }, }, }, diff --git a/ignition/deployRolesProxy.ts b/ignition/deployRolesProxy.ts deleted file mode 100644 index 7bd8dbe..0000000 --- a/ignition/deployRolesProxy.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { ethers } from "hardhat"; -import Safe from "@safe-global/protocol-kit"; -import dotenv from "dotenv"; -import rolesAbi from "../externalAbi/zodiac/role.json"; -dotenv.config(); - -/** - * Deploy Zodiac Roles Modifier using ModuleProxyFactory - * - * This script: - * 1. Deploys a minimal proxy pointing to the canonical Roles mastercopy - * 2. Initializes it with the Safe as avatar and owner - * 3. Automatically enables the module on the Safe using Safe SDK - * - * Addresses on Base Mainnet: - * - ModuleProxyFactory: 0x000000000000aDdB49795b0f9bA5BC298cDda236 - * - Roles Mastercopy: 0x9646fDAD06d3e24444381f44362a3B0eB343D337 - * - * Environment Variables Required: - * - TESTING_SAFE_WALLET_ADDRESS: Your Safe wallet address (avatar) - * - TESTING_SAFE_OWNER_ADDRESS: Address to set as the Roles module owner - * - TESTING_SAFE_OWNER_KEY: Private key of a Safe owner (to enable module) - */ - -// Contract addresses on Base Mainnet -const MODULE_PROXY_FACTORY = "0x000000000000aDdB49795b0f9bA5BC298cDda236"; -const ROLES_MASTERCOPY = "0x9646fDAD06d3e24444381f44362a3B0eB343D337"; - -// ModuleProxyFactory ABI - only the deployModule function we need -const MODULE_PROXY_FACTORY_ABI = [ - "function deployModule(address masterCopy, bytes memory initializer, uint256 saltNonce) public returns (address proxy)", - "event ModuleProxyCreation(address indexed proxy, address indexed masterCopy)", -]; - -async function main() { - console.log("\n🚀 Deploying Roles Modifier Proxy using ModuleProxyFactory...\n"); - - // Get deployer - const [deployer] = await ethers.getSigners(); - console.log("Deployer address:", deployer.address); - console.log("Deployer balance:", ethers.formatEther(await ethers.provider.getBalance(deployer.address)), "ETH\n"); - - // Get parameters from environment - const safeAddress = process.env.TESTING_SAFE_WALLET_ADDRESS; - const ownerAddress = process.env.TESTING_SAFE_OWNER_ADDRESS; - - if (!safeAddress || safeAddress === "YOUR_SAFE_ADDRESS_HERE") { - throw new Error("❌ Please set TESTING_SAFE_WALLET_ADDRESS in your .env file"); - } - - if (!ownerAddress) { - throw new Error("❌ Please set TESTING_SAFE_OWNER_ADDRESS in your .env file"); - } - - console.log("Configuration:"); - console.log("- Safe (Avatar):", safeAddress); - console.log("- Owner:", ownerAddress); - console.log("- Target:", safeAddress); // Usually same as avatar - console.log("- Mastercopy:", ROLES_MASTERCOPY); - console.log("- Factory:", MODULE_PROXY_FACTORY); - console.log(); - - // Connect to the factory - const factory = new ethers.Contract(MODULE_PROXY_FACTORY, MODULE_PROXY_FACTORY_ABI, deployer); - - // Encode initialization parameters for Roles.setUp() - // setUp expects: abi.encode(owner, avatar, target) - const initializeParams = ethers.AbiCoder.defaultAbiCoder().encode( - ["address", "address", "address"], - [ownerAddress, safeAddress, safeAddress], - ); - - // Encode the setUp call - const rolesInterface = new ethers.Interface(rolesAbi); - const initializer = rolesInterface.encodeFunctionData("setUp", [initializeParams]); - - // Generate a unique salt nonce (using timestamp) - const saltNonce = Date.now(); - - console.log("📝 Deployment parameters:"); - console.log("- Salt nonce:", saltNonce); - console.log("- Initializer:", initializer); - console.log(); - - // Deploy the proxy - console.log("⏳ Deploying Roles proxy..."); - const tx = await factory.deployModule(ROLES_MASTERCOPY, initializer, saltNonce); - - console.log("Transaction hash:", tx.hash); - console.log("⏳ Waiting for confirmation..."); - - const receipt = await tx.wait(); - console.log("✅ Transaction confirmed in block:", receipt.blockNumber); - console.log(); - - // Parse the event to get the deployed proxy address - const event = receipt.logs.find((log: any) => { - try { - const parsedLog = factory.interface.parseLog(log); - return parsedLog?.name === "ModuleProxyCreation"; - } catch { - return false; - } - }); - - if (!event) { - throw new Error("❌ Could not find ModuleProxyCreation event"); - } - - const parsedEvent = factory.interface.parseLog(event); - const proxyAddress = parsedEvent?.args.proxy; - - console.log("✅ Roles Modifier Proxy deployed!"); - console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - console.log("📍 Proxy Address:", proxyAddress); - console.log("📍 Mastercopy:", ROLES_MASTERCOPY); - console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - console.log(); - - // Verify the deployment - console.log("🔍 Verifying deployment..."); - const rolesProxy = new ethers.Contract(proxyAddress, rolesAbi, deployer); - - const deployedOwner = await rolesProxy.owner(); - const deployedAvatar = await rolesProxy.avatar(); - - console.log("Owner:", deployedOwner); - console.log("Avatar:", deployedAvatar); - - if (deployedOwner !== ownerAddress || deployedAvatar !== safeAddress) { - console.warn("⚠️ Warning: Deployed parameters don't match expected values!"); - } else { - console.log("✅ Deployment verified successfully!"); - } - - // Enable the module on the Safe using Safe SDK - console.log(); - console.log("📌 Enabling module on Safe..."); - - if (!process.env.TESTING_SAFE_OWNER_KEY) { - throw new Error("❌ MY_SAFE_OWNER_KEY not set in .env file"); - } - - try { - const safeWallet = await Safe.init({ - provider: process.env.BASE_RPC_URL || "https://mainnet.base.org", - signer: process.env.TESTING_SAFE_OWNER_KEY, - safeAddress: safeAddress, - }); - - const enableModuleTx = await safeWallet.createEnableModuleTx(proxyAddress); - const safeTxHash = await safeWallet.executeTransaction(enableModuleTx); - - console.log("✅ Safe transaction hash:", safeTxHash); - console.log(); - console.log("🎉 Roles Modifier is now active on your Safe!"); - console.log(); - - // Verify modules - const modules = await safeWallet.getModules(); - console.log("📋 Enabled modules:", modules); - } catch (error: any) { - console.log(); - console.log("⚠️ Could not enable module automatically."); - console.log("This is expected if the module is already enabled."); - console.log(); - console.log("Error details:", error.message); - throw error; - } - - console.log(); - console.log("🔗 View on BaseScan:"); - console.log(` https://basescan.org/address/${proxyAddress}`); - console.log(); - - // Save deployment info - const deploymentInfo = { - network: "base", - timestamp: new Date().toISOString(), - deployer: deployer.address, - factory: MODULE_PROXY_FACTORY, - mastercopy: ROLES_MASTERCOPY, - proxy: proxyAddress, - safe: safeAddress, - owner: ownerAddress, - txHash: tx.hash, - blockNumber: receipt.blockNumber, - }; - - console.log("💾 Deployment Info:"); - console.log(JSON.stringify(deploymentInfo, null, 2)); -} - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error("\n❌ Deployment failed:"); - console.error(error); - process.exit(1); - }); diff --git a/ignition/modules/0_DeployRegistryOnly.ts b/ignition/modules/0_DeployRegistryOnly.ts index 4469960..f694155 100644 --- a/ignition/modules/0_DeployRegistryOnly.ts +++ b/ignition/modules/0_DeployRegistryOnly.ts @@ -28,17 +28,9 @@ import { wrsETH_ADDRESS, wstETH_ADDRESS, } from "../../contractAddresses"; +import { makeRequireAddress } from "./deployHelpers"; -const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; - -function requireAddress(label: string, value: string): void { - if (!ADDRESS_RE.test(value)) { - throw new Error( - `DeployRegistryOnly: ${label} must be a valid address but got "${value}". ` + - "Set the corresponding env var in your .env before deploying.", - ); - } -} +const requireAddress = makeRequireAddress("DeployRegistryOnly"); /** * Deploys and configures ProtocolRegistry. @@ -53,7 +45,7 @@ function requireAddress(label: string, value: string): void { * - TIMELOCK_ADMIN: Used by TimelockControllerModule when REGISTRY_TIMELOCK is unset. * Falls back to ADMIN_ADDRESS. * - TIMELOCK_DELAY: Used by TimelockControllerModule when REGISTRY_TIMELOCK is unset. - * Defaults to 28800 seconds. + * Defaults to 172800 seconds (2 days). * - DEPLOYER_PRIVATE_KEY: Deployer key (set in hardhat.config.ts). * * Usage: diff --git a/ignition/modules/1_DeployCore.ts b/ignition/modules/1_DeployCore.ts index 4c1727a..62122ae 100644 --- a/ignition/modules/1_DeployCore.ts +++ b/ignition/modules/1_DeployCore.ts @@ -6,7 +6,7 @@ import { AAVE_V3_DATA_PROVIDER_ADDRESS, MORPHO_ADDRESS, COMPTROLLER_ADDRESS, - Protocol, + DebtProtocol, } from "../../contractAddresses"; /** @@ -45,29 +45,35 @@ export default buildModule("DeployCore", (m) => { // ── 1. Handlers (sequential) ────────────────────────────────────── const aaveV3Handler = m.contract( - "AaveV3Handler", + "AaveV3DebtHandler", [AAVE_V3_POOL_ADDRESS, AAVE_V3_DATA_PROVIDER_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registry], { after: [registryConfigured] }, ); - const compoundHandler = m.contract("CompoundHandler", [registry, UNISWAP_V3_FACTORY_ADDRESS], { + const compoundHandler = m.contract("CompoundDebtHandler", [registry, UNISWAP_V3_FACTORY_ADDRESS], { after: [aaveV3Handler], }); - const morphoHandler = m.contract("MorphoHandler", [MORPHO_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registry], { + const morphoHandler = m.contract("MorphoDebtHandler", [MORPHO_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registry], { after: [compoundHandler], }); - const fluidSafeHandler = m.contract("FluidSafeHandler", [UNISWAP_V3_FACTORY_ADDRESS, registry], { + const fluidSafeHandler = m.contract("FluidSafeDebtHandler", [UNISWAP_V3_FACTORY_ADDRESS, registry], { after: [morphoHandler], }); - const moonwellHandler = m.contract("MoonwellHandler", [COMPTROLLER_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registry], { + const moonwellHandler = m.contract("MoonwellDebtHandler", [COMPTROLLER_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registry], { after: [fluidSafeHandler], }); // ── 2. SafeDebtManager ───────────────────────────────────────────── - const protocols = [Protocol.AAVE_V3, Protocol.COMPOUND, Protocol.MORPHO, Protocol.FLUID, Protocol.MOONWELL]; + const protocols = [ + DebtProtocol.AAVE_V3, + DebtProtocol.COMPOUND, + DebtProtocol.MORPHO, + DebtProtocol.FLUID, + DebtProtocol.MOONWELL, + ]; const handlers = [aaveV3Handler, compoundHandler, morphoHandler, fluidSafeHandler, moonwellHandler]; const safeDebtManager = m.contract("SafeDebtManager", [registry, protocols, handlers, pauserAddress], { diff --git a/ignition/modules/2_DeployUniV3Helper.ts b/ignition/modules/2_DeployUniV3Helper.ts deleted file mode 100644 index 3d1b990..0000000 --- a/ignition/modules/2_DeployUniV3Helper.ts +++ /dev/null @@ -1,156 +0,0 @@ -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 28800 (8 hours). - * - 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/2_DeployYieldManager.ts b/ignition/modules/2_DeployYieldManager.ts new file mode 100644 index 0000000..8b72bad --- /dev/null +++ b/ignition/modules/2_DeployYieldManager.ts @@ -0,0 +1,246 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; +import TimelockControllerModule from "./TimelockControllerModule"; +import { + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_VOTER_ADDRESS, + PERMIT2_ADDRESS, + PROTOCOL_REGISTRY_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, + TWAP_REF_WETH_USDC_POOL, + TWAP_REF_AERO_USDC_POOL, + TWAP_WINDOW, + TWAP_CARDINALITY, + AERO_ADDRESS, +} from "../../contractAddresses"; +import { envBigInt, envNumber, envString, makeRequireAddress } from "./deployHelpers"; + +const requireAddress = makeRequireAddress("DeployYieldManager"); + +const UNISWAP_V3 = YieldProtocol.UNISWAP_V3; +const AERODROME = YieldProtocol.AERODROME; +const UNISWAP_V4 = YieldProtocol.UNISWAP_V4; + +const NATIVE = "0x0000000000000000000000000000000000000000"; + +// Default pool-param allow-lists — pool params carry the pair: +// abi.encode(token0, token1, feeTier | tickSpacing). WETH/USDC pools with +// Uniswap V3 fee tiers {100, 500, 3000} (10000 deliberately excluded — thin +// pool, cheap slot0 manipulation); Aerodrome Slipstream tick spacings +// {100, 200}; the canonical hookless native ETH/USDC 0.05% Uniswap V4 pool. +// Price references are SEEDED AT CONSTRUCTION (see TWAP_SEEDS below), because +// `setTwapConfig` is timelock-critical: configuring after the fact would leave +// a freshly deployed manager open-enabled but unable to swap until a timelock +// proposal executed, days later. Seeding also lets the constructor hold its own +// allow-listed pool params to the same reference requirement `setPoolParamAllowed` +// applies, so "allow-listed implies a live reference" holds from block one. +// +// Every non-USDC side of every seeded pool param needs an entry: WETH, the +// native `address(0)` key used by V4 native pools (both point at the same +// WETH/USDC pool — WETH is substituted only for tick math), and AERO so staked +// Aerodrome emissions can be sold without waiting on the timelock. A seeded +// reference is validated live, so the deploy reverts rather than installing one +// that cannot answer — if the AERO pool happens to be quiet at deploy time, drop +// that entry and add it later by timelock; only WETH and NATIVE are load-bearing +// for the pool params seeded below. +// +// Additional pairs are allow-listed post-deploy via setPoolParamAllowed only +// after every non-USDC side has a live TWAP reference — which now means a +// timelock proposal for the reference first. Hooked V4 pools remain restricted +// to audited hooks (the allow-list is the sole hook gate). +const UNIV3_POOL_PARAMS = [100, 500, 3000].map((feeTier) => encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, feeTier)); +const AERODROME_POOL_PARAMS = [100, 200].map((tickSpacing) => + encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, tickSpacing), +); +const UNIV4_POOL_PARAMS = [encodeUniV4PoolParam(NATIVE, USDC_ADDRESS, 500, 10, NATIVE)]; + +const twapSeed = (token: string, pool: string) => ({ + token, + config: { pool, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, +}); +const TWAP_SEEDS = [ + twapSeed(WETH_ADDRESS, TWAP_REF_WETH_USDC_POOL), + twapSeed(NATIVE, TWAP_REF_WETH_USDC_POOL), + twapSeed(AERO_ADDRESS, TWAP_REF_AERO_USDC_POOL), +]; + +/** + * Deployment module for the yield adapter stack (AP-4817): + * UniV3YieldHandler + AerodromeYieldHandler + UniV4YieldHandler + * + SafeYieldManager. + * + * SafeYieldManager is the ONLY contract users enable as a Safe module; the + * handlers are stateless delegatecall targets registered in its constructor + * (registration auto-enables open/close and seeds the pool allow-lists). + * Coexists with the previously deployed standalone RatehopperUniV3Positions: + * existing positions keep closing there; new positions open here. + * + * Environment variables — resolution order is SYM_* module override, then + * the unprefixed name shared by all yield deploy modules, then a legacy + * RHP_* fallback (addresses only), then the default. Empty values (X=) + * count as unset: + * - SYM_REGISTRY / REGISTRY / RHP_REGISTRY: ProtocolRegistry address. + * Falls back to PROTOCOL_REGISTRY_ADDRESS. + * - SYM_TREASURY / TREASURY / RHP_TREASURY: Fee treasury. Required. + * - SYM_INITIAL_ADMIN / INITIAL_ADMIN / ADMIN_ADDRESS: DEFAULT_ADMIN_ROLE holder. Required. + * - SYM_PAUSER / PAUSER_ADDRESS / ADMIN_ADDRESS: Pauser address. Required. + * - SYM_TIMELOCK / RHP_TIMELOCK: Pre-deployed TimelockController to + * reuse; unset → shared TimelockControllerModule. + * - SYM_PERFORMANCE_FEE_BPS / PERFORMANCE_FEE_BPS: Default 1000 (10%). + * - SYM_FEE_COLLECT_BPS / FEE_COLLECT_BPS: Default 250 (2.5%). + * - SYM_MAX_FEE_BPS / MAX_FEE_BPS: Default 2000 (20%). + * - SYM_UNIV3_MIN_POSITION_LIQUIDITY / SYM_AERODROME_MIN_POSITION_LIQUIDITY / + * MIN_POSITION_LIQUIDITY: Mint-liquidity floors. Default 10000. + * - SYM_UNIV3_MIN_POOL_LIQUIDITY / SYM_AERODROME_MIN_POOL_LIQUIDITY / + * MIN_POOL_LIQUIDITY: Pool-liquidity floors. Default 0 (disabled). + * - SYM_UNIV4_MIN_POSITION_LIQUIDITY / SYM_UNIV4_MIN_POOL_LIQUIDITY: + * Same floors for Uniswap V4. + * - SYM_UNIV4_POSITION_MANAGER / UNIV4_POSITION_MANAGER: V4 PositionManager. + * - SYM_UNIVERSAL_ROUTER / UNIVERSAL_ROUTER: UniversalRouter. + * - SYM_PERMIT2 / PERMIT2: Permit2 (Base uses + * 0x...B43aC78BA3). + * - SYM_UNIV4_STATE_VIEW / UNIV4_STATE_VIEW: StateView lens. + * + * Usage: + * npx hardhat ignition deploy ignition/modules/2_DeployYieldManager.ts \ + * --network base --verify + */ +export default buildModule("DeployYieldManager", (m) => { + const reuseTimelockAddr = envString("SYM_TIMELOCK", "RHP_TIMELOCK"); + const timelock = reuseTimelockAddr ? undefined : m.useModule(TimelockControllerModule).timelock; + const timelockArg: any = timelock ?? reuseTimelockAddr; + + // Module-specific SYM_* overrides win; unprefixed names are shared with + // the other yield deploy modules (RHP_* kept as a legacy fallback). + const registryAddr = envString("SYM_REGISTRY", "REGISTRY", "RHP_REGISTRY") || PROTOCOL_REGISTRY_ADDRESS; + const treasuryAddr = envString("SYM_TREASURY", "TREASURY", "RHP_TREASURY"); + const initialAdminAddr = envString("SYM_INITIAL_ADMIN", "INITIAL_ADMIN", "ADMIN_ADDRESS"); + const pauserAddr = envString("SYM_PAUSER", "PAUSER_ADDRESS", "ADMIN_ADDRESS"); + + requireAddress("registry (SYM_REGISTRY / REGISTRY / PROTOCOL_REGISTRY_ADDRESS)", registryAddr); + requireAddress("treasury (SYM_TREASURY / TREASURY)", treasuryAddr); + requireAddress("initialAdmin (SYM_INITIAL_ADMIN / INITIAL_ADMIN / ADMIN_ADDRESS)", initialAdminAddr); + requireAddress("pauser (SYM_PAUSER / PAUSER_ADDRESS / ADMIN_ADDRESS)", pauserAddr); + if (reuseTimelockAddr) requireAddress("timelock (SYM_TIMELOCK / RHP_TIMELOCK)", reuseTimelockAddr); + + const registry = m.getParameter("registry", registryAddr); + const treasury = m.getParameter("treasury", treasuryAddr); + const initialAdmin = m.getParameter("initialAdmin", initialAdminAddr); + const pauser = m.getParameter("pauser", pauserAddr); + const performanceFeeBps = m.getParameter( + "performanceFeeBps", + envNumber(1000, "SYM_PERFORMANCE_FEE_BPS", "PERFORMANCE_FEE_BPS"), + ); + const feeCollectBps = m.getParameter( + "feeCollectBps", + envNumber(250, "SYM_FEE_COLLECT_BPS", "FEE_COLLECT_BPS"), + ); + const maxFeeBps = m.getParameter("maxFeeBps", envNumber(2000, "SYM_MAX_FEE_BPS", "MAX_FEE_BPS")); + const uniV3MinPositionLiquidity = m.getParameter( + "uniV3MinPositionLiquidity", + envBigInt(10_000n, "SYM_UNIV3_MIN_POSITION_LIQUIDITY", "MIN_POSITION_LIQUIDITY"), + ); + const aerodromeMinPositionLiquidity = m.getParameter( + "aerodromeMinPositionLiquidity", + envBigInt(10_000n, "SYM_AERODROME_MIN_POSITION_LIQUIDITY", "MIN_POSITION_LIQUIDITY"), + ); + const uniV3MinPoolLiquidity = m.getParameter( + "uniV3MinPoolLiquidity", + envBigInt(0n, "SYM_UNIV3_MIN_POOL_LIQUIDITY", "MIN_POOL_LIQUIDITY"), + ); + const aerodromeMinPoolLiquidity = m.getParameter( + "aerodromeMinPoolLiquidity", + envBigInt(0n, "SYM_AERODROME_MIN_POOL_LIQUIDITY", "MIN_POOL_LIQUIDITY"), + ); + const uniV4MinPositionLiquidity = m.getParameter( + "uniV4MinPositionLiquidity", + envBigInt(10_000n, "SYM_UNIV4_MIN_POSITION_LIQUIDITY", "MIN_POSITION_LIQUIDITY"), + ); + const uniV4MinPoolLiquidity = m.getParameter( + "uniV4MinPoolLiquidity", + envBigInt(0n, "SYM_UNIV4_MIN_POOL_LIQUIDITY", "MIN_POOL_LIQUIDITY"), + ); + + const uniV4PositionManagerAddr = + envString("SYM_UNIV4_POSITION_MANAGER", "UNIV4_POSITION_MANAGER") || UNISWAP_V4_POSITION_MANAGER_ADDRESS; + const universalRouterAddr = envString("SYM_UNIVERSAL_ROUTER", "UNIVERSAL_ROUTER") || UNIVERSAL_ROUTER_ADDRESS; + const permit2Addr = envString("SYM_PERMIT2", "PERMIT2") || PERMIT2_ADDRESS; + const stateViewAddr = envString("SYM_UNIV4_STATE_VIEW", "UNIV4_STATE_VIEW") || UNISWAP_V4_STATE_VIEW_ADDRESS; + + requireAddress("uniV4PositionManager (SYM_UNIV4_POSITION_MANAGER)", uniV4PositionManagerAddr); + requireAddress("universalRouter (SYM_UNIVERSAL_ROUTER)", universalRouterAddr); + requireAddress("permit2 (SYM_PERMIT2)", permit2Addr); + requireAddress("stateView (SYM_UNIV4_STATE_VIEW)", stateViewAddr); + + const uniV4PositionManager = m.getParameter("uniV4PositionManager", uniV4PositionManagerAddr); + const universalRouter = m.getParameter("universalRouter", universalRouterAddr); + const permit2 = m.getParameter("permit2", permit2Addr); + const stateView = m.getParameter("stateView", stateViewAddr); + + const uniV3YieldHandler = m.contract("UniV3YieldHandler", [ + UNISWAP_V3_NPM_ADDRESS, + USDC_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + ]); + + const aerodromeYieldHandler = m.contract("AerodromeYieldHandler", [ + AERODROME_SLIPSTREAM_NPM_ADDRESS, + USDC_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_VOTER_ADDRESS, + ]); + + const uniV4YieldHandler = m.contract("UniV4YieldHandler", [ + uniV4PositionManager, + universalRouter, + permit2, + stateView, + USDC_ADDRESS, + WETH_ADDRESS, + ]); + + const safeYieldManager = m.contract( + "SafeYieldManager", + [ + registry, + USDC_ADDRESS, + WETH_ADDRESS, + [UNISWAP_V3, AERODROME, UNISWAP_V4], + [uniV3YieldHandler, aerodromeYieldHandler, uniV4YieldHandler], + [UNIV3_POOL_PARAMS, AERODROME_POOL_PARAMS, UNIV4_POOL_PARAMS], + [uniV3MinPoolLiquidity, aerodromeMinPoolLiquidity, uniV4MinPoolLiquidity], + [uniV3MinPositionLiquidity, aerodromeMinPositionLiquidity, uniV4MinPositionLiquidity], + TWAP_SEEDS, + treasury, + performanceFeeBps, + feeCollectBps, + maxFeeBps, + initialAdmin, + timelockArg, + pauser, + ], + timelock ? { after: [timelock] } : undefined, + ); + + return { + uniV3YieldHandler, + aerodromeYieldHandler, + uniV4YieldHandler, + safeYieldManager, + ...(timelock ? { timelock } : {}), + }; +}); diff --git a/ignition/modules/TimelockControllerModule.ts b/ignition/modules/TimelockControllerModule.ts index 16d3755..89ae349 100644 --- a/ignition/modules/TimelockControllerModule.ts +++ b/ignition/modules/TimelockControllerModule.ts @@ -3,14 +3,14 @@ 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 + * `DeployRegistryOnly`, `DeployCore`, and `DeployYieldManager` 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. + * runs, so calling `yarn deploy:1_core` first and `yarn deploy:2_yield_manager` + * 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 @@ -21,10 +21,10 @@ import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; * - 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 28800 (8 hours). + * Defaults to 172800 (2 days), matching the live deployment. */ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; -const EIGHT_HOURS = 8 * 60 * 60; +const TWO_DAYS = 2 * 24 * 60 * 60; export default buildModule("TimelockControllerModule", (m) => { const admin = m.getParameter( @@ -33,7 +33,7 @@ export default buildModule("TimelockControllerModule", (m) => { ); const delay = m.getParameter( "delay", - Number(process.env.TIMELOCK_DELAY ?? EIGHT_HOURS), + Number(process.env.TIMELOCK_DELAY ?? TWO_DAYS), ); const timelock = m.contract("TimelockController", [ diff --git a/ignition/modules/deployHelpers.ts b/ignition/modules/deployHelpers.ts new file mode 100644 index 0000000..739646c --- /dev/null +++ b/ignition/modules/deployHelpers.ts @@ -0,0 +1,36 @@ +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +// Read the first NON-EMPTY env var in priority order (module-specific +// override first, then the shared name). An empty value (X=) counts as +// unset and falls through, so a blank line in .env can never mask the +// shared value or the default. +export function envString(...names: string[]): string { + for (const name of names) { + const value = process.env[name]; + if (value) return value; + } + return ""; +} + +export function envNumber(defaultValue: number, ...names: string[]): number { + const value = envString(...names); + return value ? Number(value) : defaultValue; +} + +export function envBigInt(defaultValue: bigint, ...names: string[]): bigint { + const value = envString(...names); + return value ? BigInt(value) : defaultValue; +} + +// 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. +export function makeRequireAddress(moduleName: string) { + return function requireAddress(label: string, value: string): void { + if (!ADDRESS_RE.test(value)) { + throw new Error( + `${moduleName}: ${label} must be a valid address but got "${value}". ` + + "Set the corresponding env var in your .env before deploying.", + ); + } + }; +} diff --git a/package.json b/package.json index 8a323d2..03110cc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "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:rhp": "node --max-old-space-size=12288 ./node_modules/.bin/hardhat coverage --testfiles \"test/legacy/ratehopperUniV3Positions*.ts\"", + "coverage:gated": "node --max-old-space-size=12288 ./node_modules/.bin/hardhat coverage --testfiles \"test/{legacy/ratehopperUniV3Positions*.ts,yield/safeYieldManager.ts,yield/safeYieldManagerUniV4.ts}\"", "coverage:check": "node scripts/checkCoverage.js", "lint:sol": "npx solhint 'contracts/**/*.sol'", "format": "npx prettier --write 'contracts/**/*.sol'", @@ -23,15 +24,16 @@ "clean": "npx hardhat clean", "abis:export": "node scripts/exportAbis.js", "abis": "npx hardhat compile && node scripts/exportAbis.js", - "deploy:0_registry": "npx hardhat ignition deploy ignition/modules/0_DeployRegistryOnly.ts --network base --verify && node scripts/syncRegistryAddress.js && node scripts/exportAbis.js", - "deploy:1_core": "npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify && node scripts/syncRegistryAddress.js && node scripts/exportAbis.js", - "deploy:2_univ3_helper": "npx hardhat ignition deploy ignition/modules/2_DeployUniV3Helper.ts --network base --verify && node scripts/exportAbis.js", + "deployments:sync": "node scripts/syncDeploymentAddresses.js", + "deploy:0_registry": "npx hardhat ignition deploy ignition/modules/0_DeployRegistryOnly.ts --network base --verify && node scripts/syncRegistryAddress.js && node scripts/exportAbis.js && node scripts/syncDeploymentAddresses.js", + "deploy:1_core": "npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify && node scripts/syncRegistryAddress.js && node scripts/exportAbis.js && node scripts/syncDeploymentAddresses.js", + "deploy:2_yield_manager": "npx hardhat ignition deploy ignition/modules/2_DeployYieldManager.ts --network base --verify && node scripts/exportAbis.js && node scripts/syncDeploymentAddresses.js", "verify": "npx hardhat run scripts/verifyAll.ts --network base", "wipe:all": "rm -rf ignition/deployments/chain-8453", "wipe:leveraged-position": "npx hardhat ignition wipe chain-8453 DeployCore#LeveragedPosition", - "wipe:univ3-positions": "npx hardhat ignition wipe chain-8453 DeployUniV3Helper#RatehopperUniV3Positions", "wipe:safe-debt-manager": "npx hardhat ignition wipe chain-8453 DeployCore#SafeDebtManager", - "wipe:safe-wrapper": "npx hardhat ignition wipe chain-8453 DeployCore#SafeExecTransactionWrapper" + "wipe:safe-wrapper": "npx hardhat ignition wipe chain-8453 DeployCore#SafeExecTransactionWrapper", + "wipe:yield-manager": "npx hardhat ignition wipe chain-8453 DeployYieldManager#SafeYieldManager" }, "author": "Ratehopper AI", "license": "BUSL-1.1", @@ -43,6 +45,8 @@ "@safe-global/protocol-kit": "^5.2.0", "@safe-global/types-kit": "^1.0.1", "@uniswap/v3-periphery": "^1.4.4", + "@uniswap/v4-core": "1.0.2", + "@uniswap/v4-periphery": "1.0.3", "axios": "^1.13.2", "dotenv": "^16.4.5", "hardhat": "^2.27.2", diff --git a/scripts/checkCoverage.js b/scripts/checkCoverage.js index 5cf14b7..2983fd5 100644 --- a/scripts/checkCoverage.js +++ b/scripts/checkCoverage.js @@ -5,10 +5,10 @@ // 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. +// Scope: RatehopperUniV3Positions.sol (legacy) and the SafeYieldManager +// stack. The 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"); @@ -17,7 +17,17 @@ 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"]; +const GATED = [ + "contracts/legacy/RatehopperUniV3Positions.sol", + "contracts/yield/SafeYieldManager.sol", + "contracts/yield/handlers/BaseYieldHandler.sol", + "contracts/yield/handlers/V3StyleYieldHandler.sol", + "contracts/yield/handlers/UniV3YieldHandler.sol", + "contracts/yield/handlers/AerodromeYieldHandler.sol", + "contracts/yield/handlers/UniV4YieldHandler.sol", + "contracts/yield/handlers/YieldStorage.sol", + "contracts/yield/libraries/TwapOracle.sol", +]; function isGated(key) { const k = key.replace(/\\/g, "/"); diff --git a/scripts/closeLpBySafe.ts b/scripts/closeLpBySafe.ts new file mode 100644 index 0000000..3eec235 --- /dev/null +++ b/scripts/closeLpBySafe.ts @@ -0,0 +1,279 @@ +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 { + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../contractAddresses"; +import { + AERO_FACTORY_ABI, + AERO_NPM_ABI, + AERO_POOL_ABI, + UNIV3_FACTORY_ABI, + UNIV3_NPM_ABI, + UNIV3_POOL_ABI, + UNIV4_PM_ABI, + UNIV4_STATE_VIEW_ABI, + amountsForLiquidity, + decreaseMinimums, + deployedManagerAddress, + resolveOwnerKey, + unpackV4PositionTicks, + waitForReceipt, +} from "./lpSafeShared"; + +/** + * Closes an LP position on the deployed SafeYieldManager (Base mainnet) FROM + * the user's own Safe (msg.sender == Safe path of `onlyOperatorOrSafe`). + * + * Edit the configuration constants below, set TESTING_SAFE_OWNER_KEY (or + * SAFE_OWNER_PRIVATE_KEY / DEPLOYER_PRIVATE_KEY) in .env, then run: + * + * npx hardhat run scripts/closeLpBySafe.ts --network base + * + * The script: + * 1. Reads the position's liquidity and range from the protocol's position + * manager and the pool's current price. + * 2. Estimates the WETH/USDC amounts the close will withdraw and derives + * `expectedSwapOut` (withdrawn WETH -> USDC), `swapAmountOutMin` and + * `minUsdcOut` from the pool spot price minus slippage. + * 3. Executes `closeLp` via the Safe (protocol-kit). Threshold must be 1 — + * for multi-sig Safes set DRY_RUN = true and propose the printed calldata + * through the Safe UI instead. + */ + +// ─── Configuration ─────────────────────────────────────────────────────── +const SAFE_ADDRESS: string = process.env.TESTING_SAFE_WALLET_ADDRESS || ""; +const PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "aerodrome"; +const TOKEN_ID = 74554383n; +// 10_000 = full close (burns the NFT); 1..9_999 = partial close +const EXIT_BPS = 10_000; +const SLIPPAGE_BPS: bigint = 100n; +// Aerodrome tick spacing (100 or 200) — used when PROTOCOL_NAME is "aerodrome" +const TICK_SPACING = 100; +// UniV3 fee tier (100 / 500 / 3000) — used when PROTOCOL_NAME is "univ3" +const FEE_TIER = 500; +// Slippage applied to the decrease minima — the floor on the principal the +// position manager must actually hand back. +const DECREASE_SLIPPAGE_BPS: bigint = 100n; +// Empty = use the ignition-deployed address for chain 8453 +const MANAGER_ADDRESS_OVERRIDE = ""; +// true = print the resolved params and calldata without executing +const DRY_RUN = true; +// ───────────────────────────────────────────────────────────────────────── + +async function main() { + const OWNER_KEY = resolveOwnerKey(); + const MANAGER_ADDRESS = MANAGER_ADDRESS_OVERRIDE || deployedManagerAddress(); + + if (!SAFE_ADDRESS) throw new Error("Set SAFE_ADDRESS at the top of the script"); + if (!OWNER_KEY) throw new Error("Set TESTING_SAFE_OWNER_KEY (or SAFE_OWNER_PRIVATE_KEY) in .env"); + if (!ethers.isAddress(SAFE_ADDRESS)) throw new Error(`Invalid SAFE_ADDRESS: ${SAFE_ADDRESS}`); + if (EXIT_BPS < 1 || EXIT_BPS > 10_000) throw new Error(`EXIT_BPS must be in 1..10000`); + + const provider = ethers.provider; + + let protocol: number; + let poolParam: string; + let poolAddress: string; + let sqrtPriceX96: bigint; + let tickLower: number; + let tickUpper: number; + let liquidity: bigint; + + if (PROTOCOL_NAME === "univ4") { + protocol = YieldProtocol.UNISWAP_V4; + // The position's own PoolKey is the pool identity — no fee/spacing + // config needed; native ETH (currency0 == address(0)) works the same + // as WETH here (both 18-decimals currency0). + const pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, UNIV4_PM_ABI, provider); + const [key, info] = await pm.getPoolAndPositionInfo(TOKEN_ID); + if (key.currency1.toLowerCase() !== USDC_ADDRESS.toLowerCase()) { + throw new Error(`Token ${TOKEN_ID} is not a */USDC V4 position`); + } + poolParam = encodeUniV4PoolParam(key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks); + const poolId = ethers.keccak256(poolParam); + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, UNIV4_STATE_VIEW_ABI, provider); + [sqrtPriceX96] = await stateView.getSlot0(poolId); + ({ tickLower, tickUpper } = unpackV4PositionTicks(BigInt(info))); + liquidity = await pm.getPositionLiquidity(TOKEN_ID); + poolAddress = `V4 PoolManager (poolId ${poolId})`; + } else if (PROTOCOL_NAME === "aerodrome") { + protocol = YieldProtocol.AERODROME; + poolParam = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, AERO_FACTORY_ABI, provider); + poolAddress = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No Aerodrome pool for tickSpacing ${TICK_SPACING}`); + const pool = new ethers.Contract(poolAddress, AERO_POOL_ABI, provider); + [sqrtPriceX96] = await pool.slot0(); + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, AERO_NPM_ABI, provider); + const position = await npm.positions(TOKEN_ID); + tickLower = Number(position[5]); + tickUpper = Number(position[6]); + liquidity = position[7]; + } else { + protocol = YieldProtocol.UNISWAP_V3; + poolParam = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, UNIV3_FACTORY_ABI, provider); + poolAddress = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No UniV3 pool for feeTier ${FEE_TIER}`); + const pool = new ethers.Contract(poolAddress, UNIV3_POOL_ABI, provider); + [sqrtPriceX96] = await pool.slot0(); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, UNIV3_NPM_ABI, provider); + const position = await npm.positions(TOKEN_ID); + tickLower = Number(position[5]); + tickUpper = Number(position[6]); + liquidity = position[7]; + } + + if (liquidity === 0n) throw new Error(`Position ${TOKEN_ID} has zero liquidity`); + + const liquidityToRemove = EXIT_BPS === 10_000 ? liquidity : (liquidity * BigInt(EXIT_BPS)) / 10_000n; + const { amount0: wethOut, amount1: usdcOut } = amountsForLiquidity( + sqrtPriceX96, + tickLower, + tickUpper, + liquidityToRemove, + ); + + // Spot-price estimate of the withdrawn-WETH -> USDC swap output. Fees + // collected on top only increase the swap input, so the floor stays safe. + let expectedSwapOut = (wethOut * sqrtPriceX96 * sqrtPriceX96) >> 192n; + if (expectedSwapOut === 0n) expectedSwapOut = 1n; + let swapAmountOutMin = (expectedSwapOut * (10_000n - SLIPPAGE_BPS)) / 10_000n; + if (swapAmountOutMin === 0n) swapAmountOutMin = 1n; + const minUsdcOut = ((usdcOut + expectedSwapOut) * (10_000n - SLIPPAGE_BPS)) / 10_000n; + + // Floors on the withdrawal itself, prorated by exitBps. Zero survives only + // on a side the position mathematically does not hold at this price. + const decrease = decreaseMinimums( + { sqrtPriceX96, tickLower, tickUpper }, + liquidity, + BigInt(EXIT_BPS), + DECREASE_SLIPPAGE_BPS, + ); + + const manager = await ethers.getContractAt("SafeYieldManager", MANAGER_ADDRESS); + const maxSlippageBps: bigint = await manager.maxSlippageBps(); + if (SLIPPAGE_BPS === 0n || SLIPPAGE_BPS > maxSlippageBps) { + throw new Error(`SLIPPAGE_BPS must be in 1..${maxSlippageBps}`); + } + if (!(await manager.protocolEnabledForClose(protocol))) { + throw new Error(`Protocol ${PROTOCOL_NAME} (${protocol}) is disabled for close`); + } + + const block = await provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp) + 1_200n; + + const closeParams = { + onBehalfOf: SAFE_ADDRESS, + tokenId: TOKEN_ID, + exitBps: EXIT_BPS, + // WETH is token0 on Base; the USDC side needs no swap leg. + swap0: { amountOutMin: swapAmountOutMin, poolParam }, + swap1: { amountOutMin: 0, poolParam: "0x" }, + slippageBps: SLIPPAGE_BPS, + decreaseAmount0Min: decrease.amount0Min, + decreaseAmount1Min: decrease.amount1Min, + deadline, + minUsdcOut, + }; + + console.log("Configuration:"); + console.log("- SafeYieldManager:", MANAGER_ADDRESS); + console.log("- Safe:", SAFE_ADDRESS); + console.log("- Protocol:", PROTOCOL_NAME, `(id ${protocol})`); + console.log("- Pool:", poolAddress); + console.log("- Token id:", TOKEN_ID.toString(), "| exitBps:", EXIT_BPS); + console.log("- Position range:", tickLower, "..", tickUpper, "| liquidity:", liquidity.toString()); + console.log("- Estimated withdrawal: WETH", ethers.formatEther(wethOut), "| USDC", ethers.formatUnits(usdcOut, 6)); + console.log("- expectedSwapOut (USDC):", ethers.formatUnits(expectedSwapOut, 6)); + console.log("- swapAmountOutMin (USDC):", ethers.formatUnits(swapAmountOutMin, 6)); + console.log("- minUsdcOut (USDC):", ethers.formatUnits(minUsdcOut, 6)); + console.log("- Slippage bps:", SLIPPAGE_BPS.toString()); + console.log( + "- Decrease minimums (token0/token1):", + decrease.amount0Min.toString(), + "/", + decrease.amount1Min.toString(), + ); + console.log("- Deadline:", deadline.toString()); + + const closeLpData = manager.interface.encodeFunctionData("closeLp", [protocol, closeParams]); + if (DRY_RUN) { + console.log("\nDRY_RUN — closeLp calldata:"); + console.log(closeLpData); + return; + } + + const rpcUrl = (network.config as { url?: string }).url; + if (!rpcUrl) throw new Error(`Network ${network.name} has no RPC url — run with --network base`); + + const safeWallet = await Safe.init({ + provider: rpcUrl, + signer: OWNER_KEY, + safeAddress: SAFE_ADDRESS, + }); + + const threshold = await safeWallet.getThreshold(); + if (threshold > 1) { + throw new Error( + `Safe threshold is ${threshold}. This script only supports 1/N execution — propose the transaction via the Safe UI instead (target ${MANAGER_ADDRESS}, calldata printed with DRY_RUN = true).`, + ); + } + + if (!(await safeWallet.isModuleEnabled(MANAGER_ADDRESS))) { + throw new Error("SafeYieldManager is not enabled as a module on this Safe"); + } + + const transactions: MetaTransactionData[] = [ + { + to: MANAGER_ADDRESS, + value: "0", + data: closeLpData, + operation: OperationType.Call, + }, + ]; + + const safeTransaction = await safeWallet.createTransaction({ transactions }); + const result = await safeWallet.executeTransaction(safeTransaction); + console.log("Submitted:", result.hash); + + const receipt = await waitForReceipt(provider, result.hash); + console.log("Confirmed in block", receipt.blockNumber); + + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== MANAGER_ADDRESS.toLowerCase()) continue; + let parsed: ReturnType; + try { + parsed = manager.interface.parseLog({ topics: [...log.topics], data: log.data }); + } catch { + continue; + } + if (parsed?.name !== "PositionClosed") continue; + console.log("PositionClosed — tokenId:", parsed.args.tokenId.toString()); + console.log("- Exit bps:", parsed.args.exitBps.toString()); + console.log("- Basis for exit (USD):", ethers.formatUnits(parsed.args.basisUsd6, 6)); + console.log("- Realized value (USD):", ethers.formatUnits(parsed.args.currentValueUsd6, 6)); + console.log("- Performance fee (USD):", ethers.formatUnits(parsed.args.feeUsd6, 6)); + return; + } + console.log("No PositionClosed event found in the receipt — check the tx on Basescan."); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/collectLpBySafe.ts b/scripts/collectLpBySafe.ts new file mode 100644 index 0000000..a8c536d --- /dev/null +++ b/scripts/collectLpBySafe.ts @@ -0,0 +1,273 @@ +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 { + AERO_ADDRESS, + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + AERODROME_VOTER_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../contractAddresses"; +import { + AERO_NPM_ABI, + UNIV3_NPM_ABI, + UNIV4_PM_ABI, + deployedManagerAddress, + resolveOwnerKey, + waitForReceipt, +} from "./lpSafeShared"; + +/** + * Harvests accrued LP fees of a position on the deployed SafeYieldManager + * (Base mainnet) FROM the user's own Safe (msg.sender == Safe path of + * `onlyOperatorOrSafe`) without exiting it. + * + * Edit the configuration constants below, set TESTING_SAFE_OWNER_KEY (or + * SAFE_OWNER_PRIVATE_KEY / DEPLOYER_PRIVATE_KEY) in .env, then run: + * + * npx hardhat run scripts/collectLpBySafe.ts --network base + * + * With SWAP_FEES_TO_USDC = false the harvest lands on the Safe in the pool's + * own tokens (minus feeCollectBps). Setting it true swaps the non-USDC side + * to USDC through the position's own pool; the swap legs carry only absolute + * floors (fee amounts are unknown until the collect), so leave it false for + * dust-sized positions where the swap output could round to zero. + */ + +// ─── Configuration ─────────────────────────────────────────────────────── +const SAFE_ADDRESS: string = process.env.TESTING_SAFE_WALLET_ADDRESS || ""; +const PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "aerodrome"; +const TOKEN_ID = 74555704n; +const SWAP_FEES_TO_USDC = false; +// Swap the AERO staking reward claimed from the gauge to USDC through the +// rewardSwap leg (staked Aerodrome positions only; ignored otherwise). The +// AERO/USDC pool at REWARD_TICK_SPACING must be allow-listed on the manager. +const SWAP_REWARD_TO_USDC = false; +const REWARD_TICK_SPACING = 100; +const SLIPPAGE_BPS: bigint = 100n; +// Aerodrome tick spacing (100 or 200) — used when PROTOCOL_NAME is "aerodrome" +const TICK_SPACING = 100; +// UniV3 fee tier (100 / 500 / 3000) — used when PROTOCOL_NAME is "univ3" +const FEE_TIER = 500; +// Empty = use the ignition-deployed address for chain 8453 +const MANAGER_ADDRESS_OVERRIDE = ""; +// true = print the resolved params and calldata without executing +const DRY_RUN = true; +// ───────────────────────────────────────────────────────────────────────── + +const ZERO_LEG = { amountOutMin: 0n, poolParam: "0x" }; + +async function main() { + const OWNER_KEY = resolveOwnerKey(); + const MANAGER_ADDRESS = MANAGER_ADDRESS_OVERRIDE || deployedManagerAddress(); + + if (!SAFE_ADDRESS) throw new Error("Set TESTING_SAFE_WALLET_ADDRESS in .env"); + if (!OWNER_KEY) throw new Error("Set TESTING_SAFE_OWNER_KEY (or SAFE_OWNER_PRIVATE_KEY) in .env"); + if (!ethers.isAddress(SAFE_ADDRESS)) throw new Error(`Invalid SAFE_ADDRESS: ${SAFE_ADDRESS}`); + + const provider = ethers.provider; + + let protocol: number; + let poolParam: string; + + if (PROTOCOL_NAME === "univ4") { + protocol = YieldProtocol.UNISWAP_V4; + // The position's own PoolKey is the pool identity; the swap legs (when + // enabled) route through the same pool. + const pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, UNIV4_PM_ABI, provider); + const [key] = await pm.getPoolAndPositionInfo(TOKEN_ID); + if (key.currency1.toLowerCase() !== USDC_ADDRESS.toLowerCase()) { + throw new Error(`Token ${TOKEN_ID} is not a */USDC V4 position`); + } + poolParam = encodeUniV4PoolParam(key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks); + const owner: string = await pm.ownerOf(TOKEN_ID); + if (owner.toLowerCase() !== SAFE_ADDRESS.toLowerCase()) { + throw new Error(`Position ${TOKEN_ID} is owned by ${owner}, not the Safe`); + } + } else if (PROTOCOL_NAME === "aerodrome") { + protocol = YieldProtocol.AERODROME; + poolParam = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, AERO_NPM_ABI, provider); + await npm.positions(TOKEN_ID); // existence check (staked positions are owned by the stakePool) + } else { + protocol = YieldProtocol.UNISWAP_V3; + poolParam = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, UNIV3_NPM_ABI, provider); + const owner: string = await npm.ownerOf(TOKEN_ID); + if (owner.toLowerCase() !== SAFE_ADDRESS.toLowerCase()) { + throw new Error(`Position ${TOKEN_ID} is owned by ${owner}, not the Safe`); + } + } + + const manager = await ethers.getContractAt("SafeYieldManager", MANAGER_ADDRESS); + if (!(await manager.protocolEnabledForClose(protocol))) { + throw new Error(`Protocol ${PROTOCOL_NAME} (${protocol}) is disabled for close/collect`); + } + if ((await manager.positionHandlerOf(protocol, TOKEN_ID)) === ethers.ZeroAddress) { + throw new Error(`Position ${TOKEN_ID} was not opened through this manager`); + } + + const block = await provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp) + 1_200n; + + // The reward-swap leg carries a route, not a price. How much AERO the + // gauge will actually pay is only known when the collect executes, so the + // handler derives the min-out itself from the reference TWAP and the amount + // it really claimed. `earned` is read here purely to fail early and to log + // the floor that will apply. + let rewardLeg = ZERO_LEG as { amountOutMin: bigint; poolParam: string }; + if (SWAP_REWARD_TO_USDC) { + if (PROTOCOL_NAME !== "aerodrome") throw new Error("swapRewardToUsdc is only meaningful for staked Aerodrome"); + const [aeroT0, aeroT1] = + AERO_ADDRESS.toLowerCase() < USDC_ADDRESS.toLowerCase() + ? [AERO_ADDRESS, USDC_ADDRESS] + : [USDC_ADDRESS, AERO_ADDRESS]; + const rewardPoolParam = encodeAerodromePoolParam(aeroT0, aeroT1, REWARD_TICK_SPACING); + if (!(await manager.isPoolParamAllowed(protocol, rewardPoolParam))) { + throw new Error(`AERO/USDC pool (ts ${REWARD_TICK_SPACING}) is not allow-listed on the manager`); + } + const voter = new ethers.Contract( + AERODROME_VOTER_ADDRESS, + ["function gauges(address) view returns (address)"], + provider, + ); + const clFactory = new ethers.Contract( + AERODROME_CL_FACTORY_ADDRESS, + ["function getPool(address,address,int24) view returns (address)"], + provider, + ); + const lpPool = await clFactory.getPool(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); + const gaugeAddr: string = await voter.gauges(lpPool); + const gauge = new ethers.Contract( + gaugeAddr, + ["function earned(address,uint256) view returns (uint256)"], + provider, + ); + const earnedAero: bigint = await gauge.earned(SAFE_ADDRESS, TOKEN_ID); + if (earnedAero === 0n) throw new Error("No AERO earned yet — nothing to swap"); + + // The manager reverts here if AERO has no reference configured, which + // is the same revert the collect itself would hit — better to learn it + // now than mid-transaction. + const twapUsdcOut: bigint = await manager.twapQuote(AERO_ADDRESS, USDC_ADDRESS, earnedAero); + rewardLeg = { amountOutMin: 0n, poolParam: rewardPoolParam }; + console.log("- earned AERO (wei):", earnedAero.toString()); + console.log("- reward TWAP value (6dp):", twapUsdcOut.toString()); + console.log( + "- reward floor at exec (6dp, on the amount actually claimed):", + ((twapUsdcOut * (10_000n - SLIPPAGE_BPS)) / 10_000n).toString(), + ); + } + + // Fee amounts are unknown until the collect executes, so this leg supplies + // only the route: the handler prices whatever it collected against the + // reference TWAP and floors the swap at that, less slippageBps. + const feeLeg = { amountOutMin: 0n, poolParam }; + const collectParams = { + onBehalfOf: SAFE_ADDRESS, + tokenId: TOKEN_ID, + swapFeesToUsdc: SWAP_FEES_TO_USDC, + swap0: SWAP_FEES_TO_USDC ? feeLeg : ZERO_LEG, + swap1: ZERO_LEG, // USDC side never needs a swap leg + swapRewardToUsdc: SWAP_REWARD_TO_USDC, + rewardSwap: rewardLeg, + slippageBps: SLIPPAGE_BPS, + deadline, + }; + + console.log("Configuration:"); + console.log("- SafeYieldManager:", MANAGER_ADDRESS); + console.log("- Safe:", SAFE_ADDRESS); + console.log("- Protocol:", PROTOCOL_NAME, `(id ${protocol})`); + console.log("- Token id:", TOKEN_ID.toString()); + console.log("- swapFeesToUsdc:", SWAP_FEES_TO_USDC); + console.log("- swapRewardToUsdc:", SWAP_REWARD_TO_USDC); + console.log("- Deadline:", deadline.toString()); + + const collectLpData = manager.interface.encodeFunctionData("collectLp", [protocol, collectParams]); + if (DRY_RUN) { + console.log("\nDRY_RUN — collectLp calldata:"); + console.log(collectLpData); + return; + } + + const rpcUrl = (network.config as { url?: string }).url; + if (!rpcUrl) throw new Error(`Network ${network.name} has no RPC url — run with --network base`); + + const safeWallet = await Safe.init({ + provider: rpcUrl, + signer: OWNER_KEY, + safeAddress: SAFE_ADDRESS, + }); + + const threshold = await safeWallet.getThreshold(); + if (threshold > 1) { + throw new Error( + `Safe threshold is ${threshold}. This script only supports 1/N execution — propose the transaction via the Safe UI instead (target ${MANAGER_ADDRESS}, calldata printed with DRY_RUN = true).`, + ); + } + + if (!(await safeWallet.isModuleEnabled(MANAGER_ADDRESS))) { + throw new Error("SafeYieldManager is not enabled as a module on this Safe"); + } + + const transactions: MetaTransactionData[] = [ + { + to: MANAGER_ADDRESS, + value: "0", + data: collectLpData, + operation: OperationType.Call, + }, + ]; + + const safeTransaction = await safeWallet.createTransaction({ transactions }); + const result = await safeWallet.executeTransaction(safeTransaction); + console.log("Submitted:", result.hash); + + const receipt = await waitForReceipt(provider, result.hash); + console.log("Confirmed in block", receipt.blockNumber); + + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== MANAGER_ADDRESS.toLowerCase()) continue; + let parsed: ReturnType; + try { + parsed = manager.interface.parseLog({ topics: [...log.topics], data: log.data }); + } catch { + continue; + } + if (parsed?.name !== "FeesCollected") continue; + console.log("FeesCollected — tokenId:", parsed.args.tokenId.toString()); + console.log( + "- token0:", + parsed.args.token0, + "collected:", + parsed.args.collected0.toString(), + "fee:", + parsed.args.fee0.toString(), + ); + console.log( + "- token1:", + parsed.args.token1, + "collected:", + parsed.args.collected1.toString(), + "fee:", + parsed.args.fee1.toString(), + ); + return; + } + console.log("No FeesCollected event found in the receipt — check the tx on Basescan."); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/exportAbis.js b/scripts/exportAbis.js index bc4b2c8..efd2df6 100644 --- a/scripts/exportAbis.js +++ b/scripts/exportAbis.js @@ -1,23 +1,26 @@ const fs = require("fs"); const path = require("path"); -const contracts = [ - "LeveragedPosition", - "RatehopperUniV3Positions", - "SafeDebtManager", - "SafeExecTransactionWrapper", -]; +// Contract name → source folder under contracts/ (artifact paths mirror it). +const contracts = { + LeveragedPosition: "debt", + SafeDebtManager: "debt", + SafeExecTransactionWrapper: "debt", + SafeYieldManager: "yield", + RatehopperUniV3Positions: "legacy", +}; const rootDir = path.join(__dirname, ".."); const abisDir = path.join(rootDir, "abis"); fs.mkdirSync(abisDir, { recursive: true }); -for (const contractName of contracts) { +for (const [contractName, sourceDir] of Object.entries(contracts)) { const artifactPath = path.join( rootDir, "artifacts", "contracts", + sourceDir, `${contractName}.sol`, `${contractName}.json`, ); diff --git a/scripts/lpMath.ts b/scripts/lpMath.ts new file mode 100644 index 0000000..55dfec2 --- /dev/null +++ b/scripts/lpMath.ts @@ -0,0 +1,242 @@ +/** + * Exact integer tick and liquidity math for building LP transactions. + * + * Every value here is a bigint end to end. Nothing is routed through a + * JavaScript `number`: sqrt prices are 160-bit, liquidity is 128-bit and token + * amounts are 256-bit, so a single `Number(...)` or `Math.pow` would silently + * round the very quantity a transaction minimum is meant to pin. `test/scripts/ + * lpMath.ts` diffs these functions against the pinned Uniswap libraries + * (TickMath, LiquidityAmounts, SqrtPriceMath) through `LpMathProbe`. + * + * Tick indices ARE plain numbers — they are small signed integers, exact in a + * double, and that is how the contracts' ABI takes them. + */ + +export const Q96 = 1n << 96n; +export const MIN_TICK = -887272; +export const MAX_TICK = 887272; +export const BPS = 10_000n; + +/// Q128.128 multipliers of the canonical Uniswap TickMath, indexed by the bit +/// of |tick| they correspond to. +const TICK_RATIOS: readonly bigint[] = [ + 0xfffcb933bd6fad37aa2d162d1a594001n, + 0xfff97272373d413259a46990580e213an, + 0xfff2e50f5f656932ef12357cf3c7fdccn, + 0xffe5caca7e10e4e61c3624eaa0941cd0n, + 0xffcb9843d60f6159c9db58835c926644n, + 0xff973b41fa98c081472e6896dfb254c0n, + 0xff2ea16466c96a3843ec78b326b52861n, + 0xfe5dee046a99a2a811c461f1969c3053n, + 0xfcbe86c7900a88aedcffc83b479aa3a4n, + 0xf987a7253ac413176f2b074cf7815e54n, + 0xf3392b0822b70005940c7a398e4b70f3n, + 0xe7159475a2c29b7443b29c7fa6e889d9n, + 0xd097f3bdfd2022b8845ad8f792aa5825n, + 0xa9f746462d870fdf8a65dc1f90e061e5n, + 0x70d869a156d2a1b890bb3df62baf32f7n, + 0x31be135f97d08fd981231505542fcfa6n, + 0x9aa508b5b7a84e1c677de54f3e99bc9n, + 0x5d6af8dedb81196699c329225ee604n, + 0x2216e584f5fa1ea926041bedfe98n, + 0x48a170391f7dc42444e8fa2n, +]; + +const MAX_UINT256 = (1n << 256n) - 1n; +const MAX_UINT128 = (1n << 128n) - 1n; + +/// Liquidity is uint128 on-chain, and the Uniswap libraries revert on overflow. +/// Producing an unrepresentable number here would hand the caller a minimum the +/// position manager could never satisfy, so refuse it at the same boundary. +function toUint128(value: bigint): bigint { + if (value > MAX_UINT128) throw new Error(`liquidity overflows uint128: ${value}`); + return value; +} + +/// Uniswap `TickMath.getSqrtRatioAtTick`, bit for bit. +export function getSqrtRatioAtTick(tick: number): bigint { + if (!Number.isInteger(tick)) throw new Error(`tick must be an integer: ${tick}`); + if (tick < MIN_TICK || tick > MAX_TICK) throw new Error(`tick out of range: ${tick}`); + + const absTick = BigInt(Math.abs(tick)); + let ratio = (absTick & 0x1n) !== 0n ? TICK_RATIOS[0] : 1n << 128n; + for (let i = 1; i < TICK_RATIOS.length; i++) { + if ((absTick & (1n << BigInt(i))) !== 0n) ratio = (ratio * TICK_RATIOS[i]) >> 128n; + } + if (tick > 0) ratio = MAX_UINT256 / ratio; + + // Q128.128 -> Q128.96, rounding up, exactly as the library does. + return (ratio >> 32n) + (ratio % (1n << 32n) === 0n ? 0n : 1n); +} + +/// Largest multiple of `spacing` at or below `tick` (floors toward -infinity, +/// so a negative tick widens outward rather than snapping inward). +export function alignTick(tick: number, spacing: number): number { + if (spacing <= 0) throw new Error(`spacing must be positive: ${spacing}`); + return Math.floor(tick / spacing) * spacing; +} + +function sortPrices(a: bigint, b: bigint): [bigint, bigint] { + return a > b ? [b, a] : [a, b]; +} + +export function getLiquidityForAmount0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + if (hi === lo) return 0n; + const intermediate = (lo * hi) / Q96; + return toUint128((amount0 * intermediate) / (hi - lo)); +} + +export function getLiquidityForAmount1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + if (hi === lo) return 0n; + return toUint128((amount1 * Q96) / (hi - lo)); +} + +/// Liquidity that `amount0`/`amount1` can actually fund — the SMALLER of the +/// two sides in range, which is what the position manager will mint. +export function getLiquidityForAmounts( + sqrtPriceX96: bigint, + sqrtA: bigint, + sqrtB: bigint, + amount0: bigint, + amount1: bigint, +): bigint { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + if (sqrtPriceX96 <= lo) return getLiquidityForAmount0(lo, hi, amount0); + if (sqrtPriceX96 < hi) { + const liquidity0 = getLiquidityForAmount0(sqrtPriceX96, hi, amount0); + const liquidity1 = getLiquidityForAmount1(lo, sqrtPriceX96, amount1); + return liquidity0 < liquidity1 ? liquidity0 : liquidity1; + } + return getLiquidityForAmount1(lo, hi, amount1); +} + +export function getAmount0ForLiquidity(sqrtA: bigint, sqrtB: bigint, liquidity: bigint): bigint { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + if (lo === 0n) throw new Error("sqrt price cannot be zero"); + return ((liquidity << 96n) * (hi - lo)) / hi / lo; +} + +export function getAmount1ForLiquidity(sqrtA: bigint, sqrtB: bigint, liquidity: bigint): bigint { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + return (liquidity * (hi - lo)) / Q96; +} + +/// Token amounts a position of `liquidity` holds at `sqrtPriceX96`. Below the +/// range it is all token0, above it all token1 — which is why a minimum on the +/// unused side must be zero rather than "small". +export function getAmountsForLiquidity( + sqrtPriceX96: bigint, + sqrtA: bigint, + sqrtB: bigint, + liquidity: bigint, +): { amount0: bigint; amount1: bigint } { + const [lo, hi] = sortPrices(sqrtA, sqrtB); + if (sqrtPriceX96 <= lo) return { amount0: getAmount0ForLiquidity(lo, hi, liquidity), amount1: 0n }; + if (sqrtPriceX96 < hi) { + return { + amount0: getAmount0ForLiquidity(sqrtPriceX96, hi, liquidity), + amount1: getAmount1ForLiquidity(lo, sqrtPriceX96, liquidity), + }; + } + return { amount0: 0n, amount1: getAmount1ForLiquidity(lo, hi, liquidity) }; +} + +function applySlippage(amount: bigint, slippageBps: bigint): bigint { + if (slippageBps < 0n || slippageBps >= BPS) throw new Error(`slippageBps out of range: ${slippageBps}`); + return (amount * (BPS - slippageBps)) / BPS; +} + +export interface RangeAtPrice { + sqrtPriceX96: bigint; + tickLower: number; + tickUpper: number; +} + +/** + * Floors for `mintAmount0Min` / `mintAmount1Min`. + * + * The mint consumes the two sides in the ratio the range demands, so the + * binding constraint is the side that funds the LESS liquidity; the other side + * is only partly consumed. Deriving the minima from that liquidity — rather + * than from the desired amounts — is what keeps them achievable: a minimum + * above what the mint can consume would revert every honest transaction. + * A side the range cannot consume at the current price comes back as zero, + * because any positive floor there is unsatisfiable. + */ +export function mintMinimums( + range: RangeAtPrice, + amount0Desired: bigint, + amount1Desired: bigint, + slippageBps: bigint, +): { amount0Min: bigint; amount1Min: bigint; liquidity: bigint; expected0: bigint; expected1: bigint } { + const sqrtA = getSqrtRatioAtTick(range.tickLower); + const sqrtB = getSqrtRatioAtTick(range.tickUpper); + const liquidity = getLiquidityForAmounts(range.sqrtPriceX96, sqrtA, sqrtB, amount0Desired, amount1Desired); + const { amount0: expected0, amount1: expected1 } = getAmountsForLiquidity( + range.sqrtPriceX96, + sqrtA, + sqrtB, + liquidity, + ); + return { + amount0Min: applySlippage(expected0, slippageBps), + amount1Min: applySlippage(expected1, slippageBps), + liquidity, + expected0, + expected1, + }; +} + +/** + * Floors for `decreaseAmount0Min` / `decreaseAmount1Min`. + * + * `exitBps` prorates the liquidity first, so a partial close is bounded by what + * that fraction actually holds instead of by the whole position. As with the + * mint, a side the position does not hold at the current price yields zero. + */ +export function decreaseMinimums( + range: RangeAtPrice, + liquidity: bigint, + exitBps: bigint, + slippageBps: bigint, +): { amount0Min: bigint; amount1Min: bigint; liquidityRemoved: bigint; expected0: bigint; expected1: bigint } { + if (exitBps <= 0n || exitBps > BPS) throw new Error(`exitBps out of range: ${exitBps}`); + const sqrtA = getSqrtRatioAtTick(range.tickLower); + const sqrtB = getSqrtRatioAtTick(range.tickUpper); + const liquidityRemoved = (liquidity * exitBps) / BPS; + const { amount0: expected0, amount1: expected1 } = getAmountsForLiquidity( + range.sqrtPriceX96, + sqrtA, + sqrtB, + liquidityRemoved, + ); + return { + amount0Min: applySlippage(expected0, slippageBps), + amount1Min: applySlippage(expected1, slippageBps), + liquidityRemoved, + expected0, + expected1, + }; +} + +/** + * Mint floors for the destination leg of an in-kind switch. + * + * The switch feeds the mint whatever the withdrawal delivered, and the smallest + * withdrawal the transaction will accept is exactly the decrease minima. Sizing + * the mint floors from that worst accepted budget — not from the expected + * withdrawal — is what stops a transaction that satisfied the withdraw leg from + * reverting on the open leg. Anything the withdrawal delivers above the budget + * (collected fees, favourable rounding) only makes the mint easier to satisfy. + */ +export function switchMintMinimums( + destination: RangeAtPrice, + decreaseAmount0Min: bigint, + decreaseAmount1Min: bigint, + slippageBps: bigint, +): { amount0Min: bigint; amount1Min: bigint } { + const { amount0Min, amount1Min } = mintMinimums(destination, decreaseAmount0Min, decreaseAmount1Min, slippageBps); + return { amount0Min, amount1Min }; +} diff --git a/scripts/lpSafeShared.ts b/scripts/lpSafeShared.ts new file mode 100644 index 0000000..696a824 --- /dev/null +++ b/scripts/lpSafeShared.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; + +/** + * Shared helpers for the openLpBySafe / closeLpBySafe / switchLpBySafe ops + * scripts: minimal ABI fragments, the ignition deployment lookup, tick / + * liquidity math, and the receipt-polling workaround. One copy so a slot0 + * arity, deployment key, or math change cannot drift between the scripts. + */ + +export const ERC20_ABI = ["function balanceOf(address) view returns (uint256)"]; +export const UNIV3_FACTORY_ABI = ["function getPool(address,address,uint24) view returns (address)"]; +export const AERO_FACTORY_ABI = ["function getPool(address,address,int24) view returns (address)"]; +export const UNIV3_POOL_ABI = [ + "function slot0() view returns (uint160,int24,uint16,uint16,uint16,uint8,bool)", + "function tickSpacing() view returns (int24)", +]; +export const AERO_POOL_ABI = ["function slot0() view returns (uint160,int24,uint16,uint16,uint16,bool)"]; +export const UNIV3_NPM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function positions(uint256) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)", +]; +export const AERO_NPM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function positions(uint256) view returns (uint96,address,address,address,int24,int24,int24,uint128,uint256,uint256,uint128,uint128)", +]; +export const UNIV4_STATE_VIEW_ABI = [ + "function getSlot0(bytes32) view returns (uint160,int24,uint24,uint24)", + "function getLiquidity(bytes32) view returns (uint128)", +]; +export const UNIV4_PM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function getPoolAndPositionInfo(uint256) view returns ((address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks) poolKey,uint256 info)", + "function getPositionLiquidity(uint256) view returns (uint128)", +]; + +// V4 PositionInfo packing (PositionInfoLibrary): bytes25 poolId | int24 +// tickUpper (offset 32) | int24 tickLower (offset 8) | uint8 hasSubscriber. +export function unpackV4PositionTicks(info: bigint): { tickLower: number; tickUpper: number } { + const signed24 = (v: bigint) => { + const masked = v & 0xffffffn; + return Number(masked >= 0x800000n ? masked - 0x1000000n : masked); + }; + return { tickLower: signed24(info >> 8n), tickUpper: signed24(info >> 32n) }; +} + +export const Q96 = 1n << 96n; + +export function deployedManagerAddress(): string { + const deploymentId = process.env.IGNITION_DEPLOYMENT_ID || "chain-8453"; + const file = path.join(__dirname, `../ignition/deployments/${deploymentId}/deployed_addresses.json`); + const deployed = JSON.parse(fs.readFileSync(file, "utf8")); + return deployed["DeployYieldManager#SafeYieldManager"]; +} + +export function resolveOwnerKey(): string { + return ( + process.env.TESTING_SAFE_OWNER_KEY || + process.env.SAFE_OWNER_PRIVATE_KEY || + process.env.DEPLOYER_PRIVATE_KEY || + "" + ); +} + +// Tick and liquidity math lives in ./lpMath as exact bigint ports of the +// Uniswap libraries; re-exported here so existing script imports keep working +// and every script provably shares ONE implementation. +export { + alignTick, + decreaseMinimums, + getAmountsForLiquidity, + getLiquidityForAmounts, + getSqrtRatioAtTick, + mintMinimums, + switchMintMinimums, +} from "./lpMath"; + +import { getAmountsForLiquidity, getSqrtRatioAtTick } from "./lpMath"; + +/// Token amounts held by `liquidity` over [tickLower, tickUpper] at the current +/// price. Thin tick-indexed wrapper over the sqrt-price form in ./lpMath. +export function amountsForLiquidity( + sqrtPriceX96: bigint, + tickLower: number, + tickUpper: number, + liquidity: bigint, +): { amount0: bigint; amount1: bigint } { + return getAmountsForLiquidity( + sqrtPriceX96, + getSqrtRatioAtTick(tickLower), + getSqrtRatioAtTick(tickUpper), + liquidity, + ); +} + +// HardhatEthersProvider does not implement waitForTransaction — poll instead. +export async function waitForReceipt( + provider: { + getTransactionReceipt(hash: string): Promise<{ + status: number | null; + blockNumber: number; + logs: ReadonlyArray<{ address: string; topics: ReadonlyArray; data: string }>; + } | null>; + }, + hash: string, +) { + let receipt = await provider.getTransactionReceipt(hash); + for (let i = 0; i < 60 && !receipt; i++) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + receipt = await provider.getTransactionReceipt(hash); + } + if (!receipt) throw new Error(`Timed out waiting for transaction: ${hash}`); + if (receipt.status !== 1) throw new Error(`Transaction failed: ${hash}`); + return receipt; +} diff --git a/scripts/openLpBySafe.ts b/scripts/openLpBySafe.ts new file mode 100644 index 0000000..04670c0 --- /dev/null +++ b/scripts/openLpBySafe.ts @@ -0,0 +1,279 @@ +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 { + AERODROME_CL_FACTORY_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../contractAddresses"; +import { + AERO_FACTORY_ABI, + AERO_POOL_ABI, + ERC20_ABI, + UNIV3_FACTORY_ABI, + UNIV3_POOL_ABI, + UNIV4_STATE_VIEW_ABI, + alignTick, + deployedManagerAddress, + mintMinimums, + resolveOwnerKey, + waitForReceipt, +} from "./lpSafeShared"; + +/** + * Opens an LP position on the deployed SafeYieldManager (Base mainnet) FROM the + * user's own Safe (msg.sender == Safe path of `onlyOperatorOrSafe`). + * + * Edit the configuration constants below, set SAFE_OWNER_PRIVATE_KEY (or + * DEPLOYER_PRIVATE_KEY) in .env, then run: + * + * npx hardhat run scripts/openLpBySafe.ts --network base + * + * The script: + * 1. Verifies the Safe has enough USDC and that SafeYieldManager is enabled as + * a module (prepends `enableModule` to the batch if not). + * 2. Reads the pool's current tick, aligns a symmetric range around it. + * 3. Derives `expectedSwapOut` (USDC/2 -> WETH) from the pool spot price and + * sets `swapAmountOutMin = expectedSwapOut * (1 - slippage)`. + * 4. Executes `openLp` via the Safe (protocol-kit). Threshold must be 1 — + * for multi-sig Safes set DRY_RUN = true and propose the printed calldata + * through the Safe UI instead. + */ + +// ─── Configuration ─────────────────────────────────────────────────────── +const SAFE_ADDRESS: string = process.env.TESTING_SAFE_WALLET_ADDRESS || ""; +const USDC_AMOUNT = "0.1"; +const PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "aerodrome"; +const SLIPPAGE_BPS: bigint = 100n; +// Aerodrome tick spacing (100 or 200) — used when PROTOCOL_NAME is "aerodrome" +const TICK_SPACING = 100; +// Stake the minted NFT into the pool's gauge (Aerodrome only; reverts +// StakingNotSupported on univ3/univ4). Requires the pool to have a live gauge. +const STAKE = false; +// UniV3 fee tier (100 / 500 / 3000) — used when PROTOCOL_NAME is "univ3" +const FEE_TIER = 500; +// Uniswap V4 PoolKey fields — used when PROTOCOL_NAME is "univ4". Native +// ETH/USDC (currency0 = address(0)) is the deepest V4 pool on Base; set +// V4_USE_NATIVE_ETH = false for the (shallower) WETH/USDC V4 pool. +const V4_FEE_TIER = 500; +const V4_TICK_SPACING = 10; +const V4_USE_NATIVE_ETH = true; +const V4_HOOKS = "0x0000000000000000000000000000000000000000"; +// Half-width of the range in raw ticks; 0 = default (10 * pool tick spacing) +const TICK_RANGE = 0; +// Slippage applied to the mint minima, derived from the worst accepted swap +// output rather than the optimistic spot estimate. +const MINT_SLIPPAGE_BPS: bigint = 100n; +// Empty = use the ignition-deployed address for chain 8453 +const MANAGER_ADDRESS_OVERRIDE = ""; +// true = print the resolved params and calldata without executing +const DRY_RUN = true; +// ───────────────────────────────────────────────────────────────────────── + +async function main() { + const OWNER_KEY = resolveOwnerKey(); + const MANAGER_ADDRESS = MANAGER_ADDRESS_OVERRIDE || deployedManagerAddress(); + + if (!SAFE_ADDRESS) throw new Error("Set SAFE_ADDRESS at the top of the script"); + if (!OWNER_KEY) throw new Error("Set SAFE_OWNER_PRIVATE_KEY (or DEPLOYER_PRIVATE_KEY) in .env"); + if (!ethers.isAddress(SAFE_ADDRESS)) throw new Error(`Invalid SAFE_ADDRESS: ${SAFE_ADDRESS}`); + + const provider = ethers.provider; + const usdcAmount = ethers.parseUnits(USDC_AMOUNT, 6); + + // Resolve pool, tick spacing and the ABI-encoded pool param per protocol + let protocol: number; + let poolParam: string; + let poolAddress: string; + let tickSpacing: number; + let currentTick: number; + let sqrtPriceX96: bigint; + + if (PROTOCOL_NAME === "aerodrome") { + protocol = YieldProtocol.AERODROME; + tickSpacing = TICK_SPACING; + poolParam = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, tickSpacing); + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, AERO_FACTORY_ABI, provider); + poolAddress = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, tickSpacing); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No Aerodrome pool for tickSpacing ${tickSpacing}`); + const pool = new ethers.Contract(poolAddress, AERO_POOL_ABI, provider); + const [sqrtP, tick] = await pool.slot0(); + sqrtPriceX96 = sqrtP; + currentTick = Number(tick); + } else if (PROTOCOL_NAME === "univ4") { + protocol = YieldProtocol.UNISWAP_V4; + tickSpacing = V4_TICK_SPACING; + // Both native ETH and WETH are 18-decimals currency0, so the spot + // math below is identical to the V3/Aerodrome WETH path. + const currency0 = V4_USE_NATIVE_ETH ? ethers.ZeroAddress : WETH_ADDRESS; + poolParam = encodeUniV4PoolParam(currency0, USDC_ADDRESS, V4_FEE_TIER, V4_TICK_SPACING, V4_HOOKS); + const poolId = ethers.keccak256(poolParam); + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, UNIV4_STATE_VIEW_ABI, provider); + const [sqrtP, tick] = await stateView.getSlot0(poolId); + sqrtPriceX96 = BigInt(sqrtP); + if (sqrtPriceX96 === 0n) throw new Error(`V4 pool not initialized: ${poolId}`); + currentTick = Number(tick); + poolAddress = `V4 PoolManager (poolId ${poolId})`; + } else { + protocol = YieldProtocol.UNISWAP_V3; + poolParam = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, UNIV3_FACTORY_ABI, provider); + poolAddress = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No UniV3 pool for feeTier ${FEE_TIER}`); + const pool = new ethers.Contract(poolAddress, UNIV3_POOL_ABI, provider); + const [sqrtP, tick] = await pool.slot0(); + sqrtPriceX96 = sqrtP; + currentTick = Number(tick); + tickSpacing = Number(await pool.tickSpacing()); + } + + const tickRange = TICK_RANGE || tickSpacing * 10; + const alignedTick = alignTick(currentTick, tickSpacing); + const tickLower = alignTick(alignedTick - tickRange, tickSpacing); + const tickUpper = alignTick(alignedTick + tickRange, tickSpacing); + if (tickLower >= tickUpper) throw new Error("TICK_RANGE too small for the pool's tick spacing"); + + // Spot-price estimate of the USDC/2 -> WETH swap output. + // token0 = WETH, token1 = USDC on Base, so price(USDC per WETH) = sqrtP^2 / 2^192 + const halfUsdc = usdcAmount / 2n; + const expectedSwapOut = (halfUsdc << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const swapAmountOutMin = (expectedSwapOut * (10_000n - SLIPPAGE_BPS)) / 10_000n; + if (swapAmountOutMin === 0n) throw new Error("USDC_AMOUNT too small: swapAmountOutMin rounds to zero"); + + const manager = await ethers.getContractAt("SafeYieldManager", MANAGER_ADDRESS); + const maxSlippageBps: bigint = await manager.maxSlippageBps(); + if (SLIPPAGE_BPS === 0n || SLIPPAGE_BPS > maxSlippageBps) { + throw new Error(`SLIPPAGE_BPS must be in 1..${maxSlippageBps}`); + } + if (!(await manager.protocolEnabledForOpen(protocol))) { + throw new Error(`Protocol ${PROTOCOL_NAME} (${protocol}) is disabled for open`); + } + + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider); + const safeUsdcBalance: bigint = await usdc.balanceOf(SAFE_ADDRESS); + if (safeUsdcBalance < usdcAmount) { + throw new Error(`Safe USDC balance ${ethers.formatUnits(safeUsdcBalance, 6)} < requested ${USDC_AMOUNT}`); + } + + // Mint minima from the CONSERVATIVE budget: if the swap lands exactly on + // its floor, the mint must still clear its own. Sizing them off + // `expectedSwapOut` instead would revert honest transactions. + const mint = mintMinimums( + { sqrtPriceX96: BigInt(sqrtPriceX96), tickLower, tickUpper }, + swapAmountOutMin, + halfUsdc, + MINT_SLIPPAGE_BPS, + ); + + const block = await provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp) + 1_200n; + + const openParams = { + onBehalfOf: SAFE_ADDRESS, + usdcAmount, + tickLower, + tickUpper, + mintAmount0Min: mint.amount0Min, + mintAmount1Min: mint.amount1Min, + // WETH is token0 on Base; the USDC side needs no swap leg. + swap0: { amountOutMin: swapAmountOutMin, poolParam }, + swap1: { amountOutMin: 0, poolParam: "0x" }, + slippageBps: SLIPPAGE_BPS, + deadline, + lpPoolParam: poolParam, + stake: STAKE, + }; + + console.log("Configuration:"); + console.log("- SafeYieldManager:", MANAGER_ADDRESS); + console.log("- Safe:", SAFE_ADDRESS); + console.log("- Protocol:", PROTOCOL_NAME, `(id ${protocol})`); + console.log("- Pool:", poolAddress); + console.log("- USDC amount:", USDC_AMOUNT); + console.log("- Current tick:", currentTick, "| range:", tickLower, "..", tickUpper); + console.log("- expectedSwapOut (WETH):", ethers.formatEther(expectedSwapOut)); + console.log("- swapAmountOutMin (WETH):", ethers.formatEther(swapAmountOutMin)); + console.log("- Slippage bps:", SLIPPAGE_BPS.toString()); + console.log("- Mint minimums (token0/token1):", mint.amount0Min.toString(), "/", mint.amount1Min.toString()); + console.log("- Mint expected consumed:", mint.expected0.toString(), "/", mint.expected1.toString()); + console.log("- Deadline:", deadline.toString()); + + const openLpData = manager.interface.encodeFunctionData("openLp", [protocol, openParams]); + if (DRY_RUN) { + console.log("\nDRY_RUN — openLp calldata:"); + console.log(openLpData); + return; + } + + const rpcUrl = (network.config as { url?: string }).url; + if (!rpcUrl) throw new Error(`Network ${network.name} has no RPC url — run with --network base`); + + const safeWallet = await Safe.init({ + provider: rpcUrl, + signer: OWNER_KEY, + safeAddress: SAFE_ADDRESS, + }); + + const threshold = await safeWallet.getThreshold(); + if (threshold > 1) { + throw new Error( + `Safe threshold is ${threshold}. This script only supports 1/N execution — propose the transaction via the Safe UI instead (target ${MANAGER_ADDRESS}, calldata printed with DRY_RUN = true).`, + ); + } + + const transactions: MetaTransactionData[] = []; + + const moduleEnabled = await safeWallet.isModuleEnabled(MANAGER_ADDRESS); + if (!moduleEnabled) { + console.log("SafeYieldManager module not enabled — prepending enableModule"); + const safeIface = new ethers.Interface(["function enableModule(address)"]); + transactions.push({ + to: SAFE_ADDRESS, + value: "0", + data: safeIface.encodeFunctionData("enableModule", [MANAGER_ADDRESS]), + operation: OperationType.Call, + }); + } + + transactions.push({ + to: MANAGER_ADDRESS, + value: "0", + data: openLpData, + operation: OperationType.Call, + }); + + const safeTransaction = await safeWallet.createTransaction({ transactions }); + const result = await safeWallet.executeTransaction(safeTransaction); + console.log("Submitted:", result.hash); + + const receipt = await waitForReceipt(provider, result.hash); + console.log("Confirmed in block", receipt.blockNumber); + + const opened = await manager.queryFilter( + manager.filters.PositionOpened(SAFE_ADDRESS), + receipt.blockNumber, + receipt.blockNumber, + ); + if (opened.length > 0) { + const event = opened[opened.length - 1]; + console.log("PositionOpened — tokenId:", event.args.tokenId.toString()); + console.log("- WETH to LP:", ethers.formatEther(event.args.amount0ToLp)); + console.log("- USDC to LP:", ethers.formatUnits(event.args.amount1ToLp, 6)); + console.log("- Position value (USD):", ethers.formatUnits(event.args.currentValueUsd6, 6)); + } else { + console.log("No PositionOpened event found in the confirmation block — check the tx on Basescan."); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/setPoolParamAllowed.ts b/scripts/setPoolParamAllowed.ts new file mode 100644 index 0000000..59cfd97 --- /dev/null +++ b/scripts/setPoolParamAllowed.ts @@ -0,0 +1,154 @@ +import { ethers, network } from "hardhat"; +import dotenv from "dotenv"; +dotenv.config(); +import { + USDC_ADDRESS, + WETH_ADDRESS, + AERO_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../contractAddresses"; +import { deployedManagerAddress } from "./lpSafeShared"; + +/** + * Toggles a pool-param entry in the SafeYieldManager allow-list (Base mainnet). + * + * `setPoolParamAllowed(uint8 protocol, bytes poolParam, bool allowed)` is a + * DEFAULT_ADMIN_ROLE (routine, non-timelock) setter, so it must be signed by + * the manager's admin. The signer is resolved from ADMIN_PRIVATE_KEY (falling + * back to TESTING_SAFE_OPERATOR_KEY, then DEPLOYER_PRIVATE_KEY); it must hold + * DEFAULT_ADMIN_ROLE or the call reverts. + * + * The pool param carries the pair and must be byte-identical to what callers + * later pass as lpPoolParam / SwapLeg.poolParam: + * - univ3: abi.encode(token0, token1, uint24 feeTier) + * - aerodrome: abi.encode(token0, token1, int24 tickSpacing) + * - univ4: abi.encode(currency0, currency1, uint24 fee, int24 tickSpacing, address hooks) + * token0/currency0 must be the lower-sorted address (native ETH = address(0) + * always sorts first). + * + * Enabling fails closed unless every non-USDC side already has a live TWAP + * reference. Native ETH uses the address(0) reference key; configure it to a + * WETH/USDC reference pool through the critical timelock first. + * + * Configure the constants below (or override via the noted env vars), then: + * IGNITION_DEPLOYMENT_ID=yield-v2 npx hardhat run scripts/setPoolParamAllowed.ts --network base + */ + +// ─── Configuration ─────────────────────────────────────────────────────── +const PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "aerodrome"; +const ALLOWED = true; + +// Pair to allow-list. Defaults below build an AERO/USDC pool (the reward-swap +// venue for staked Aerodrome collect); edit TOKEN0/TOKEN1 for another pair. +// Addresses are auto-sorted so token0 < token1 as the pool expects. +const TOKEN_A = AERO_ADDRESS; +const TOKEN_B = USDC_ADDRESS; + +// Aerodrome tick spacing / UniV3 fee tier / UniV4 fee+spacing+hooks. +const TICK_SPACING = 100; +const FEE_TIER = 500; +const V4_FEE_TIER = 500; +const V4_TICK_SPACING = 10; +const V4_HOOKS = "0x0000000000000000000000000000000000000000"; + +const MANAGER_ADDRESS_OVERRIDE = ""; +// true = print the resolved params and calldata without executing. +const DRY_RUN = true; +// ───────────────────────────────────────────────────────────────────────── + +function resolveAdminKey(): string { + return ( + process.env.ADMIN_PRIVATE_KEY || process.env.TESTING_SAFE_OPERATOR_KEY || process.env.DEPLOYER_PRIVATE_KEY || "" + ); +} + +async function main() { + const MANAGER_ADDRESS = MANAGER_ADDRESS_OVERRIDE || deployedManagerAddress(); + const ADMIN_KEY = resolveAdminKey(); + if (!ADMIN_KEY) throw new Error("Set ADMIN_PRIVATE_KEY (or TESTING_SAFE_OPERATOR_KEY / DEPLOYER_PRIVATE_KEY)"); + + // Sort so token0 < token1 (native ETH address(0) already sorts first). + const [t0, t1] = TOKEN_A.toLowerCase() < TOKEN_B.toLowerCase() ? [TOKEN_A, TOKEN_B] : [TOKEN_B, TOKEN_A]; + + let protocol: number; + let poolParam: string; + if (PROTOCOL_NAME === "aerodrome") { + protocol = YieldProtocol.AERODROME; + poolParam = encodeAerodromePoolParam(t0, t1, TICK_SPACING); + } else if (PROTOCOL_NAME === "univ4") { + protocol = YieldProtocol.UNISWAP_V4; + poolParam = encodeUniV4PoolParam(t0, t1, V4_FEE_TIER, V4_TICK_SPACING, V4_HOOKS); + } else { + protocol = YieldProtocol.UNISWAP_V3; + poolParam = encodeUniV3PoolParam(t0, t1, FEE_TIER); + } + const rpcUrl = (network.config as { url?: string }).url; + if (!rpcUrl) throw new Error(`Network ${network.name} has no RPC url — run with --network base`); + const provider = new ethers.JsonRpcProvider(rpcUrl); + const admin = new ethers.Wallet(ADMIN_KEY, provider); + + const manager = new ethers.Contract( + MANAGER_ADDRESS, + [ + "function setPoolParamAllowed(uint8,bytes,bool) external", + "function isPoolParamAllowed(uint8,bytes) view returns (bool)", + "function twapConfigOf(address) view returns ((address pool,uint32 window,uint16 minCardinality))", + "function hasRole(bytes32,address) view returns (bool)", + "function DEFAULT_ADMIN_ROLE() view returns (bytes32)", + ], + admin, + ); + + const adminRole = await manager.DEFAULT_ADMIN_ROLE(); + const signerIsAdmin = await manager.hasRole(adminRole, admin.address); + const already = await manager.isPoolParamAllowed(protocol, poolParam); + + console.log("Configuration:"); + console.log("- SafeYieldManager:", MANAGER_ADDRESS); + console.log("- Protocol:", PROTOCOL_NAME, `(id ${protocol})`); + console.log("- token0:", t0); + console.log("- token1:", t1); + console.log("- poolParam:", poolParam); + console.log("- key (keccak256):", ethers.keccak256(poolParam)); + console.log("- target allowed:", ALLOWED, "| currently allowed:", already); + console.log("- Signer:", admin.address, "| has DEFAULT_ADMIN_ROLE:", signerIsAdmin); + + if (!signerIsAdmin) throw new Error(`Signer ${admin.address} lacks DEFAULT_ADMIN_ROLE — the call would revert`); + if (ALLOWED) { + for (const token of [t0, t1]) { + if (token.toLowerCase() === USDC_ADDRESS.toLowerCase()) continue; + const config = await manager.twapConfigOf(token); + if (config.pool === ethers.ZeroAddress) { + throw new Error( + `Missing TWAP reference for ${token} — schedule setTwapConfig through the critical timelock first`, + ); + } + console.log(`- TWAP reference for ${token}:`, config.pool, `(window ${config.window})`); + } + } + if (already === ALLOWED) { + console.log(`\nNo-op: allow-list is already ${ALLOWED}.`); + return; + } + + const data = manager.interface.encodeFunctionData("setPoolParamAllowed", [protocol, poolParam, ALLOWED]); + if (DRY_RUN) { + console.log("\nDRY_RUN — setPoolParamAllowed calldata:"); + console.log(data); + return; + } + + const tx = await manager.setPoolParamAllowed(protocol, poolParam, ALLOWED); + console.log("\nSubmitted:", tx.hash); + const receipt = await tx.wait(); + console.log("Confirmed in block", receipt?.blockNumber); + console.log("Now allowed:", await manager.isPoolParamAllowed(protocol, poolParam)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/switchLpBySafe.ts b/scripts/switchLpBySafe.ts new file mode 100644 index 0000000..d42a974 --- /dev/null +++ b/scripts/switchLpBySafe.ts @@ -0,0 +1,349 @@ +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 { + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + AERODROME_VOTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../contractAddresses"; +import { + AERO_FACTORY_ABI, + AERO_NPM_ABI, + AERO_POOL_ABI, + UNIV3_FACTORY_ABI, + UNIV3_NPM_ABI, + UNIV3_POOL_ABI, + UNIV4_PM_ABI, + UNIV4_STATE_VIEW_ABI, + alignTick, + amountsForLiquidity, + decreaseMinimums, + switchMintMinimums, + deployedManagerAddress, + resolveOwnerKey, + unpackV4PositionTicks, + waitForReceipt, +} from "./lpSafeShared"; + +/** + * Switches a SafeYieldManager LP position between Uniswap V3 and Aerodrome + * Slipstream on Base mainnet. + * + * Edit the constants below, set TESTING_SAFE_OWNER_KEY (or + * SAFE_OWNER_PRIVATE_KEY / DEPLOYER_PRIVATE_KEY) in .env, then run: + * + * npx hardhat run scripts/switchLpBySafe.ts --network base + * + * The switch is IN KIND: the withdrawn token amounts are redeployed into the + * target pool directly, with no swaps. The script only derives the target + * tick range from the target pool's current spot price and logs the estimated + * withdrawal for review. It starts in DRY_RUN mode so the calldata can be + * reviewed before submission. + */ + +// ─── Configuration ─────────────────────────────────────────────────────── +const SAFE_ADDRESS = process.env.TESTING_SAFE_WALLET_ADDRESS || ""; +const TOKEN_ID = 74555704n; +const FROM_PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "aerodrome"; +const TO_PROTOCOL_NAME: "aerodrome" | "univ3" | "univ4" = "univ4"; + +// Target Aerodrome tick spacing / UniV3 fee tier. +const TARGET_TICK_SPACING = 100; +const TARGET_FEE_TIER = 500; +// Target Uniswap V4 PoolKey fields — used when TO_PROTOCOL_NAME is "univ4". +const TARGET_V4_FEE_TIER = 500; +const TARGET_V4_TICK_SPACING = 10; +const TARGET_V4_USE_NATIVE_ETH = true; +const TARGET_V4_HOOKS = "0x0000000000000000000000000000000000000000"; +// Half-width in raw ticks. Zero means 10 * target pool tick spacing. +const TARGET_TICK_RANGE = 0; + +// Slippage applied to the withdraw-leg floors, and to the destination mint +// floors that are derived from them. +const DECREASE_SLIPPAGE_BPS: bigint = 100n; +const MINT_SLIPPAGE_BPS: bigint = 100n; +const MANAGER_ADDRESS_OVERRIDE = ""; +// Keep true until the calldata and estimates have been reviewed. +const DRY_RUN = true; +// ───────────────────────────────────────────────────────────────────────── + +type PoolInfo = { + protocol: number; + poolParam: string; + poolAddress: string; + sqrtPriceX96: bigint; + tick: number; + tickSpacing: number; +}; + +async function resolveV4Pool(poolParam: string, tickSpacing: number): Promise { + const poolId = ethers.keccak256(poolParam); + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, UNIV4_STATE_VIEW_ABI, ethers.provider); + const [sqrtPrice, tick] = await stateView.getSlot0(poolId); + if (BigInt(sqrtPrice) === 0n) throw new Error(`V4 pool not initialized: ${poolId}`); + return { + protocol: YieldProtocol.UNISWAP_V4, + poolParam, + poolAddress: `V4 PoolManager (poolId ${poolId})`, + sqrtPriceX96: BigInt(sqrtPrice), + tick: Number(tick), + tickSpacing, + }; +} + +async function resolvePool(protocolName: "aerodrome" | "univ3" | "univ4", poolParamValue: number): Promise { + const provider = ethers.provider; + if (protocolName === "univ4") { + const currency0 = TARGET_V4_USE_NATIVE_ETH ? ethers.ZeroAddress : WETH_ADDRESS; + const poolParam = encodeUniV4PoolParam( + currency0, + USDC_ADDRESS, + TARGET_V4_FEE_TIER, + TARGET_V4_TICK_SPACING, + TARGET_V4_HOOKS, + ); + return resolveV4Pool(poolParam, TARGET_V4_TICK_SPACING); + } + if (protocolName === "aerodrome") { + const poolParam = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, poolParamValue); + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, AERO_FACTORY_ABI, provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, poolParamValue); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No Aerodrome pool for tickSpacing ${poolParamValue}`); + const pool = new ethers.Contract(poolAddress, AERO_POOL_ABI, provider); + const [sqrtPrice, tick] = await pool.slot0(); + return { + protocol: YieldProtocol.AERODROME, + poolParam, + poolAddress, + sqrtPriceX96: BigInt(sqrtPrice), + tick: Number(tick), + tickSpacing: poolParamValue, + }; + } + + const poolParam = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, poolParamValue); + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, UNIV3_FACTORY_ABI, provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, poolParamValue); + if (poolAddress === ethers.ZeroAddress) throw new Error(`No Uniswap V3 pool for fee tier ${poolParamValue}`); + const pool = new ethers.Contract(poolAddress, UNIV3_POOL_ABI, provider); + const [sqrtPrice, tick] = await pool.slot0(); + return { + protocol: YieldProtocol.UNISWAP_V3, + poolParam, + poolAddress, + sqrtPriceX96: BigInt(sqrtPrice), + tick: Number(tick), + tickSpacing: Number(await pool.tickSpacing()), + }; +} + +async function main() { + const OWNER_KEY = resolveOwnerKey(); + const MANAGER_ADDRESS = MANAGER_ADDRESS_OVERRIDE || deployedManagerAddress(); + + if (!ethers.isAddress(SAFE_ADDRESS)) throw new Error(`Invalid SAFE_ADDRESS: ${SAFE_ADDRESS}`); + if (!ethers.isAddress(MANAGER_ADDRESS)) throw new Error(`Invalid SafeYieldManager address: ${MANAGER_ADDRESS}`); + if (!OWNER_KEY) throw new Error("Set TESTING_SAFE_OWNER_KEY (or SAFE_OWNER_PRIVATE_KEY) in .env"); + if (FROM_PROTOCOL_NAME === TO_PROTOCOL_NAME) { + console.warn("Source and target protocols are the same; only the pool parameter/range will change."); + } + + const provider = ethers.provider; + const manager = await ethers.getContractAt("SafeYieldManager", MANAGER_ADDRESS); + + let source: PoolInfo; + let sourceTickLower: number; + let sourceTickUpper: number; + let liquidity: bigint; + + if (FROM_PROTOCOL_NAME === "univ4") { + const pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, UNIV4_PM_ABI, provider); + const owner: string = await pm.ownerOf(TOKEN_ID); + if (owner.toLowerCase() !== SAFE_ADDRESS.toLowerCase()) { + throw new Error(`Token ${TOKEN_ID} is owned by ${owner}, not SAFE_ADDRESS ${SAFE_ADDRESS}`); + } + const [key, info] = await pm.getPoolAndPositionInfo(TOKEN_ID); + const currency0Ok = + key.currency0 === ethers.ZeroAddress || key.currency0.toLowerCase() === WETH_ADDRESS.toLowerCase(); + if (!currency0Ok || key.currency1.toLowerCase() !== USDC_ADDRESS.toLowerCase()) { + throw new Error(`Token ${TOKEN_ID} is not an ETH/USDC or WETH/USDC V4 position`); + } + const poolParam = encodeUniV4PoolParam(key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks); + source = await resolveV4Pool(poolParam, Number(key.tickSpacing)); + ({ tickLower: sourceTickLower, tickUpper: sourceTickUpper } = unpackV4PositionTicks(BigInt(info))); + liquidity = await pm.getPositionLiquidity(TOKEN_ID); + } else { + const sourceNpmAddress = + FROM_PROTOCOL_NAME === "aerodrome" ? AERODROME_SLIPSTREAM_NPM_ADDRESS : UNISWAP_V3_NPM_ADDRESS; + const sourceNpm = new ethers.Contract( + sourceNpmAddress, + FROM_PROTOCOL_NAME === "aerodrome" ? AERO_NPM_ABI : UNIV3_NPM_ABI, + provider, + ); + const owner: string = await sourceNpm.ownerOf(TOKEN_ID); + const position = await sourceNpm.positions(TOKEN_ID); + const token0: string = position[2]; + const token1: string = position[3]; + // A staked Aerodrome position is owned by its gauge, not the Safe; + // switchLp's close leg unstakes it on-chain. Accept the Safe OR the + // pool's gauge as owner. + let ownerOk = owner.toLowerCase() === SAFE_ADDRESS.toLowerCase(); + if (!ownerOk && FROM_PROTOCOL_NAME === "aerodrome") { + const clFactory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, AERO_FACTORY_ABI, provider); + const srcPool = await clFactory.getPool(token0, token1, Number(position[4])); + const voter = new ethers.Contract( + AERODROME_VOTER_ADDRESS, + ["function gauges(address) view returns (address)"], + provider, + ); + const gauge: string = await voter.gauges(srcPool); + ownerOk = gauge !== ethers.ZeroAddress && owner.toLowerCase() === gauge.toLowerCase(); + if (ownerOk) console.log(`- Source position is STAKED in gauge ${gauge} (switch will unstake it)`); + } + if (!ownerOk) { + throw new Error(`Token ${TOKEN_ID} is owned by ${owner}, not the Safe or its gauge`); + } + if ( + token0.toLowerCase() !== WETH_ADDRESS.toLowerCase() || + token1.toLowerCase() !== USDC_ADDRESS.toLowerCase() + ) { + throw new Error(`Token ${TOKEN_ID} is not a WETH/USDC position`); + } + source = await resolvePool(FROM_PROTOCOL_NAME, Number(position[4])); + sourceTickLower = Number(position[5]); + sourceTickUpper = Number(position[6]); + liquidity = BigInt(position[7]); + } + if (liquidity === 0n) throw new Error(`Token ${TOKEN_ID} has zero liquidity`); + + const targetValue = TO_PROTOCOL_NAME === "aerodrome" ? TARGET_TICK_SPACING : TARGET_FEE_TIER; + const target = await resolvePool(TO_PROTOCOL_NAME, targetValue); + + const withdrawn = amountsForLiquidity(source.sqrtPriceX96, sourceTickLower, sourceTickUpper, liquidity); + // The switch is in kind, so the withdraw leg's floors ARE the budget the + // destination mint has to work with. + const decrease = decreaseMinimums( + { sqrtPriceX96: source.sqrtPriceX96, tickLower: sourceTickLower, tickUpper: sourceTickUpper }, + liquidity, + 10_000n, + DECREASE_SLIPPAGE_BPS, + ); + const targetTickRange = TARGET_TICK_RANGE || target.tickSpacing * 10; + const targetAlignedTick = alignTick(target.tick, target.tickSpacing); + const tickLower = alignTick(targetAlignedTick - targetTickRange, target.tickSpacing); + const tickUpper = alignTick(targetAlignedTick + targetTickRange, target.tickSpacing); + if (tickLower >= tickUpper) throw new Error("Target tick range is invalid"); + + if (!(await manager.protocolEnabledForClose(source.protocol))) + throw new Error("Source protocol is disabled for close"); + if (!(await manager.protocolEnabledForOpen(target.protocol))) + throw new Error("Target protocol is disabled for open"); + const pinnedHandler: string = await manager.positionHandlerOf(source.protocol, TOKEN_ID); + if (pinnedHandler === ethers.ZeroAddress) throw new Error(`Token ${TOKEN_ID} is not managed by SafeYieldManager`); + + // Size the mint floors from the WORST accepted withdrawal, never from the + // optimistic estimate: a transaction that satisfies the withdraw leg must + // not then revert on the open leg. + const mint = switchMintMinimums( + { sqrtPriceX96: target.sqrtPriceX96, tickLower, tickUpper }, + decrease.amount0Min, + decrease.amount1Min, + MINT_SLIPPAGE_BPS, + ); + + const block = await provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp) + 1_200n; + const params = { + onBehalfOf: SAFE_ADDRESS, + tokenId: TOKEN_ID, + decreaseAmount0Min: decrease.amount0Min, + decreaseAmount1Min: decrease.amount1Min, + tickLower, + tickUpper, + mintAmount0Min: mint.amount0Min, + mintAmount1Min: mint.amount1Min, + lpPoolParam: target.poolParam, + deadline, + }; + + console.log("Configuration:"); + console.log("- SafeYieldManager:", MANAGER_ADDRESS); + console.log("- Safe:", SAFE_ADDRESS); + console.log("- Source:", FROM_PROTOCOL_NAME, `(id ${source.protocol})`, "pool", source.poolAddress); + console.log("- Target:", TO_PROTOCOL_NAME, `(id ${target.protocol})`, "pool", target.poolAddress); + console.log("- Token id:", TOKEN_ID.toString()); + console.log("- Source range:", sourceTickLower, "..", sourceTickUpper, "| liquidity:", liquidity.toString()); + console.log("- Target range:", tickLower, "..", tickUpper); + console.log("- Estimated withdrawal (WETH/ETH):", ethers.formatEther(withdrawn.amount0)); + console.log("- Estimated withdrawal (USDC):", ethers.formatUnits(withdrawn.amount1, 6)); + console.log( + "- Decrease minimums (token0/token1):", + decrease.amount0Min.toString(), + "/", + decrease.amount1Min.toString(), + ); + console.log("- Mint minimums (token0/token1):", mint.amount0Min.toString(), "/", mint.amount1Min.toString()); + console.log("- Deadline:", deadline.toString()); + + const switchLpData = manager.interface.encodeFunctionData("switchLp", [source.protocol, target.protocol, params]); + if (DRY_RUN) { + console.log("\nDRY_RUN — switchLp calldata:"); + console.log(switchLpData); + return; + } + + const rpcUrl = (network.config as { url?: string }).url; + if (!rpcUrl) throw new Error(`Network ${network.name} has no RPC url — run with --network base`); + const safeWallet = await Safe.init({ provider: rpcUrl, signer: OWNER_KEY, safeAddress: SAFE_ADDRESS }); + const threshold = await safeWallet.getThreshold(); + if (threshold > 1) throw new Error("Safe threshold is greater than 1; use DRY_RUN and propose through the Safe UI"); + if (!(await safeWallet.isModuleEnabled(MANAGER_ADDRESS))) { + throw new Error("SafeYieldManager is not enabled as a module on this Safe"); + } + + const transactions: MetaTransactionData[] = [ + { to: MANAGER_ADDRESS, value: "0", data: switchLpData, operation: OperationType.Call }, + ]; + const safeTransaction = await safeWallet.createTransaction({ transactions }); + const result = await safeWallet.executeTransaction(safeTransaction); + console.log("Submitted:", result.hash); + + const receipt = await waitForReceipt(provider, result.hash); + console.log("Confirmed in block", receipt.blockNumber); + + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== MANAGER_ADDRESS.toLowerCase()) continue; + try { + const parsed = manager.interface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed?.name === "PositionSwitched") { + console.log("PositionSwitched — old token:", parsed.args.oldTokenId.toString()); + console.log("- New token:", parsed.args.newTokenId.toString()); + console.log("- Carried basis (USD):", ethers.formatUnits(parsed.args.carriedBasisUsd6, 6)); + console.log("- Withdrawn:", parsed.args.withdrawn0.toString(), "/", parsed.args.withdrawn1.toString()); + console.log("- Deployed:", parsed.args.used0.toString(), "/", parsed.args.used1.toString()); + // In kind: no swaps, no performance fee — the basis carries over unchanged. + return; + } + } catch { + // Ignore unrelated manager logs. + } + } + console.log("No PositionSwitched event found in the receipt — check the tx on Basescan."); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/syncDeploymentAddresses.js b/scripts/syncDeploymentAddresses.js new file mode 100644 index 0000000..00c9564 --- /dev/null +++ b/scripts/syncDeploymentAddresses.js @@ -0,0 +1,25 @@ +const fs = require("fs"); +const path = require("path"); + +const chainId = process.env.CHAIN_ID || "8453"; +const networkNames = { 8453: "base", 84532: "base-sepolia" }; +const network = networkNames[chainId] || `chain-${chainId}`; +const root = path.resolve(__dirname, ".."); +// Same override convention as scripts/lpSafeShared.ts's deployedManagerAddress(): a deploy under a +// non-default --deployment-id (e.g. a version bump kept separate from the chain-${chainId} default) +// must be synced from THAT id, or this silently writes the stale default generation's addresses +// back over the reviewable deployments/${network}.json manifest. +const deploymentId = process.env.IGNITION_DEPLOYMENT_ID || `chain-${chainId}`; +const source = path.join(root, "ignition", "deployments", deploymentId, "deployed_addresses.json"); +const outputDir = path.join(root, "deployments"); +const output = path.join(outputDir, `${network}.json`); + +if (!fs.existsSync(source)) { + throw new Error(`Ignition deployment addresses not found: ${source}`); +} + +const contracts = JSON.parse(fs.readFileSync(source, "utf8")); +const manifest = { chainId: Number(chainId), network, contracts }; +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`Synced ${Object.keys(contracts).length} deployment addresses to ${output}`); diff --git a/scripts/syncRegistryAddress.js b/scripts/syncRegistryAddress.js index 831a7c2..ef969ea 100644 --- a/scripts/syncRegistryAddress.js +++ b/scripts/syncRegistryAddress.js @@ -2,7 +2,7 @@ // to the ProtocolRegistry address deployed by 0_DeployRegistryOnly.ts. // // Runs automatically after registry/core deploys so the -// `yarn deploy:2_univ3_helper` fallback (which reads PROTOCOL_REGISTRY_ADDRESS) +// `yarn deploy:2_yield_manager` fallback (which reads PROTOCOL_REGISTRY_ADDRESS) // always points at the configured registry — a dev can't forget to bump // it manually. // diff --git a/scripts/timelockUpdateOperator.ts b/scripts/timelockUpdateOperator.ts index 37e389c..0be4a20 100644 --- a/scripts/timelockUpdateOperator.ts +++ b/scripts/timelockUpdateOperator.ts @@ -6,7 +6,7 @@ import { ethers } from "hardhat"; * `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 (8 hours by default) + * 2. Wait for the timelock delay (2 days by default) * 3. Execute the operation (requires EXECUTOR_ROLE) * * Usage: @@ -30,9 +30,7 @@ async function main() { 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" - ); + throw new Error("Please set TIMELOCK_ADDRESS, PROTOCOL_REGISTRY_ADDRESS, and NEW_OPERATOR_ADDRESS"); } if (!ethers.isAddress(NEW_OPERATOR_ADDRESS)) { @@ -103,7 +101,6 @@ async function main() { 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"); @@ -115,9 +112,7 @@ async function main() { 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()}` - ); + 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)"); } diff --git a/scripts/timelockUpdateParaswap.ts b/scripts/timelockUpdateParaswap.ts index 5c2aa59..a0cad7b 100644 --- a/scripts/timelockUpdateParaswap.ts +++ b/scripts/timelockUpdateParaswap.ts @@ -6,7 +6,7 @@ import { ethers } from "hardhat"; * * This demonstrates the two-step process: * 1. Schedule the operation (requires PROPOSER_ROLE) - * 2. Wait for the timelock delay (8 hours by default) + * 2. Wait for the timelock delay (2 days by default) * 3. Execute the operation (requires EXECUTOR_ROLE) * * Usage: @@ -30,9 +30,7 @@ async function main() { const OPERATION_ID = process.env.OPERATION_ID || "paraswap-update-" + Date.now(); if (!TIMELOCK_ADDRESS || !PROTOCOL_REGISTRY_ADDRESS || !NEW_PARASWAP_ADDRESS) { - throw new Error( - "Please set TIMELOCK_ADDRESS, PROTOCOL_REGISTRY_ADDRESS, and NEW_PARASWAP_ADDRESS" - ); + throw new Error("Please set TIMELOCK_ADDRESS, PROTOCOL_REGISTRY_ADDRESS, and NEW_PARASWAP_ADDRESS"); } console.log("Configuration:"); @@ -96,7 +94,6 @@ async function main() { console.log(` NEW_PARASWAP_ADDRESS=${NEW_PARASWAP_ADDRESS} \\`); console.log(` OPERATION_ID="${OPERATION_ID}" \\`); console.log(" npx hardhat run scripts/timelock-update-paraswap.ts --network base"); - } else { // STEP 2: Execute the operation console.log("\n=== EXECUTING OPERATION ===\n"); @@ -108,9 +105,7 @@ async function main() { 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()}` - ); + 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)"); } diff --git a/scripts/verifyAll.ts b/scripts/verifyAll.ts index 920212e..4889119 100644 --- a/scripts/verifyAll.ts +++ b/scripts/verifyAll.ts @@ -28,8 +28,25 @@ const CHAIN_CONFIG: Record = { }; /** - * Parse the Ignition journal to extract constructor args for each deployed contract. + * Ignition serializes `bigint` constructor args in the journal as tagged objects + * (`{ _kind: "bigint", value: "10000" }`) rather than JSON numbers. Passing those + * straight to hardhat-verify's ABI encoder throws "invalid BigNumber value" / + * "[object Object] cannot be encoded". Recursively rehydrate them to native + * bigints (and walk arrays/objects) so numeric uint256 args encode correctly. */ +function reviveIgnitionValues(value: any): any { + if (Array.isArray(value)) return value.map(reviveIgnitionValues); + if (value && typeof value === "object") { + if (value._kind === "bigint" && typeof value.value === "string") { + return BigInt(value.value); + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = reviveIgnitionValues(v); + return out; + } + return value; +} + async function getConstructorArgsFromJournal(journalPath: string): Promise> { const result: Record = {}; const fileStream = fs.createReadStream(journalPath); @@ -40,7 +57,7 @@ async function getConstructorArgsFromJournal(journalPath: string): Promise { + snapshot = await takeSnapshot(); + // Fund the signer wallet (TESTING_SAFE_OWNER_KEY) with ETH for gas fees await fundSignerWithETH(signer.address); @@ -52,6 +62,8 @@ describe("SafeExecTransactionWrapper", function () { }); this.afterEach(async () => { + await snapshot.restore(); + // Force garbage collection to free memory if (global.gc) { global.gc(); @@ -60,8 +72,8 @@ describe("SafeExecTransactionWrapper", function () { await new Promise((resolve) => setTimeout(resolve, 100)); }); - async function sendCollateralToSafe(tokenAddress = cbETH_ADDRESS, protocol?: Protocols) { - if (tokenAddress === WETH_ADDRESS && protocol === Protocols.FLUID) { + async function sendCollateralToSafe(tokenAddress = cbETH_ADDRESS, protocol?: DebtProtocols) { + if (tokenAddress === WETH_ADDRESS && protocol === DebtProtocols.FLUID) { // Send ETH directly to Safe for WETH only for Fluid protocol const tx = await signer.sendTransaction({ to: safeAddress, @@ -69,9 +81,7 @@ describe("SafeExecTransactionWrapper", function () { }); await tx.wait(); } else { - const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, signer); - const tx = await tokenContract.transfer(safeAddress, ethers.parseEther("0.001")); - await tx.wait(); + await dealTokenAmount(tokenAddress, safeAddress, DEFAULT_SUPPLY_AMOUNT); } } @@ -80,7 +90,7 @@ describe("SafeExecTransactionWrapper", function () { collateralTokenAddress = cbETH_ADDRESS, supplyAmount = ethers.parseEther(DEFAULT_SUPPLY_AMOUNT), ) { - await sendCollateralToSafe(collateralTokenAddress, Protocols.FLUID); + await sendCollateralToSafe(collateralTokenAddress, DebtProtocols.FLUID); const collateralTokenContract = new ethers.Contract(collateralTokenAddress, ERC20_ABI, signer); const fluidVault = new ethers.Contract(vaultAddress, FluidVaultAbi, signer); diff --git a/test/contractUpgrade.ts b/test/debt/contractUpgrade.ts similarity index 76% rename from test/contractUpgrade.ts rename to test/debt/contractUpgrade.ts index dd573a2..811ea4d 100644 --- a/test/contractUpgrade.ts +++ b/test/debt/contractUpgrade.ts @@ -4,11 +4,12 @@ import { ethers, upgrades } from "hardhat"; import "dotenv/config"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { LeveragedPosition } from "../typechain-types"; -import morphoAbi from "../externalAbi/morpho/morpho.json"; +import { LeveragedPosition } from "../../typechain-types"; +import morphoAbi from "../../externalAbi/morpho/morpho.json"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; -import { approve, getDecimals, getParaswapData, protocolHelperMap } from "./utils"; -import { Protocols } from "./constants"; +import { approve, getDecimals, getParaswapData } from "../helpers/utils"; +import { protocolHelperMap } from "../helpers/protocolHelperMap"; +import { DebtProtocols } from "../helpers/constants"; describe.skip("Upgrade contract", function () { let impersonatedSigner: HardhatEthersSigner; @@ -19,7 +20,7 @@ describe.skip("Upgrade contract", function () { const SafeDebtManager = await ethers.getContractFactory("SafeDebtManagerUpgradeable"); // Prepare constructor arguments for initialize - const protocols = [Protocols.AAVE_V3]; + const protocols = [DebtProtocols.AAVE_V3]; const handlers = ["0x123"]; // Deploy as upgradeable using UUPS proxy diff --git a/test/createLeveragedPosition.ts b/test/debt/createLeveragedPosition.ts similarity index 86% rename from test/createLeveragedPosition.ts rename to test/debt/createLeveragedPosition.ts index 3d45a5c..2dd49e4 100644 --- a/test/createLeveragedPosition.ts +++ b/test/debt/createLeveragedPosition.ts @@ -1,13 +1,20 @@ -import { time, loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { + time, + loadFixture, + SnapshotRestorer, + takeSnapshot, + clearSnapshots, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; const { expect } = require("chai"); import { ethers } from "hardhat"; import "dotenv/config"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { LeveragedPosition } from "../typechain-types"; -import morphoAbi from "../externalAbi/morpho/morpho.json"; +import { LeveragedPosition } from "../../typechain-types"; +import morphoAbi from "../../externalAbi/morpho/morpho.json"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; -import { approve, fundSignerWithETH, getDecimals, getParaswapData, protocolHelperMap } from "./utils"; +import { approve, fundSignerWithETH, getDecimals, getParaswapData } from "../helpers/utils"; +import { protocolHelperMap } from "../helpers/protocolHelperMap"; import { USDC_ADDRESS, @@ -15,17 +22,17 @@ import { cbETH_ADDRESS, TEST_ADDRESS, TEST_FEE_BENEFICIARY_ADDRESS, - Protocols, + DebtProtocols, WETH_ADDRESS, DEFAULT_SUPPLY_AMOUNT, cbETH_ETH_POOL, cbBTC_ADDRESS, cbBTC_USDC_POOL, ETH_USDC_POOL, -} from "./constants"; +} from "../helpers/constants"; -import { AaveV3Helper } from "./protocols/aaveV3"; -import { cometAddressMap, CompoundHelper } from "./protocols/compound"; +import { AaveV3Helper } from "../helpers/protocolsDebt/aaveV3"; +import { cometAddressMap, CompoundHelper } from "../helpers/protocolsDebt/compound"; import { MORPHO_ADDRESS, MorphoHelper, @@ -34,8 +41,8 @@ import { morphoMarket5Id, morphoMarket6Id, morphoMarket7Id, -} from "./protocols/morpho"; -import { deployLeveragedPositionContractFixture } from "./deployUtils"; +} from "../helpers/protocolsDebt/morpho"; +import { deployLeveragedPositionContractFixture } from "../helpers/deployUtils"; describe("Create leveraged position", function () { let myContract: LeveragedPosition; @@ -45,10 +52,21 @@ describe("Create leveraged position", function () { let aaveV3Helper: AaveV3Helper; let compoundHelper: CompoundHelper; let morphoHelper: MorphoHelper; + let suiteSnapshot: SnapshotRestorer; const defaultTargetSupplyAmount = "0.002"; const cbBTCPrincipleAmount = 0.00006; + this.beforeAll(async () => { + await clearSnapshots(); + suiteSnapshot = await takeSnapshot(); + }); + + this.afterAll(async () => { + await suiteSnapshot.restore(); + await clearSnapshots(); + }); + this.beforeEach(async () => { impersonatedSigner = await ethers.getImpersonatedSigner(TEST_ADDRESS); @@ -67,12 +85,13 @@ describe("Create leveraged position", function () { async function createLeveragedPosition( flashloanPool: string, - protocol: Protocols, + protocol: DebtProtocols, collateralAddress = cbETH_ADDRESS, debtTokenAddress = USDC_ADDRESS, principleAmount = Number(DEFAULT_SUPPLY_AMOUNT), targetAmount = Number(defaultTargetSupplyAmount), morphoMarketId?: string, + preferredDEX?: string, ) { const Helper = protocolHelperMap.get(protocol)!; const protocolHelper = new Helper(impersonatedSigner); @@ -93,13 +112,13 @@ describe("Create leveraged position", function () { ); switch (protocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: await aaveV3Helper.approveDelegation(debtAsset, deployedContractAddress); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: await compoundHelper.allow(debtAsset, deployedContractAddress); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: const morphoContract = new ethers.Contract(MORPHO_ADDRESS, morphoAbi, impersonatedSigner); await morphoContract.setAuthorization(deployedContractAddress, true); break; @@ -108,10 +127,10 @@ describe("Create leveraged position", function () { let extraData = "0x"; switch (protocol) { - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: extraData = compoundHelper.encodeExtraData(cometAddressMap.get(debtAsset)!); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: extraData = morphoHelper.encodeExtraData(morphoMarketId!, BigInt(0)); break; } @@ -120,7 +139,14 @@ describe("Create leveraged position", function () { const diffAmount = parsedTargetAmount - ethers.parseUnits(principleAmount.toString(), collateralDecimals); - const paraswapData = await getParaswapData(collateralAddress, debtAsset, deployedContractAddress, diffAmount); + const paraswapData = await getParaswapData( + collateralAddress, + debtAsset, + deployedContractAddress, + diffAmount, + 1n, + preferredDEX, + ); await myContract.createLeveragedPosition( flashloanPool, @@ -134,22 +160,22 @@ describe("Create leveraged position", function () { paraswapData, ); - const debtAmountParameter = protocol === Protocols.MORPHO ? morphoMarketId! : debtAsset; + const debtAmountParameter = protocol === DebtProtocols.MORPHO ? morphoMarketId! : debtAsset; const debtAmount = await protocolHelper.getDebtAmount(debtAmountParameter); console.log("debtAmount: ", ethers.formatUnits(debtAmount, debtDecimals)); let collateralAmount: bigint; switch (protocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: collateralAmount = await aaveV3Helper.getCollateralAmount(collateralAddress); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: collateralAmount = await compoundHelper.getCollateralAmount( cometAddressMap.get(debtAsset)!, collateralAddress, ); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: collateralAmount = await morphoHelper.getCollateralAmount(morphoMarketId!); break; default: @@ -173,7 +199,7 @@ describe("Create leveraged position", function () { async function deleveragePosition( flashloanPool: string, - protocol: Protocols, + protocol: DebtProtocols, collateralAddress = cbETH_ADDRESS, debtTokenAddress = USDC_ADDRESS, morphoMarketId?: string, @@ -188,27 +214,27 @@ describe("Create leveraged position", function () { const debtDecimals = await getDecimals(debtAsset); // Get current debt amount before closing - const debtAmountParameter = protocol === Protocols.MORPHO ? morphoMarketId! : debtAsset; + const debtAmountParameter = protocol === DebtProtocols.MORPHO ? morphoMarketId! : debtAsset; const debtAmountFull = await protocolHelper.getDebtAmount(debtAmountParameter); console.log("Debt amount before closing: ", ethers.formatUnits(debtAmountFull, debtDecimals)); // Get current collateral amount before closing let collateralAmountFull: bigint; switch (protocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: collateralAmountFull = await aaveV3Helper.getCollateralAmount(collateralAddress); // Approve aToken to the contract for withdrawal const aTokenAddress = await aaveV3Helper.getATokenAddress(collateralAddress); await approve(aTokenAddress, deployedContractAddress, impersonatedSigner); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: collateralAmountFull = await compoundHelper.getCollateralAmount( cometAddressMap.get(debtAsset)!, collateralAddress, ); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: collateralAmountFull = await morphoHelper.getCollateralAmount(morphoMarketId!); break; default: @@ -228,10 +254,10 @@ describe("Create leveraged position", function () { let extraData = "0x"; switch (protocol) { - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: extraData = compoundHelper.encodeExtraData(cometAddressMap.get(debtAsset)!); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: // Fetch borrowShares for full repayment const borrowShares = await morphoHelper.getBorrowShares(morphoMarketId!); console.log("Morpho borrowShares for repayment:", borrowShares.toString()); @@ -289,16 +315,16 @@ describe("Create leveraged position", function () { let collateralAmountAfter: bigint; switch (protocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: collateralAmountAfter = await aaveV3Helper.getCollateralAmount(collateralAddress); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: collateralAmountAfter = await compoundHelper.getCollateralAmount( cometAddressMap.get(debtAsset)!, collateralAddress, ); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: collateralAmountAfter = await morphoHelper.getCollateralAmount(morphoMarketId!); break; default: @@ -351,11 +377,11 @@ describe("Create leveraged position", function () { describe("on Aave", function () { it("create and close position with cbETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.AAVE_V3); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.AAVE_V3); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.AAVE_V3); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.AAVE_V3); }); it("create position with cbETH collateral and protocol fee", async function () { @@ -375,7 +401,7 @@ describe("Create leveraged position", function () { // Record fee beneficiary's USDC balance before const beneficiaryUsdcBalanceBefore = await usdcContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.AAVE_V3); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.AAVE_V3); // Check fee beneficiary's USDC balance after const beneficiaryUsdcBalanceAfter = await usdcContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); @@ -389,24 +415,33 @@ describe("Create leveraged position", function () { }); it("create and close position with WETH collateral", async function () { - await createLeveragedPosition(ETH_USDC_POOL, Protocols.AAVE_V3, WETH_ADDRESS, USDC_ADDRESS); + await createLeveragedPosition(ETH_USDC_POOL, DebtProtocols.AAVE_V3, WETH_ADDRESS, USDC_ADDRESS); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.AAVE_V3, WETH_ADDRESS, USDC_ADDRESS); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.AAVE_V3, WETH_ADDRESS, USDC_ADDRESS); }); it("partial close position with cbETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.AAVE_V3); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.AAVE_V3); await time.increaseTo((await time.latest()) + 3600); // 1 hour // Partially close 50% of the position - await deleveragePosition(ETH_USDC_POOL, Protocols.AAVE_V3, cbETH_ADDRESS, USDC_ADDRESS, undefined, 50); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.AAVE_V3, cbETH_ADDRESS, USDC_ADDRESS, undefined, 50); }); it("with cbETH collateral and EURC debt", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.AAVE_V3, cbETH_ADDRESS, EURC_ADDRESS); + await createLeveragedPosition( + cbETH_ETH_POOL, + DebtProtocols.AAVE_V3, + cbETH_ADDRESS, + EURC_ADDRESS, + Number(DEFAULT_SUPPLY_AMOUNT), + Number(defaultTargetSupplyAmount), + undefined, + "UniswapV3", + ); }); it("with cbBTC collateral", async function () { @@ -414,7 +449,7 @@ describe("Create leveraged position", function () { await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, @@ -427,7 +462,7 @@ describe("Create leveraged position", function () { await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, @@ -438,23 +473,23 @@ describe("Create leveraged position", function () { describe("on Compoud", function () { it("create and close position with cbETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.COMPOUND); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.COMPOUND); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.COMPOUND); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.COMPOUND); }); // USDbC is no longer available in Compound it.skip("with cbETH collateral and EURC debt", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.COMPOUND, cbETH_ADDRESS, EURC_ADDRESS); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.COMPOUND, cbETH_ADDRESS, EURC_ADDRESS); }); it("with cbBTC collateral", async function () { const targetAmount = cbBTCPrincipleAmount * 2; await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.COMPOUND, + DebtProtocols.COMPOUND, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, @@ -463,11 +498,11 @@ describe("Create leveraged position", function () { }); it("close position with WETH collateral", async function () { - await createLeveragedPosition(ETH_USDC_POOL, Protocols.COMPOUND, WETH_ADDRESS, USDC_ADDRESS); + await createLeveragedPosition(ETH_USDC_POOL, DebtProtocols.COMPOUND, WETH_ADDRESS, USDC_ADDRESS); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.COMPOUND, WETH_ADDRESS, USDC_ADDRESS); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.COMPOUND, WETH_ADDRESS, USDC_ADDRESS); }); }); @@ -475,7 +510,7 @@ describe("Create leveraged position", function () { it("create and close position with cbETH collateral", async function () { await createLeveragedPosition( cbETH_ETH_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, undefined, undefined, undefined, @@ -485,7 +520,7 @@ describe("Create leveraged position", function () { await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.MORPHO, cbETH_ADDRESS, USDC_ADDRESS, morphoMarket1Id); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.MORPHO, cbETH_ADDRESS, USDC_ADDRESS, morphoMarket1Id); }); it("with cbETH collateral and protocol fee", async function () { @@ -507,7 +542,7 @@ describe("Create leveraged position", function () { await createLeveragedPosition( cbETH_ETH_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, undefined, undefined, undefined, @@ -530,7 +565,7 @@ describe("Create leveraged position", function () { const targetAmount = cbBTCPrincipleAmount * 2; await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, @@ -542,7 +577,7 @@ describe("Create leveraged position", function () { it("with USDC collateral and WETH debt", async function () { await createLeveragedPosition( ETH_USDC_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, USDC_ADDRESS, WETH_ADDRESS, 10, // Increased from 1 to 10 USDC to avoid Paraswap edge case with small amounts @@ -571,7 +606,7 @@ describe("Create leveraged position", function () { // Create leveraged position with USDC collateral (6 decimals) and WETH debt (18 decimals) await createLeveragedPosition( ETH_USDC_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, USDC_ADDRESS, WETH_ADDRESS, 10, @@ -595,7 +630,7 @@ describe("Create leveraged position", function () { it("close position with WETH collateral", async function () { await createLeveragedPosition( ETH_USDC_POOL, - Protocols.MORPHO, + DebtProtocols.MORPHO, WETH_ADDRESS, USDC_ADDRESS, undefined, @@ -605,7 +640,7 @@ describe("Create leveraged position", function () { await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.MORPHO, WETH_ADDRESS, USDC_ADDRESS, morphoMarket7Id); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.MORPHO, WETH_ADDRESS, USDC_ADDRESS, morphoMarket7Id); }); }); @@ -719,7 +754,7 @@ describe("Create leveraged position", function () { it("revert if flashloan pool is not uniswap v3 pool", async function () { await expect( - createLeveragedPosition(USDC_ADDRESS, Protocols.MORPHO, USDC_ADDRESS, WETH_ADDRESS, 1, 2, morphoMarket6Id), + createLeveragedPosition(USDC_ADDRESS, DebtProtocols.MORPHO, USDC_ADDRESS, WETH_ADDRESS, 1, 2, morphoMarket6Id), ).to.be.revertedWith("Invalid flashloan pool address"); }); }); diff --git a/test/createLeveragedPositionBySafe.ts b/test/debt/createLeveragedPositionBySafe.ts similarity index 89% rename from test/createLeveragedPositionBySafe.ts rename to test/debt/createLeveragedPositionBySafe.ts index a62fa8a..3907b6f 100644 --- a/test/createLeveragedPositionBySafe.ts +++ b/test/debt/createLeveragedPositionBySafe.ts @@ -1,17 +1,24 @@ -import { time, loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { + time, + loadFixture, + SnapshotRestorer, + takeSnapshot, + clearSnapshots, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; const { expect } = require("chai"); import { ethers } from "hardhat"; import "dotenv/config"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { LeveragedPosition } from "../typechain-types"; +import { LeveragedPosition } from "../../typechain-types"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; -import { eip1193Provider, fundSignerWithETH, getDecimals, getParaswapData, protocolHelperMap } from "./utils"; +import { dealToken, eip1193Provider, fundSignerWithETH, getDecimals, getParaswapData } from "../helpers/utils"; +import { protocolHelperMap } from "../helpers/protocolHelperMap"; import Safe from "@safe-global/protocol-kit"; import { USDC_ADDRESS, cbETH_ADDRESS, TEST_ADDRESS, - Protocols, + DebtProtocols, WETH_ADDRESS, DEFAULT_SUPPLY_AMOUNT, cbETH_ETH_POOL, @@ -19,13 +26,13 @@ import { cbBTC_USDC_POOL, ETH_USDC_POOL, ETH_USDbC_POOL, -} from "./constants"; +} from "../helpers/constants"; import { MaxUint256 } from "ethers"; -import { deployLeveragedPositionContractFixture } from "./deployUtils"; -import { mContractAddressMap, MoonwellHelper, COMPTROLLER_ADDRESS } from "./protocols/moonwell"; -import { safeAddress } from "./safeTestContext"; +import { deployLeveragedPositionContractFixture } from "../helpers/deployUtils"; +import { mContractAddressMap, MoonwellHelper, COMPTROLLER_ADDRESS } from "../helpers/protocolsDebt/moonwell"; +import { safeAddress } from "../helpers/safeTestContext"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; -import { fluidVaultMap, FluidHelper } from "./protocols/fluid"; +import { fluidVaultMap, FluidHelper } from "../helpers/protocolsDebt/fluid"; describe("Create leveraged position by Safe", function () { this.timeout(3000000); // 50 minutes @@ -35,13 +42,27 @@ describe("Create leveraged position by Safe", function () { let deployedContractAddress: string; - const defaultTargetSupplyAmount = "0.002"; + // 2x leverage, derived so it cannot drift from the principle: the swap leg is + // `target - principle`, and a target below the principle makes that negative — which Paraswap + // rejects as "Invalid Amount" rather than anything that points back at the constants. + const defaultTargetSupplyAmount = String(Number(DEFAULT_SUPPLY_AMOUNT) * 2); const cbBTCPrincipleAmount = 0.00006; let safeWallet; let operator: HardhatEthersSigner; + let suiteSnapshot: SnapshotRestorer; const safeOwnerWallet = new ethers.Wallet(process.env.TESTING_SAFE_OWNER_KEY!, ethers.provider); + this.beforeAll(async () => { + await clearSnapshots(); + suiteSnapshot = await takeSnapshot(); + }); + + this.afterAll(async () => { + await suiteSnapshot.restore(); + await clearSnapshots(); + }); + this.beforeEach(async () => { impersonatedSigner = await ethers.getImpersonatedSigner(TEST_ADDRESS); @@ -79,7 +100,7 @@ describe("Create leveraged position by Safe", function () { async function createLeveragedPosition( flashloanPool: string, - protocol: Protocols, + protocol: DebtProtocols, collateralAddress = cbETH_ADDRESS, debtAddress = USDC_ADDRESS, principleAmount = Number(DEFAULT_SUPPLY_AMOUNT), @@ -98,7 +119,7 @@ describe("Create leveraged position by Safe", function () { let extraData = "0x"; switch (protocol) { - case Protocols.FLUID: + case DebtProtocols.FLUID: const vaultAddress = fluidVaultMap.get(collateralAddress)!; // Encode with isFullRepay = false for Fluid create operation extraData = ethers.AbiCoder.defaultAbiCoder().encode( @@ -115,11 +136,11 @@ describe("Create leveraged position by Safe", function () { const paraswapData = await getParaswapData(collateralAddress, debtAddress, deployedContractAddress, diffAmount); // send collateral token to safe - const tx = await collateralContract.transfer( + await dealToken( + collateralAddress, safeAddress, ethers.parseUnits(principleAmount.toString(), collateralDecimals), ); - await tx.wait(); const approveTransactionData: MetaTransactionData = { to: collateralAddress, @@ -163,14 +184,15 @@ describe("Create leveraged position by Safe", function () { }); console.log(safeTxHash); - const addressForDebtAmount = protocol === Protocols.FLUID ? fluidVaultMap.get(collateralAddress)! : debtAddress; + const addressForDebtAmount = + protocol === DebtProtocols.FLUID ? fluidVaultMap.get(collateralAddress)! : debtAddress; const debtAmount = await protocolHelper.getDebtAmount(addressForDebtAmount, safeAddress); const collateralAmount = await protocolHelper.getCollateralAmount(collateralAddress, safeAddress); // For Fluid protocol, get and log the nftId - if (protocol === Protocols.FLUID) { + if (protocol === DebtProtocols.FLUID) { const fluidHelper = new FluidHelper(impersonatedSigner); const vaultAddress = fluidVaultMap.get(collateralAddress)!; const nftId = await fluidHelper.getNftId(vaultAddress, safeAddress); @@ -187,12 +209,17 @@ describe("Create leveraged position by Safe", function () { const debtToken = new ethers.Contract(debtAddress, ERC20_ABI, impersonatedSigner); const debtRemainingBalance = await debtToken.balanceOf(deployedContractAddress); - expect(Number(debtRemainingBalance)).to.be.equal(0); + if (protocol === DebtProtocols.FLUID) { + // Fluid rejects repayments below 10,000 units, so the handler intentionally leaves that dust unspent. + expect(debtRemainingBalance).to.be.lt(10_000n); + } else { + expect(debtRemainingBalance).to.equal(0n); + } } async function deleveragePosition( flashloanPool: string, - protocol: Protocols, + protocol: DebtProtocols, collateralAddress = cbETH_ADDRESS, debtAddress = USDC_ADDRESS, callViaOperator = false, @@ -209,7 +236,8 @@ describe("Create leveraged position by Safe", function () { const debtDecimals = await getDecimals(debtAddress); // Get current debt and collateral amounts before closing - const addressForDebtAmount = protocol === Protocols.FLUID ? fluidVaultMap.get(collateralAddress)! : debtAddress; + const addressForDebtAmount = + protocol === DebtProtocols.FLUID ? fluidVaultMap.get(collateralAddress)! : debtAddress; const debtAmountBefore = await protocolHelper.getDebtAmount(addressForDebtAmount, safeAddress); console.log("Debt amount before closing: ", ethers.formatUnits(debtAmountBefore, debtDecimals)); @@ -221,7 +249,7 @@ describe("Create leveraged position by Safe", function () { let extraData = "0x"; switch (protocol) { - case Protocols.FLUID: + case DebtProtocols.FLUID: const fluidHelper = new FluidHelper(impersonatedSigner); const vaultAddress = fluidVaultMap.get(collateralAddress)!; const nftIdBefore = await fluidHelper.getNftId(vaultAddress, safeAddress); @@ -298,7 +326,7 @@ describe("Create leveraged position by Safe", function () { // For Moonwell, we need to approve the mToken const transactions: MetaTransactionData[] = []; - if (protocol === Protocols.MOONWELL) { + if (protocol === DebtProtocols.MOONWELL) { const mTokenAddress = mContractAddressMap.get(collateralAddress)!; transactions.push({ to: mTokenAddress, @@ -377,7 +405,7 @@ describe("Create leveraged position by Safe", function () { await fundSignerWithETH(impersonatedSigner.address, "0.5"); // Create position first using Safe - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.FLUID); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.FLUID); // Wait for some time to accrue interest await time.increaseTo((await time.latest()) + 600); // 10 minutes @@ -385,7 +413,7 @@ describe("Create leveraged position by Safe", function () { console.log("Operator wallet address:", operator.address); // Close position via operator - await deleveragePosition(ETH_USDC_POOL, Protocols.FLUID, cbETH_ADDRESS, USDC_ADDRESS, true); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.FLUID, cbETH_ADDRESS, USDC_ADDRESS, true); }); }); @@ -393,7 +421,7 @@ describe("Create leveraged position by Safe", function () { it("create normal position and partially deleverage (repay 20%)", async function () { // Step 1: Create a normal (non-leveraged) position await createNormalPosition( - Protocols.MOONWELL, + DebtProtocols.MOONWELL, cbETH_ADDRESS, USDC_ADDRESS, "0.002", // supply 0.002 cbETH @@ -406,7 +434,7 @@ describe("Create leveraged position by Safe", function () { // Step 2: Partially deleverage - repay 20% of debt await partialDeleveragePosition( ETH_USDC_POOL, - Protocols.MOONWELL, + DebtProtocols.MOONWELL, cbETH_ADDRESS, USDC_ADDRESS, 20n, // repay 20% @@ -415,19 +443,19 @@ describe("Create leveraged position by Safe", function () { }); it("create and close position with cbETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.MOONWELL); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.MOONWELL); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.MOONWELL); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.MOONWELL); }); it("create and close position with WETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.MOONWELL, WETH_ADDRESS, USDC_ADDRESS); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.MOONWELL, WETH_ADDRESS, USDC_ADDRESS); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.MOONWELL, WETH_ADDRESS, USDC_ADDRESS); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.MOONWELL, WETH_ADDRESS, USDC_ADDRESS); }); it("with cbBTC collateral", async function () { @@ -435,31 +463,17 @@ describe("Create leveraged position by Safe", function () { const targetAmount = cbBTCPrincipleAmount * 2; await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.MOONWELL, + DebtProtocols.MOONWELL, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, targetAmount, ); }); - - it("with USDC collateral, cbETH debt", async function () { - const principleAmount = 0.1; - const targetAmount = principleAmount * 2; - - await createLeveragedPosition( - cbBTC_USDC_POOL, - Protocols.MOONWELL, - USDC_ADDRESS, - cbETH_ADDRESS, - principleAmount, - targetAmount, - ); - }); }); async function createNormalPosition( - protocol: Protocols, + protocol: DebtProtocols, collateralAddress: string, debtAddress: string, supplyAmountStr: string, @@ -475,11 +489,10 @@ describe("Create leveraged position by Safe", function () { const borrowAmount = ethers.parseUnits(borrowAmountStr, debtDecimals); // Transfer collateral to Safe - const transferTx = await collateralContract.transfer(safeAddress, supplyAmount); - await transferTx.wait(); + await dealToken(collateralAddress, safeAddress, supplyAmount); console.log("Transferred collateral to Safe"); - if (protocol === Protocols.FLUID) { + if (protocol === DebtProtocols.FLUID) { const vaultAddress = fluidVaultMap.get(collateralAddress)!; // Approve and supply collateral via Safe transaction @@ -537,12 +550,12 @@ describe("Create leveraged position by Safe", function () { console.log("Collateral amount:", ethers.formatUnits(collateralAmountAfter, collateralDecimals)); expect(debtAmountAfter).to.be.gt(0); expect(collateralAmountAfter).to.be.gt(0); - } else if (protocol === Protocols.MOONWELL) { + } else if (protocol === DebtProtocols.MOONWELL) { const mCollateralAddress = mContractAddressMap.get(collateralAddress)!; const mDebtAddress = mContractAddressMap.get(debtAddress)!; - const MErc20DelegatorAbi = require("../externalAbi/moonwell/MErc20Delegator.json"); - const ComptrollerAbi = require("../externalAbi/moonwell/comptroller.json"); + const MErc20DelegatorAbi = require("../../externalAbi/moonwell/MErc20Delegator.json"); + const ComptrollerAbi = require("../../externalAbi/moonwell/comptroller.json"); const mCollateralContract = new ethers.Contract(mCollateralAddress, MErc20DelegatorAbi, impersonatedSigner); const mDebtContract = new ethers.Contract(mDebtAddress, MErc20DelegatorAbi, impersonatedSigner); @@ -599,7 +612,7 @@ describe("Create leveraged position by Safe", function () { async function partialDeleveragePosition( flashloanPool: string, - protocol: Protocols, + protocol: DebtProtocols, collateralAddress: string, debtAddress: string, repayPercentage: bigint, @@ -613,7 +626,7 @@ describe("Create leveraged position by Safe", function () { let collateralAmountBefore: bigint; let extraData: string; - if (protocol === Protocols.FLUID) { + if (protocol === DebtProtocols.FLUID) { const vaultAddress = fluidVaultMap.get(collateralAddress)!; const fluidHelper = new FluidHelper(impersonatedSigner); @@ -628,7 +641,7 @@ describe("Create leveraged position by Safe", function () { ["address", "uint256", "bool"], [vaultAddress, nftId, false], ); - } else if (protocol === Protocols.MOONWELL) { + } else if (protocol === DebtProtocols.MOONWELL) { const moonwellHelper = new MoonwellHelper(impersonatedSigner); debtAmountBefore = await moonwellHelper.getDebtAmount(debtAddress, safeAddress); @@ -723,7 +736,7 @@ describe("Create leveraged position by Safe", function () { let debtAmountAfter: bigint; let collateralAmountAfter: bigint; - if (protocol === Protocols.FLUID) { + if (protocol === DebtProtocols.FLUID) { const vaultAddress = fluidVaultMap.get(collateralAddress)!; const fluidHelper = new FluidHelper(impersonatedSigner); debtAmountAfter = await fluidHelper.getDebtAmount(vaultAddress, safeAddress); @@ -765,7 +778,7 @@ describe("Create leveraged position by Safe", function () { it("create normal position and partially deleverage (repay 20%)", async function () { // Step 1: Create a normal (non-leveraged) position await createNormalPosition( - Protocols.FLUID, + DebtProtocols.FLUID, cbETH_ADDRESS, USDC_ADDRESS, "0.002", // supply 0.002 cbETH @@ -778,7 +791,7 @@ describe("Create leveraged position by Safe", function () { // Step 2: Partially deleverage - repay 20% of debt await partialDeleveragePosition( ETH_USDC_POOL, - Protocols.FLUID, + DebtProtocols.FLUID, cbETH_ADDRESS, USDC_ADDRESS, 20n, // repay 20% @@ -787,19 +800,19 @@ describe("Create leveraged position by Safe", function () { }); it("create and close position with WETH collateral", async function () { - await createLeveragedPosition(ETH_USDbC_POOL, Protocols.FLUID, WETH_ADDRESS, USDC_ADDRESS); + await createLeveragedPosition(ETH_USDbC_POOL, DebtProtocols.FLUID, WETH_ADDRESS, USDC_ADDRESS); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.FLUID, WETH_ADDRESS, USDC_ADDRESS); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.FLUID, WETH_ADDRESS, USDC_ADDRESS); }); it("create and close position with cbETH collateral", async function () { - await createLeveragedPosition(cbETH_ETH_POOL, Protocols.FLUID); + await createLeveragedPosition(cbETH_ETH_POOL, DebtProtocols.FLUID); await time.increaseTo((await time.latest()) + 3600); // 1 hour - await deleveragePosition(ETH_USDC_POOL, Protocols.FLUID); + await deleveragePosition(ETH_USDC_POOL, DebtProtocols.FLUID); }); it("with cbBTC collateral", async function () { @@ -807,7 +820,7 @@ describe("Create leveraged position by Safe", function () { const targetAmount = cbBTCPrincipleAmount * 2; await createLeveragedPosition( cbBTC_USDC_POOL, - Protocols.FLUID, + DebtProtocols.FLUID, cbBTC_ADDRESS, USDC_ADDRESS, cbBTCPrincipleAmount, diff --git a/test/handlers.ts b/test/debt/debtHandlers.ts similarity index 81% rename from test/handlers.ts rename to test/debt/debtHandlers.ts index ff0753a..ddb34e2 100644 --- a/test/handlers.ts +++ b/test/debt/debtHandlers.ts @@ -1,8 +1,8 @@ import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; const { expect } = require("chai"); import { ethers } from "hardhat"; -import { TEST_ADDRESS, USDC_ADDRESS, DAI_ADDRESS, MAI_ADDRESS } from "./constants"; -import { deployHandlers, deployMaliciousUniswapV3Pool } from "./deployUtils"; +import { TEST_ADDRESS, USDC_ADDRESS, DAI_ADDRESS, MAI_ADDRESS } from "../helpers/constants"; +import { deployHandlers, deployMaliciousUniswapV3Pool } from "../helpers/deployUtils"; describe("Handler contracts should", function () { let aaveV3Handler; @@ -29,12 +29,12 @@ describe("Handler contracts should", function () { .reverted; }); - it("CompoundHandler should revert when calling borrow directly with default signer", async function () { + it("CompoundDebtHandler should revert when calling borrow directly with default signer", async function () { await expect(compoundHandler.borrow(USDC_ADDRESS, ethers.parseUnits("1", 6), TEST_ADDRESS, "0x")).to.be .reverted; }); - it("CompoundHandler should revert when calling borrow directly with another signer", async function () { + it("CompoundDebtHandler should revert when calling borrow directly with another signer", async function () { const [, signer1] = await ethers.getSigners(); const compoundHandlerWithSigner1 = compoundHandler.connect(signer1); @@ -42,7 +42,7 @@ describe("Handler contracts should", function () { .be.reverted; }); - it("MoonwellHandler should revert when calling borrow directly with default signer", async function () { + it("MoonwellDebtHandler should revert when calling borrow directly with default signer", async function () { await expect(moonwellHandler.borrow(USDC_ADDRESS, ethers.parseUnits("1", 6), TEST_ADDRESS, "0x")).to.be .reverted; }); @@ -51,7 +51,7 @@ describe("Handler contracts should", function () { await expect(fluidHandler.borrow(USDC_ADDRESS, ethers.parseUnits("1", 6), TEST_ADDRESS, "0x")).to.be.reverted; }); - it("MorphoHandler should revert when calling borrow directly with default signer", async function () { + it("MorphoDebtHandler should revert when calling borrow directly with default signer", async function () { await expect(morphoHandler.borrow(USDC_ADDRESS, ethers.parseUnits("1", 6), TEST_ADDRESS, "0x")).to.be.reverted; }); @@ -73,7 +73,7 @@ describe("Handler contracts should", function () { await expect(maliciousPool.attemptMaliciousBorrow(USDC_ADDRESS, 1000, TEST_ADDRESS)).to.be.reverted; }); - it("should revert malicious pool attempting CompoundHandler borrow", async function () { + it("should revert malicious pool attempting CompoundDebtHandler borrow", async function () { expect(await maliciousPoolCompound.token0()).to.equal(USDC_ADDRESS); expect(await maliciousPoolCompound.fee()).to.equal(3000); expect(await maliciousPoolCompound.targetHandler()).to.equal(await compoundHandler.getAddress()); @@ -81,7 +81,7 @@ describe("Handler contracts should", function () { await expect(maliciousPoolCompound.attemptMaliciousBorrow(USDC_ADDRESS, 1000, TEST_ADDRESS)).to.be.reverted; }); - it("should revert malicious pool attempting CompoundHandler borrow with unregistered token", async function () { + it("should revert malicious pool attempting CompoundDebtHandler borrow with unregistered token", async function () { // Try to borrow MAI which is not registered in Compound protocol await expect(maliciousPoolCompound.attemptMaliciousBorrow(MAI_ADDRESS, 1000, TEST_ADDRESS)).to.be.reverted; }); diff --git a/test/debtSwapBySafe.ts b/test/debt/debtSwapBySafe.ts similarity index 80% rename from test/debtSwapBySafe.ts rename to test/debt/debtSwapBySafe.ts index 2f0cb5b..8ef72f5 100644 --- a/test/debtSwapBySafe.ts +++ b/test/debt/debtSwapBySafe.ts @@ -13,7 +13,7 @@ import { EURC_USDC_POOL, GHO_ADDRESS, GHO_USDC_POOL, - Protocols, + DebtProtocols, sUSDS_ADDRESS, TEST_ADDRESS, TEST_FEE_BENEFICIARY_ADDRESS, @@ -21,22 +21,30 @@ import { ETH_USDC_POOL, WETH_ADDRESS, wstETH_ADDRESS, -} from "./constants"; +} from "../helpers/constants"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; -import cometAbi from "../externalAbi/compound/comet.json"; -import morphoAbi from "../externalAbi/morpho/morpho.json"; +import cometAbi from "../../externalAbi/compound/comet.json"; +import morphoAbi from "../../externalAbi/morpho/morpho.json"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; import { MaxUint256 } from "ethers"; -import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; import { + clearSnapshots, + loadFixture, + setBalance, + SnapshotRestorer, + takeSnapshot, + time, +} from "@nomicfoundation/hardhat-network-helpers"; +import { + dealTokenAmount, eip1193Provider, formatAmount, fundETH, fundSignerWithETH, getDecimals, getParaswapData, - protocolHelperMap, -} from "./utils"; +} from "../helpers/utils"; +import { protocolHelperMap } from "../helpers/protocolHelperMap"; import { FLUID_cbETH_EURC_VAULT, FLUID_cbETH_USDC_VAULT, @@ -44,41 +52,41 @@ import { FLUID_wstETH_sUSDS_VAULT, FLUID_wstETH_USDC_VAULT, FluidHelper, -} from "./protocols/fluid"; -import { cometAddressMap, CompoundHelper, USDC_COMET_ADDRESS } from "./protocols/compound"; -import { MORPHO_ADDRESS, morphoMarket1Id, morphoMarket2Id, morphoMarket7Id, MorphoHelper } from "./protocols/morpho"; -import { AaveV3Helper } from "./protocols/aaveV3"; -import FluidVaultAbi from "../externalAbi/fluid/fluidVaultT1.json"; -import aaveDebtTokenJson from "../externalAbi/aaveV3/aaveDebtToken.json"; -import aaveV3PoolJson from "../externalAbi/aaveV3/aaveV3Pool.json"; +} from "../helpers/protocolsDebt/fluid"; +import { cometAddressMap, CompoundHelper, USDC_COMET_ADDRESS } from "../helpers/protocolsDebt/compound"; +import { + MORPHO_ADDRESS, + morphoMarket1Id, + morphoMarket2Id, + morphoMarket7Id, + MorphoHelper, +} from "../helpers/protocolsDebt/morpho"; +import { AaveV3Helper } from "../helpers/protocolsDebt/aaveV3"; +import FluidVaultAbi from "../../externalAbi/fluid/fluidVaultT1.json"; +import aaveDebtTokenJson from "../../externalAbi/aaveV3/aaveDebtToken.json"; +import aaveV3PoolJson from "../../externalAbi/aaveV3/aaveV3Pool.json"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { expect } from "chai"; -import { deploySafeContractFixture } from "./deployUtils"; +import { deploySafeContractFixture } from "../helpers/deployUtils"; import { zeroAddress } from "viem"; -import { safeAddress } from "./safeTestContext"; +import { safeAddress } from "../helpers/safeTestContext"; // Export helper functions for reuse in other test files export function createSafeTestHelpers(context: { signer: ethers.Wallet; safeWallet: any; safeModuleAddress: string }) { const { signer, safeWallet, safeModuleAddress } = context; - async function sendCollateralToSafe(tokenAddress = cbETH_ADDRESS, protocol?: Protocols) { - if (tokenAddress === WETH_ADDRESS && protocol === Protocols.FLUID) { + async function sendCollateralToSafe(tokenAddress = cbETH_ADDRESS, protocol?: DebtProtocols) { + if (tokenAddress === WETH_ADDRESS && protocol === DebtProtocols.FLUID) { // Send ETH directly to Safe for WETH only for Fluid protocol - const tx = await signer.sendTransaction({ - to: safeAddress, - value: ethers.parseEther("0.001"), - }); - await tx.wait(); + await setBalance(safeAddress, ethers.parseEther("1")); } else { - const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, signer); - const tx = await tokenContract.transfer(safeAddress, ethers.parseEther("0.001")); - await tx.wait(); + await dealTokenAmount(tokenAddress, safeAddress, DEFAULT_SUPPLY_AMOUNT); } } async function supplyAndBorrow( - protocol: Protocols, + protocol: DebtProtocols, debtTokenAddress = USDC_ADDRESS, collateralTokenAddress = cbETH_ADDRESS, ) { @@ -127,7 +135,7 @@ export function createSafeTestHelpers(context: { signer: ethers.Wallet; safeWall collateralTokenAddress = cbETH_ADDRESS, supplyAmount = ethers.parseEther(DEFAULT_SUPPLY_AMOUNT), ) { - await sendCollateralToSafe(collateralTokenAddress, Protocols.FLUID); + await sendCollateralToSafe(collateralTokenAddress, DebtProtocols.FLUID); const collateralTokenContract = new ethers.Contract(collateralTokenAddress, ERC20_ABI, signer); const fluidVault = new ethers.Contract(vaultAddress, FluidVaultAbi, signer); @@ -255,6 +263,17 @@ describe("Safe wallet should debtSwap", function () { let aaveV3Helper: AaveV3Helper; let compoundHelper: CompoundHelper; let morphoHelper: MorphoHelper; + let suiteSnapshot: SnapshotRestorer; + + this.beforeAll(async () => { + await clearSnapshots(); + suiteSnapshot = await takeSnapshot(); + }); + + this.afterAll(async () => { + await suiteSnapshot.restore(); + await clearSnapshots(); + }); this.beforeEach(async () => { // Get the operator (third signer) @@ -295,7 +314,28 @@ describe("Safe wallet should debtSwap", function () { console.log("Modules:", await safeWallet.getModules()); } + // Every asset a case in this suite can touch. A handler is allowed to + // decline part of a repayment (Aave's 1 wei floor, Fluid's minimum operate + // amount, Moonwell's cap at the debt); the module must hand what it + // declined back to the Safe rather than hold it for the next operation. + const SWEPT_ASSETS = { + USDC: USDC_ADDRESS, + EURC: EURC_ADDRESS, + GHO: GHO_ADDRESS, + DAI: DAI_ADDRESS, + sUSDS: sUSDS_ADDRESS, + WETH: WETH_ADDRESS, + cbETH: cbETH_ADDRESS, + cbBTC: cbBTC_ADDRESS, + wstETH: wstETH_ADDRESS, + }; + this.afterEach(async () => { + for (const [symbol, address] of Object.entries(SWEPT_ASSETS)) { + const token = new ethers.Contract(address, ERC20_ABI, ethers.provider); + expect(await token.balanceOf(safeModuleAddress), `${symbol} left in the module`).to.equal(0); + } + // Force garbage collection to free memory if (global.gc) { global.gc(); @@ -314,59 +354,65 @@ describe("Safe wallet should debtSwap", function () { describe("switch In", function () { describe("In Aave", function () { it("from USDC to EURC", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, Protocols.AAVE_V3, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + EURC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, + ); }); it("from USDC to EURC with specific amount", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { useMaxAmount: false }, ); }); it("from EURC to USDC, cbETH Collateral", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, EURC_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, EURC_ADDRESS); await executeDebtSwap( EURC_USDC_POOL, EURC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, ); }); it("from USDC to EURC, WETH Collateral", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, WETH_ADDRESS, ); }); it("from EURC to USDC, WETH Collateral", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, EURC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, EURC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( EURC_USDC_POOL, EURC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, WETH_ADDRESS, ); }); @@ -374,33 +420,51 @@ describe("Safe wallet should debtSwap", function () { describe("GHO on Aave", function () { it("from USDC to GHO", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, GHO_ADDRESS, Protocols.AAVE_V3, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + GHO_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, + ); }); it("from GHO to USDC", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, GHO_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, GHO_ADDRESS); - await executeDebtSwap(GHO_USDC_POOL, GHO_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.AAVE_V3); + await executeDebtSwap( + GHO_USDC_POOL, + GHO_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, + ); }); }); // USDbC is not available on Compound or Aave anymore it.skip("Compound from USDC to USDbC", async function () { - await supplyAndBorrow(Protocols.COMPOUND); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, Protocols.COMPOUND, Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.COMPOUND); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + EURC_ADDRESS, + DebtProtocols.COMPOUND, + DebtProtocols.COMPOUND, + ); }); describe("In Morpho", function () { it("from market 1 to market 2", async function () { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MORPHO, - Protocols.MORPHO, + DebtProtocols.MORPHO, + DebtProtocols.MORPHO, cbETH_ADDRESS, { morphoFromMarketId: morphoMarket1Id, @@ -412,8 +476,14 @@ describe("Safe wallet should debtSwap", function () { // we don't support DAI anymore it.skip("In Moonwell from USDC to DAI", async function () { - await supplyAndBorrow(Protocols.MOONWELL); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, DAI_ADDRESS, Protocols.MOONWELL, Protocols.MOONWELL); + await supplyAndBorrow(DebtProtocols.MOONWELL); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + DAI_ADDRESS, + DebtProtocols.MOONWELL, + DebtProtocols.MOONWELL, + ); }); // sUSDS market is not available on Fluid anymore @@ -423,8 +493,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, sUSDS_ADDRESS, - Protocols.FLUID, - Protocols.FLUID, + DebtProtocols.FLUID, + DebtProtocols.FLUID, wstETH_ADDRESS, { fromFluidVaultAddress: FLUID_wstETH_USDC_VAULT, @@ -439,12 +509,13 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.FLUID, - Protocols.FLUID, + DebtProtocols.FLUID, + DebtProtocols.FLUID, cbETH_ADDRESS, { fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, tofluidVaultAddress: FLUID_cbETH_EURC_VAULT, + preferredDEX: "UniswapV3", }, ); }); @@ -452,13 +523,19 @@ describe("Safe wallet should debtSwap", function () { describe("switch between protocols", function () { it("from Aave to Compound", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, + ); }); describe("with type(uint256).max collateral amount", function () { it("from Aave to Compound", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); const collateralBefore = await aaveV3Helper.getCollateralAmount(cbETH_ADDRESS, safeAddress); expect(collateralBefore).to.be.gt(0); @@ -467,8 +544,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.COMPOUND, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, cbETH_ADDRESS, { useMaxCollateral: true, @@ -480,7 +557,7 @@ describe("Safe wallet should debtSwap", function () { }); it("from Compound to Aave", async function () { - await supplyAndBorrow(Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.COMPOUND); const cometAddr = cometAddressMap.get(USDC_ADDRESS)!; const collateralBefore = await compoundHelper.getCollateralAmount( @@ -494,8 +571,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.COMPOUND, - Protocols.AAVE_V3, + DebtProtocols.COMPOUND, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { useMaxCollateral: true, @@ -507,7 +584,7 @@ describe("Safe wallet should debtSwap", function () { }); it("from Morpho to Compound", async function () { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); const collateralBefore = await morphoHelper.getCollateralAmount(morphoMarket1Id, safeAddress); expect(collateralBefore).to.be.gt(0); @@ -516,8 +593,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MORPHO, - Protocols.COMPOUND, + DebtProtocols.MORPHO, + DebtProtocols.COMPOUND, cbETH_ADDRESS, { morphoFromMarketId: morphoMarket1Id, @@ -536,8 +613,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.AAVE_V3, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, @@ -547,14 +624,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Moonwell to Compound", async function () { - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.COMPOUND, + DebtProtocols.MOONWELL, + DebtProtocols.COMPOUND, WETH_ADDRESS, { useMaxCollateral: true, @@ -573,8 +650,14 @@ describe("Safe wallet should debtSwap", function () { const setFeeBeneficiaryTx = await contractByOwner.setFeeBeneficiary(TEST_FEE_BENEFICIARY_ADDRESS); await setFeeBeneficiaryTx.wait(); - await supplyAndBorrow(Protocols.AAVE_V3); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, + ); }); it("cross-asset debt swap from Aave USDC to EURC with protocol fee", async function () { @@ -592,9 +675,15 @@ describe("Safe wallet should debtSwap", function () { const beneficiaryBalanceBefore = await eurcContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, Protocols.AAVE_V3, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + EURC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, + ); const beneficiaryBalanceAfter = await eurcContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); const feeReceived = beneficiaryBalanceAfter - beneficiaryBalanceBefore; @@ -605,45 +694,69 @@ describe("Safe wallet should debtSwap", function () { }); it("from Compound to Aave EURC", async function () { - await supplyAndBorrow(Protocols.COMPOUND); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, Protocols.COMPOUND, Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.COMPOUND); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + EURC_ADDRESS, + DebtProtocols.COMPOUND, + DebtProtocols.AAVE_V3, + ); }); it("EURC debt on Aave to USDC on Compound", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, EURC_ADDRESS); - await executeDebtSwap(EURC_USDC_POOL, EURC_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.AAVE_V3, EURC_ADDRESS); + await executeDebtSwap( + EURC_USDC_POOL, + EURC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, + ); }); it("from Compound to Aave GHO", async function () { - await supplyAndBorrow(Protocols.COMPOUND); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, GHO_ADDRESS, Protocols.COMPOUND, Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.COMPOUND); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + GHO_ADDRESS, + DebtProtocols.COMPOUND, + DebtProtocols.AAVE_V3, + ); }); it("GHO debt on Aave to USDC on Compound", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, GHO_ADDRESS); - await executeDebtSwap(GHO_USDC_POOL, GHO_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.AAVE_V3, GHO_ADDRESS); + await executeDebtSwap( + GHO_USDC_POOL, + GHO_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, + ); }); it("from Compound to Moonwell", async function () { - await supplyAndBorrow(Protocols.COMPOUND, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.COMPOUND, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.COMPOUND, - Protocols.MOONWELL, + DebtProtocols.COMPOUND, + DebtProtocols.MOONWELL, WETH_ADDRESS, ); }); it("from Moonwell to Compound", async function () { - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.COMPOUND, + DebtProtocols.MOONWELL, + DebtProtocols.COMPOUND, WETH_ADDRESS, ); }); @@ -657,8 +770,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.MOONWELL, + DebtProtocols.FLUID, + DebtProtocols.MOONWELL, WETH_ADDRESS, { fromFluidVaultAddress: FLUID_WETH_USDC_VAULT, @@ -668,7 +781,13 @@ describe("Safe wallet should debtSwap", function () { it("from Fluid to Aave", async function () { await supplyAndBorrowOnFluid(); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.FLUID, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, + ); }); it("from Fluid to Aave with WETH collateral", async function () { @@ -677,8 +796,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.AAVE_V3, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, WETH_ADDRESS, { fromFluidVaultAddress: FLUID_WETH_USDC_VAULT, @@ -692,8 +811,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.MORPHO, + DebtProtocols.FLUID, + DebtProtocols.MORPHO, cbETH_ADDRESS, { fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, @@ -704,18 +823,24 @@ describe("Safe wallet should debtSwap", function () { it("from Fluid to Compound", async function () { await supplyAndBorrowOnFluid(); - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.FLUID, Protocols.COMPOUND); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.FLUID, + DebtProtocols.COMPOUND, + ); }); it("from Moonwell to Fluid", async function () { - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.FLUID, + DebtProtocols.MOONWELL, + DebtProtocols.FLUID, WETH_ADDRESS, { tofluidVaultAddress: FLUID_WETH_USDC_VAULT, @@ -724,14 +849,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Aave to Fluid with WETH collateral", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.FLUID, + DebtProtocols.AAVE_V3, + DebtProtocols.FLUID, WETH_ADDRESS, { tofluidVaultAddress: FLUID_WETH_USDC_VAULT, @@ -740,31 +865,37 @@ describe("Safe wallet should debtSwap", function () { }); it.skip("from Moonwell DAI to Fluid USDC", async function () { - await supplyAndBorrow(Protocols.MOONWELL, DAI_ADDRESS); - await executeDebtSwap(DAI_USDC_POOL, DAI_ADDRESS, USDC_ADDRESS, Protocols.MOONWELL, Protocols.FLUID); + await supplyAndBorrow(DebtProtocols.MOONWELL, DAI_ADDRESS); + await executeDebtSwap( + DAI_USDC_POOL, + DAI_ADDRESS, + USDC_ADDRESS, + DebtProtocols.MOONWELL, + DebtProtocols.FLUID, + ); }); it("from Moonwell to Aave", async function () { - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.AAVE_V3, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, WETH_ADDRESS, ); }); it("from Aave to Moonwell", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.MOONWELL, + DebtProtocols.AAVE_V3, + DebtProtocols.MOONWELL, WETH_ADDRESS, ); }); @@ -779,14 +910,14 @@ describe("Safe wallet should debtSwap", function () { const setFeeBeneficiaryTx = await contractByOwner.setFeeBeneficiary(TEST_FEE_BENEFICIARY_ADDRESS); await setFeeBeneficiaryTx.wait(); - await supplyAndBorrow(Protocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.MOONWELL, + DebtProtocols.AAVE_V3, + DebtProtocols.MOONWELL, WETH_ADDRESS, ); }); @@ -803,7 +934,7 @@ describe("Safe wallet should debtSwap", function () { await setFeeBeneficiaryTx.wait(); // Supply and borrow USDC (6 decimals) - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS); // Get DAI contract for balance checks const daiContract = new ethers.Contract(DAI_ADDRESS, ERC20_ABI, signer); @@ -812,7 +943,13 @@ describe("Safe wallet should debtSwap", function () { const beneficiaryDaiBalanceBefore = await daiContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); // Execute debt swap from USDC (6 decimals) to DAI (18 decimals) - await executeDebtSwap(DAI_USDC_POOL, USDC_ADDRESS, DAI_ADDRESS, Protocols.MOONWELL, Protocols.AAVE_V3); + await executeDebtSwap( + DAI_USDC_POOL, + USDC_ADDRESS, + DAI_ADDRESS, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, + ); // Check fee beneficiary's DAI balance after swap const beneficiaryDaiBalanceAfter = await daiContract.balanceOf(TEST_FEE_BENEFICIARY_ADDRESS); @@ -828,14 +965,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Aave to Morpho", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.MORPHO, + DebtProtocols.AAVE_V3, + DebtProtocols.MORPHO, cbETH_ADDRESS, { morphoToMarketId: morphoMarket1Id, @@ -844,14 +981,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Morpho to Aave", async function () { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MORPHO, - Protocols.AAVE_V3, + DebtProtocols.MORPHO, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { morphoFromMarketId: morphoMarket1Id, @@ -860,14 +997,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Morpho to Compound", async function () { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MORPHO, - Protocols.COMPOUND, + DebtProtocols.MORPHO, + DebtProtocols.COMPOUND, cbETH_ADDRESS, { morphoFromMarketId: morphoMarket1Id, @@ -876,14 +1013,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Compound to Morpho with WETH collateral", async function () { - await supplyAndBorrow(Protocols.COMPOUND, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.COMPOUND, USDC_ADDRESS, WETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.COMPOUND, - Protocols.MORPHO, + DebtProtocols.COMPOUND, + DebtProtocols.MORPHO, WETH_ADDRESS, { morphoToMarketId: morphoMarket7Id, @@ -892,14 +1029,14 @@ describe("Safe wallet should debtSwap", function () { }); it("from Morpho to EURC on Aave", async function () { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.MORPHO, - Protocols.AAVE_V3, + DebtProtocols.MORPHO, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { morphoFromMarketId: morphoMarket1Id, @@ -908,21 +1045,21 @@ describe("Safe wallet should debtSwap", function () { }); it("WETH debt on Aave to Compound", async function () { - await supplyAndBorrow(Protocols.AAVE_V3, WETH_ADDRESS, cbETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.AAVE_V3, WETH_ADDRESS, cbETH_ADDRESS); await executeDebtSwap( ETH_USDC_POOL, WETH_ADDRESS, WETH_ADDRESS, - Protocols.AAVE_V3, - Protocols.COMPOUND, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, cbETH_ADDRESS, ); }); it("Multiple collateral case from Aave to Compound", async function () { // Supply cbETH collateral - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); // Supply WETH collateral additionally await helpers.sendCollateralToSafe(WETH_ADDRESS); @@ -982,8 +1119,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.COMPOUND, + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, cbETH_ADDRESS, { anotherCollateralTokenAddress: WETH_ADDRESS }, ); @@ -1009,7 +1146,7 @@ describe("Safe wallet should debtSwap", function () { it("Multiple collateral case from Compound to Aave", async function () { // Supply cbETH collateral on Compound - await supplyAndBorrow(Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.COMPOUND); // Supply WETH collateral additionally on Compound await helpers.sendCollateralToSafe(WETH_ADDRESS); @@ -1046,8 +1183,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.COMPOUND, - Protocols.AAVE_V3, + DebtProtocols.COMPOUND, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { anotherCollateralTokenAddress: WETH_ADDRESS }, ); @@ -1088,13 +1225,13 @@ describe("Safe wallet should debtSwap", function () { describe("operator and authorization", function () { it("call executeDebtSwap by operator", async function () { - await supplyAndBorrow(Protocols.MOONWELL); + await supplyAndBorrow(DebtProtocols.MOONWELL); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.AAVE_V3, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator, @@ -1105,14 +1242,14 @@ describe("Safe wallet should debtSwap", function () { it("revert when calling executeDebtSwap by non operator(wallet4)", async function () { const [_, , , wallet4] = await ethers.getSigners(); - await supplyAndBorrow(Protocols.MOONWELL); + await supplyAndBorrow(DebtProtocols.MOONWELL); await expect( executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.AAVE_V3, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator: wallet4, @@ -1124,15 +1261,15 @@ describe("Safe wallet should debtSwap", function () { describe("should revert", function () { it("if flashloan pool is not uniswap v3 pool", async function () { - await supplyAndBorrow(Protocols.MOONWELL); + await supplyAndBorrow(DebtProtocols.MOONWELL); await expect( executeDebtSwap( USDC_ADDRESS, // Using USDC contract address which doesn't have token0() function USDC_ADDRESS, USDC_ADDRESS, - Protocols.MOONWELL, - Protocols.AAVE_V3, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator, // Call directly to properly catch the revert reason @@ -1159,8 +1296,8 @@ describe("Safe wallet should debtSwap", function () { zeroAddress, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator }, ), @@ -1173,8 +1310,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, zeroAddress, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator }, ), @@ -1187,8 +1324,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, zeroAddress, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator }, ), @@ -1200,8 +1337,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1218,8 +1355,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1236,8 +1373,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1254,8 +1391,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, 0n, @@ -1273,7 +1410,7 @@ describe("Safe wallet should debtSwap", function () { moduleContract.executeDebtSwap( ETH_USDC_POOL, 99, // Invalid protocol number - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1290,7 +1427,7 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, 99, // Invalid protocol number USDC_ADDRESS, USDC_ADDRESS, @@ -1308,8 +1445,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, "0x1234567890123456789012345678901234567890", // Invalid token address USDC_ADDRESS, MaxUint256, @@ -1326,8 +1463,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, "0x1234567890123456789012345678901234567890", // Invalid token address MaxUint256, @@ -1344,8 +1481,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1367,8 +1504,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1385,8 +1522,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1406,8 +1543,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1425,8 +1562,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, USDC_ADDRESS, MaxUint256, @@ -1440,7 +1577,7 @@ describe("Safe wallet should debtSwap", function () { it("if switching to Fluid with multiple collateral assets", async function () { // Fluid only supports one collateral asset - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); // Supply additional WETH collateral to Aave await helpers.sendCollateralToSafe(WETH_ADDRESS); @@ -1482,8 +1619,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.AAVE_V3, - Protocols.FLUID, + DebtProtocols.AAVE_V3, + DebtProtocols.FLUID, cbETH_ADDRESS, { anotherCollateralTokenAddress: WETH_ADDRESS, @@ -1498,8 +1635,8 @@ describe("Safe wallet should debtSwap", function () { await expect( moduleContract.executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, "0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222", MaxUint256, @@ -1512,12 +1649,12 @@ describe("Safe wallet should debtSwap", function () { }); it("revert when paraswap data is fetched with wrong contract address", async function () { - await supplyAndBorrow(Protocols.MOONWELL); + await supplyAndBorrow(DebtProtocols.MOONWELL); // Fetch paraswap data with wrong contract address (using TEST_ADDRESS instead of safeModuleAddress) const wrongContractAddress = TEST_ADDRESS; - const FromHelper = protocolHelperMap.get(Protocols.MOONWELL)!; + const FromHelper = protocolHelperMap.get(DebtProtocols.MOONWELL)!; const fromHelper = new FromHelper(signer); const srcDebtAmount = await fromHelper.getDebtAmount(USDC_ADDRESS, safeAddress); @@ -1536,8 +1673,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, DAI_ADDRESS, - Protocols.MOONWELL, - Protocols.AAVE_V3, + DebtProtocols.MOONWELL, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, wrongParaswapData, { @@ -1550,14 +1687,14 @@ describe("Safe wallet should debtSwap", function () { describe("Fuzz testing", function () { it("randomize debt amount", async function () { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); await executeDebtSwap( ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { useMaxAmount: false, @@ -1571,8 +1708,8 @@ describe("Safe wallet should debtSwap", function () { flashloanPool: string, fromTokenAddress: string, toTokenAddress: string, - fromProtocol: Protocols, - toProtocol: Protocols, + fromProtocol: DebtProtocols, + toProtocol: DebtProtocols, collateralTokenAddress: string, customParaswapData: { srcAmount: bigint; swapData: string }, options: { @@ -1599,7 +1736,7 @@ describe("Safe wallet should debtSwap", function () { let toExtraData = "0x"; // Handle toProtocol setup (Aave approveDelegation) - if (toProtocol === Protocols.AAVE_V3) { + if (toProtocol === DebtProtocols.AAVE_V3) { const debtTokenAddress = await toHelper.getDebtTokenAddress(toTokenAddress); const aaveDebtToken = new ethers.Contract(debtTokenAddress, aaveDebtTokenJson, signer); @@ -1618,7 +1755,7 @@ describe("Safe wallet should debtSwap", function () { } let collateralAmount = ethers.parseEther(DEFAULT_SUPPLY_AMOUNT); - if (fromProtocol === Protocols.MOONWELL) { + if (fromProtocol === DebtProtocols.MOONWELL) { collateralAmount = await fromHelper.getCollateralAmount(collateralTokenAddress, safeAddress); } @@ -1645,8 +1782,8 @@ describe("Safe wallet should debtSwap", function () { flashloanPool: string, fromTokenAddress: string, toTokenAddress: string, - fromProtocol: Protocols, - toProtocol: Protocols, + fromProtocol: DebtProtocols, + toProtocol: DebtProtocols, collateralTokenAddress = cbETH_ADDRESS, options: { morphoFromMarketId?: string; @@ -1658,6 +1795,7 @@ describe("Safe wallet should debtSwap", function () { tofluidVaultAddress?: string; randomizeDebtAmount?: boolean; useMaxCollateral?: boolean; + preferredDEX?: string; } = { useMaxAmount: true, fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, @@ -1677,18 +1815,18 @@ describe("Safe wallet should debtSwap", function () { ); let fromDebtAmountParameter; - if (fromProtocol === Protocols.MORPHO) { + if (fromProtocol === DebtProtocols.MORPHO) { fromDebtAmountParameter = options!.morphoFromMarketId!; - } else if (fromProtocol === Protocols.FLUID) { + } else if (fromProtocol === DebtProtocols.FLUID) { fromDebtAmountParameter = options?.fromFluidVaultAddress; } else { fromDebtAmountParameter = fromTokenAddress; } let toDebtAmountParameter; - if (toProtocol === Protocols.MORPHO) { + if (toProtocol === DebtProtocols.MORPHO) { toDebtAmountParameter = options!.morphoToMarketId!; - } else if (toProtocol === Protocols.FLUID) { + } else if (toProtocol === DebtProtocols.FLUID) { toDebtAmountParameter = options?.tofluidVaultAddress; } else { toDebtAmountParameter = toTokenAddress; @@ -1700,7 +1838,8 @@ describe("Safe wallet should debtSwap", function () { // Calculate debt amount based on options let actualDebtAmount = srcDebtBefore; if (options.randomizeDebtAmount) { - actualDebtAmount = BigInt(Math.floor(Math.random() * Number(srcDebtBefore))); + // Keep the fuzzed amount non-zero and quote the same amount that will be swapped. + actualDebtAmount = 1n + BigInt(Math.floor(Math.random() * Number(srcDebtBefore - 1n))); } // Add 0.001% of initial amount (rounded up) for non-max amounts @@ -1716,16 +1855,23 @@ describe("Safe wallet should debtSwap", function () { }; if (fromTokenAddress != toTokenAddress) { - paraswapData = await getParaswapData(fromTokenAddress, toTokenAddress, safeModuleAddress, srcDebtBefore); + paraswapData = await getParaswapData( + fromTokenAddress, + toTokenAddress, + safeModuleAddress, + actualDebtAmount, + 1n, + options.preferredDEX, + ); } let fromExtraData = "0x"; let toExtraData = "0x"; switch (fromProtocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: // if switch to another protocol, must give approval for aToken - if (toProtocol != Protocols.AAVE_V3) { + if (toProtocol != DebtProtocols.AAVE_V3) { const aTokenAddress = await fromHelper.getATokenAddress(collateralTokenAddress || cbETH_ADDRESS); const token = new ethers.Contract(aTokenAddress, ERC20_ABI, signer); @@ -1768,7 +1914,7 @@ describe("Safe wallet should debtSwap", function () { console.log("Safe transaction: Aave approved"); } break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: const cometAddress = cometAddressMap.get(fromTokenAddress)!; const comet = new ethers.Contract(cometAddress, cometAbi, signer); @@ -1786,14 +1932,14 @@ describe("Safe wallet should debtSwap", function () { await safeWallet.executeTransaction(safeAllowTransaction); console.log("Safe transaction: Compound allow"); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: await helpers.morphoAuthorizeTxBySafe(); const borrowShares = await fromHelper.getBorrowShares(options!.morphoFromMarketId!, safeAddress); fromExtraData = fromHelper.encodeExtraData(options!.morphoFromMarketId!, borrowShares); break; - case Protocols.FLUID: + case DebtProtocols.FLUID: const vaultAddress = options.fromFluidVaultAddress || FLUID_cbETH_USDC_VAULT; const nftId = await fromHelper.getNftId(vaultAddress, safeAddress); fromExtraData = ethers.AbiCoder.defaultAbiCoder().encode( @@ -1804,7 +1950,7 @@ describe("Safe wallet should debtSwap", function () { } switch (toProtocol) { - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: const debtTokenAddress = await toHelper.getDebtTokenAddress(toTokenAddress); const aaveDebtToken = new ethers.Contract(debtTokenAddress, aaveDebtTokenJson, signer); @@ -1825,7 +1971,7 @@ describe("Safe wallet should debtSwap", function () { const safeTxHash = await safeWallet.executeTransaction(safeTransaction); console.log("Safe transaction: Aave approveDelegation"); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: const cometAddress = cometAddressMap.get(toTokenAddress)!; const comet = new ethers.Contract(cometAddress, cometAbi, signer); @@ -1843,16 +1989,16 @@ describe("Safe wallet should debtSwap", function () { await safeWallet.executeTransaction(safeAllowTransaction); console.log("Safe transaction: Compound allow"); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: // If fromProtocol is not Morpho, authorize Morpho - const shouldAuthorizeMorpho = fromProtocol !== Protocols.MORPHO; + const shouldAuthorizeMorpho = fromProtocol !== DebtProtocols.MORPHO; if (shouldAuthorizeMorpho) await helpers.morphoAuthorizeTxBySafe(); const borrowShares = await toHelper.getBorrowShares(options!.morphoToMarketId!, safeAddress); toExtraData = toHelper.encodeExtraData(options!.morphoToMarketId!, borrowShares); break; - case Protocols.FLUID: + case DebtProtocols.FLUID: const vaultAddress = options.tofluidVaultAddress || FLUID_cbETH_USDC_VAULT; toExtraData = ethers.AbiCoder.defaultAbiCoder().encode( ["address", "uint256", "bool"], @@ -1863,16 +2009,16 @@ describe("Safe wallet should debtSwap", function () { let collateralAmount = ethers.parseEther(DEFAULT_SUPPLY_AMOUNT); switch (fromProtocol) { - case Protocols.MOONWELL: + case DebtProtocols.MOONWELL: collateralAmount = await fromHelper.getCollateralAmount(collateralTokenAddress, safeAddress); break; - case Protocols.MORPHO: + case DebtProtocols.MORPHO: collateralAmount = await fromHelper.getCollateralAmount(options!.morphoFromMarketId!, safeAddress); break; - case Protocols.AAVE_V3: + case DebtProtocols.AAVE_V3: collateralAmount = await aaveV3Helper.getCollateralAmount(collateralTokenAddress, safeAddress); break; - case Protocols.COMPOUND: + case DebtProtocols.COMPOUND: const cometAddr = cometAddressMap.get(fromTokenAddress)!; collateralAmount = await compoundHelper.getCollateralAmount( cometAddr, @@ -1949,14 +2095,14 @@ describe("Safe wallet should debtSwap", function () { const dstDecimals = await getDecimals(toTokenAddress); console.log( - `Source ${Protocols[fromProtocol]}, ${fromTokenAddress} Debt Amount:`, + `Source ${DebtProtocols[fromProtocol]}, ${fromTokenAddress} Debt Amount:`, ethers.formatUnits(srcDebtBefore, srcDecimals), " -> ", ethers.formatUnits(srcDebtAfter, srcDecimals), ); console.log( - `Destination ${Protocols[toProtocol]}, ${toTokenAddress} Debt Amount:`, + `Destination ${DebtProtocols[toProtocol]}, ${toTokenAddress} Debt Amount:`, ethers.formatUnits(dstDebtBefore, dstDecimals), " -> ", ethers.formatUnits(dstDebtAfter, dstDecimals), @@ -1979,7 +2125,7 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchFrom for Fluid - const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false); + const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false); await disableTx.wait(); console.log("Disabled switchFrom for Fluid"); @@ -1989,8 +2135,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.AAVE_V3, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator, @@ -2008,7 +2154,7 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchTo for Aave - const disableTx = await contractByPauser.setProtocolEnabledForSwitchTo(Protocols.AAVE_V3, false); + const disableTx = await contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.AAVE_V3, false); await disableTx.wait(); console.log("Disabled switchTo for Aave"); @@ -2018,8 +2164,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, - Protocols.FLUID, - Protocols.AAVE_V3, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, cbETH_ADDRESS, { operator, @@ -2037,25 +2183,31 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchFrom for Fluid - let disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false); + let disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false); await disableTx.wait(); console.log("Disabled switchFrom for Fluid"); // Verify it's disabled - const isDisabled = await contractByPauser.protocolEnabledForSwitchFrom(Protocols.FLUID); + const isDisabled = await contractByPauser.protocolEnabledForSwitchFrom(DebtProtocols.FLUID); expect(isDisabled).to.be.false; // Re-enable switchFrom for Fluid - const enableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, true); + const enableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, true); await enableTx.wait(); console.log("Re-enabled switchFrom for Fluid"); // Verify it's enabled - const isEnabled = await contractByPauser.protocolEnabledForSwitchFrom(Protocols.FLUID); + const isEnabled = await contractByPauser.protocolEnabledForSwitchFrom(DebtProtocols.FLUID); expect(isEnabled).to.be.true; // Now debt swap should work - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.FLUID, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, + ); }); it("Should only allow pauser to disable/enable protocols", async function () { @@ -2063,14 +2215,14 @@ describe("Safe wallet should debtSwap", function () { const contractByWallet2 = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, wallet2); // Try to disable switchFrom as non-pauser - should fail - await expect(contractByWallet2.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false)).to.be.revertedWith( - "Caller is not authorized to pause", - ); + await expect( + contractByWallet2.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false), + ).to.be.revertedWith("Caller is not authorized to pause"); // Try to disable switchTo as non-pauser - should fail - await expect(contractByWallet2.setProtocolEnabledForSwitchTo(Protocols.FLUID, false)).to.be.revertedWith( - "Caller is not authorized to pause", - ); + await expect( + contractByWallet2.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, false), + ).to.be.revertedWith("Caller is not authorized to pause"); }); it("Should emit ProtocolStatusChanged event when enabling/disabling", async function () { @@ -2080,22 +2232,22 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Test switchFrom event - await expect(contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false)) + await expect(contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false)) .to.emit(contractByPauser, "ProtocolStatusChanged") - .withArgs(Protocols.FLUID, "switchFrom", false); + .withArgs(DebtProtocols.FLUID, "switchFrom", false); - await expect(contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, true)) + await expect(contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, true)) .to.emit(contractByPauser, "ProtocolStatusChanged") - .withArgs(Protocols.FLUID, "switchFrom", true); + .withArgs(DebtProtocols.FLUID, "switchFrom", true); // Test switchTo event - await expect(contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, false)) + await expect(contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, false)) .to.emit(contractByPauser, "ProtocolStatusChanged") - .withArgs(Protocols.FLUID, "switchTo", false); + .withArgs(DebtProtocols.FLUID, "switchTo", false); - await expect(contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, true)) + await expect(contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, true)) .to.emit(contractByPauser, "ProtocolStatusChanged") - .withArgs(Protocols.FLUID, "switchTo", true); + .withArgs(DebtProtocols.FLUID, "switchTo", true); }); it("Should allow disabling switchFrom while keeping switchTo enabled", async function () { @@ -2105,24 +2257,30 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchFrom for Fluid, but keep switchTo enabled - const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false); + const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false); await disableTx.wait(); // Verify states - const switchFromEnabled = await contractByPauser.protocolEnabledForSwitchFrom(Protocols.FLUID); - const switchToEnabled = await contractByPauser.protocolEnabledForSwitchTo(Protocols.FLUID); + const switchFromEnabled = await contractByPauser.protocolEnabledForSwitchFrom(DebtProtocols.FLUID); + const switchToEnabled = await contractByPauser.protocolEnabledForSwitchTo(DebtProtocols.FLUID); expect(switchFromEnabled).to.be.false; expect(switchToEnabled).to.be.true; // Setup position on Aave (since we can't use Fluid as from protocol) - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); // Should be able to switch TO Fluid (since switchTo is enabled) - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.AAVE_V3, Protocols.FLUID); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.AAVE_V3, + DebtProtocols.FLUID, + ); // Re-enable switchFrom for cleanup - const enableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, true); + const enableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, true); await enableTx.wait(); }); @@ -2133,12 +2291,12 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchTo for Fluid, but keep switchFrom enabled - const disableTx = await contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, false); + const disableTx = await contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, false); await disableTx.wait(); // Verify states - const switchFromEnabled = await contractByPauser.protocolEnabledForSwitchFrom(Protocols.FLUID); - const switchToEnabled = await contractByPauser.protocolEnabledForSwitchTo(Protocols.FLUID); + const switchFromEnabled = await contractByPauser.protocolEnabledForSwitchFrom(DebtProtocols.FLUID); + const switchToEnabled = await contractByPauser.protocolEnabledForSwitchTo(DebtProtocols.FLUID); expect(switchFromEnabled).to.be.true; expect(switchToEnabled).to.be.false; @@ -2147,10 +2305,16 @@ describe("Safe wallet should debtSwap", function () { await supplyAndBorrowOnFluid(); // Should be able to switch FROM Fluid (since switchFrom is enabled) - await executeDebtSwap(ETH_USDC_POOL, USDC_ADDRESS, USDC_ADDRESS, Protocols.FLUID, Protocols.AAVE_V3); + await executeDebtSwap( + ETH_USDC_POOL, + USDC_ADDRESS, + USDC_ADDRESS, + DebtProtocols.FLUID, + DebtProtocols.AAVE_V3, + ); // Re-enable switchTo for cleanup - const enableTx = await contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, true); + const enableTx = await contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, true); await enableTx.wait(); }); @@ -2163,7 +2327,7 @@ describe("Safe wallet should debtSwap", function () { const contractByPauser = await ethers.getContractAt("SafeDebtManager", safeModuleAddress, pauser); // Disable switchFrom for Fluid - const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, false); + const disableTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, false); await disableTx.wait(); // Try switchIn - should fail because switchFrom is disabled (call with operator to catch revert reason) @@ -2172,8 +2336,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.FLUID, - Protocols.FLUID, + DebtProtocols.FLUID, + DebtProtocols.FLUID, cbETH_ADDRESS, { fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, @@ -2184,10 +2348,10 @@ describe("Safe wallet should debtSwap", function () { ).to.be.revertedWith("SwitchFrom is disabled for from protocol"); // Re-enable switchFrom and disable switchTo - const enableFromTx = await contractByPauser.setProtocolEnabledForSwitchFrom(Protocols.FLUID, true); + const enableFromTx = await contractByPauser.setProtocolEnabledForSwitchFrom(DebtProtocols.FLUID, true); await enableFromTx.wait(); - const disableToTx = await contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, false); + const disableToTx = await contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, false); await disableToTx.wait(); // Try switchIn again - should fail because switchTo is disabled (call with operator to catch revert reason) @@ -2196,8 +2360,8 @@ describe("Safe wallet should debtSwap", function () { ETH_USDC_POOL, USDC_ADDRESS, EURC_ADDRESS, - Protocols.FLUID, - Protocols.FLUID, + DebtProtocols.FLUID, + DebtProtocols.FLUID, cbETH_ADDRESS, { fromFluidVaultAddress: FLUID_cbETH_USDC_VAULT, @@ -2208,7 +2372,7 @@ describe("Safe wallet should debtSwap", function () { ).to.be.revertedWith("SwitchTo is disabled for to protocol"); // Re-enable for cleanup - const enableToTx = await contractByPauser.setProtocolEnabledForSwitchTo(Protocols.FLUID, true); + const enableToTx = await contractByPauser.setProtocolEnabledForSwitchTo(DebtProtocols.FLUID, true); await enableToTx.wait(); }); }); diff --git a/test/emergencyWithdrawETH.ts b/test/debt/emergencyWithdrawETH.ts similarity index 99% rename from test/emergencyWithdrawETH.ts rename to test/debt/emergencyWithdrawETH.ts index df39ae4..e83a062 100644 --- a/test/emergencyWithdrawETH.ts +++ b/test/debt/emergencyWithdrawETH.ts @@ -1,7 +1,7 @@ import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; -import { deployLeveragedPositionContractFixture, deploySafeContractFixture } from "./deployUtils"; +import { deployLeveragedPositionContractFixture, deploySafeContractFixture } from "../helpers/deployUtils"; describe("Emergency Withdraw ETH", function () { describe("LeveragedPosition", function () { diff --git a/test/exitBySafe.ts b/test/debt/exitBySafe.ts similarity index 93% rename from test/exitBySafe.ts rename to test/debt/exitBySafe.ts index 0c4f0c3..3f3b298 100644 --- a/test/exitBySafe.ts +++ b/test/debt/exitBySafe.ts @@ -2,20 +2,26 @@ import { ethers } from "hardhat"; import dotenv from "dotenv"; dotenv.config(); import Safe from "@safe-global/protocol-kit"; -import { cbETH_ADDRESS, DEFAULT_SUPPLY_AMOUNT, Protocols, USDC_ADDRESS, WETH_ADDRESS } from "./constants"; +import { cbETH_ADDRESS, DEFAULT_SUPPLY_AMOUNT, DebtProtocols, USDC_ADDRESS, WETH_ADDRESS } from "../helpers/constants"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; -import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -import { eip1193Provider, fundETH, fundSignerWithETH } from "./utils"; -import { FLUID_cbETH_USDC_VAULT, FLUID_WETH_USDC_VAULT, FluidHelper } from "./protocols/fluid"; -import { CompoundHelper, USDC_COMET_ADDRESS } from "./protocols/compound"; -import { morphoMarket1Id, MorphoHelper } from "./protocols/morpho"; -import { AaveV3Helper } from "./protocols/aaveV3"; -import { MoonwellHelper } from "./protocols/moonwell"; +import { + clearSnapshots, + loadFixture, + SnapshotRestorer, + takeSnapshot, + time, +} from "@nomicfoundation/hardhat-network-helpers"; +import { eip1193Provider, fundETH, fundSignerWithETH } from "../helpers/utils"; +import { FLUID_cbETH_USDC_VAULT, FLUID_WETH_USDC_VAULT, FluidHelper } from "../helpers/protocolsDebt/fluid"; +import { CompoundHelper, USDC_COMET_ADDRESS } from "../helpers/protocolsDebt/compound"; +import { morphoMarket1Id, MorphoHelper } from "../helpers/protocolsDebt/morpho"; +import { AaveV3Helper } from "../helpers/protocolsDebt/aaveV3"; +import { MoonwellHelper } from "../helpers/protocolsDebt/moonwell"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { expect } from "chai"; -import { deploySafeContractFixture } from "./deployUtils"; -import { safeAddress } from "./safeTestContext"; +import { deploySafeContractFixture } from "../helpers/deployUtils"; +import { safeAddress } from "../helpers/safeTestContext"; import { createSafeTestHelpers } from "./debtSwapBySafe"; describe("Safe wallet exit function tests", function () { @@ -27,6 +33,17 @@ describe("Safe wallet exit function tests", function () { let safeModuleContract: any; let safeModuleAddress: string; let helpers: ReturnType; + let suiteSnapshot: SnapshotRestorer; + + this.beforeAll(async () => { + await clearSnapshots(); + suiteSnapshot = await takeSnapshot(); + }); + + this.afterAll(async () => { + await suiteSnapshot.restore(); + await clearSnapshots(); + }); this.beforeEach(async () => { const signers = await ethers.getSigners(); @@ -98,7 +115,7 @@ describe("Safe wallet exit function tests", function () { const fluidHelper = new FluidHelper(signer); return { - protocol: Protocols.FLUID, + protocol: DebtProtocols.FLUID, debtAsset: USDC_ADDRESS, debtDecimals: 6, collateralAsset: collateral, @@ -130,13 +147,13 @@ describe("Safe wallet exit function tests", function () { const aaveHelper = new AaveV3Helper(signer); return { - protocol: Protocols.AAVE_V3, + protocol: DebtProtocols.AAVE_V3, debtAsset: USDC_ADDRESS, debtDecimals: 6, collateralAsset: cbETH_ADDRESS, collateralDecimals: 18, setupPosition: async () => { - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); await approveAaveAToken(opts?.approvalAmount ?? ethers.parseEther("1")); }, getDebtAmount: () => aaveHelper.getDebtAmount(USDC_ADDRESS, safeAddress), @@ -154,13 +171,13 @@ describe("Safe wallet exit function tests", function () { const morphoHelper = new MorphoHelper(signer); return { - protocol: Protocols.MORPHO, + protocol: DebtProtocols.MORPHO, debtAsset: USDC_ADDRESS, debtDecimals: 6, collateralAsset: cbETH_ADDRESS, collateralDecimals: 18, setupPosition: async () => { - await supplyAndBorrow(Protocols.MORPHO); + await supplyAndBorrow(DebtProtocols.MORPHO); await morphoAuthorizeTxBySafe(); }, getDebtAmount: () => morphoHelper.getDebtAmount(marketId, safeAddress), @@ -184,13 +201,13 @@ describe("Safe wallet exit function tests", function () { const compoundHelper = new CompoundHelper(signer); return { - protocol: Protocols.COMPOUND, + protocol: DebtProtocols.COMPOUND, debtAsset: USDC_ADDRESS, debtDecimals: 6, collateralAsset: cbETH_ADDRESS, collateralDecimals: 18, setupPosition: async () => { - await supplyAndBorrow(Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.COMPOUND); await compoundAllowTxBySafe(USDC_ADDRESS); }, getDebtAmount: () => compoundHelper.getDebtAmount(USDC_ADDRESS, safeAddress), @@ -207,13 +224,13 @@ describe("Safe wallet exit function tests", function () { const moonwellHelper = new MoonwellHelper(signer); return { - protocol: Protocols.MOONWELL, + protocol: DebtProtocols.MOONWELL, debtAsset: USDC_ADDRESS, debtDecimals: 6, collateralAsset: WETH_ADDRESS, collateralDecimals: 18, setupPosition: async () => { - await supplyAndBorrow(Protocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); + await supplyAndBorrow(DebtProtocols.MOONWELL, USDC_ADDRESS, WETH_ADDRESS); }, getDebtAmount: () => moonwellHelper.getDebtAmount(USDC_ADDRESS, safeAddress), getCollateralAmount: () => moonwellHelper.getCollateralAmount(WETH_ADDRESS, safeAddress), @@ -228,7 +245,7 @@ describe("Safe wallet exit function tests", function () { // ─── Main test helper ─── async function testExitPosition(options: { - protocol: Protocols; + protocol: DebtProtocols; debtAsset: string; debtDecimals: number; collateralAsset: string; @@ -503,7 +520,7 @@ describe("Safe wallet exit function tests", function () { await time.increaseTo((await time.latest()) + 3600); await fundSignerWithETH(operator.address, "0.01"); - await supplyAndBorrow(Protocols.COMPOUND); + await supplyAndBorrow(DebtProtocols.COMPOUND); await compoundAllowTxBySafe(USDC_ADDRESS); const debtBefore = await compoundHelper.getDebtAmount(USDC_ADDRESS, safeAddress); @@ -525,7 +542,7 @@ describe("Safe wallet exit function tests", function () { await expect( moduleContract.exit( - Protocols.COMPOUND, + DebtProtocols.COMPOUND, USDC_ADDRESS, debtBefore, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], @@ -543,7 +560,7 @@ describe("Safe wallet exit function tests", function () { await expect( moduleContract.exit( - Protocols.COMPOUND, + DebtProtocols.COMPOUND, USDC_ADDRESS, debtBefore, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], @@ -556,7 +573,7 @@ describe("Safe wallet exit function tests", function () { const validExtraData = compoundHelper.encodeExtraData(USDC_COMET_ADDRESS); await moduleContract.exit( - Protocols.COMPOUND, + DebtProtocols.COMPOUND, USDC_ADDRESS, debtBefore, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], @@ -600,7 +617,7 @@ describe("Safe wallet exit function tests", function () { await expect( moduleContract.exit( - Protocols.FLUID, + DebtProtocols.FLUID, USDC_ADDRESS, ethers.MaxUint256, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], @@ -618,7 +635,7 @@ describe("Safe wallet exit function tests", function () { await time.increaseTo((await time.latest()) + 3600); await fundSignerWithETH(operator.address, "0.01"); - await supplyAndBorrow(Protocols.AAVE_V3); + await supplyAndBorrow(DebtProtocols.AAVE_V3); await approveAaveAToken(ethers.parseEther("1")); const debtBefore = await aaveHelper.getDebtAmount(USDC_ADDRESS, safeAddress); @@ -631,7 +648,7 @@ describe("Safe wallet exit function tests", function () { const collateralAmount = await aaveHelper.getCollateralAmount(cbETH_ADDRESS, safeAddress); const exitTx = await moduleContract.exit( - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, ethers.MaxUint256, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], @@ -652,7 +669,7 @@ describe("Safe wallet exit function tests", function () { const collateralBalanceBefore = await collateralContract.balanceOf(safeAddress); const withdrawTx = await moduleContract.exit( - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, 0n, [{ asset: cbETH_ADDRESS, amount: collateralInProtocol }], @@ -703,7 +720,7 @@ describe("Safe wallet exit function tests", function () { await expect( moduleContract.exit( - Protocols.FLUID, + DebtProtocols.FLUID, USDC_ADDRESS, 0n, [{ asset: cbETH_ADDRESS, amount: collateralAmount }], diff --git a/test/pausableSafeModule.ts b/test/debt/pausableSafeModule.ts similarity index 92% rename from test/pausableSafeModule.ts rename to test/debt/pausableSafeModule.ts index 724ac85..a87a66f 100644 --- a/test/pausableSafeModule.ts +++ b/test/debt/pausableSafeModule.ts @@ -1,9 +1,9 @@ import { ethers } from "hardhat"; import { expect } from "chai"; -import { deploySafeContractFixture } from "./deployUtils"; +import { deploySafeContractFixture } from "../helpers/deployUtils"; import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -import { Protocols, USDC_ADDRESS, DAI_ADDRESS, ETH_USDC_POOL } from "./constants"; -import { SafeDebtManager } from "../typechain-types"; +import { DebtProtocols, USDC_ADDRESS, DAI_ADDRESS, ETH_USDC_POOL } from "../helpers/constants"; +import { SafeDebtManager } from "../../typechain-types"; describe("SafeDebtManager Pausable", function () { let safeModuleContract: any; @@ -74,8 +74,8 @@ describe("SafeDebtManager Pausable", function () { .connect(operator) .executeDebtSwap( ETH_USDC_POOL, - Protocols.AAVE_V3, - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, + DebtProtocols.AAVE_V3, USDC_ADDRESS, DAI_ADDRESS, ethers.parseUnits("100", 6), diff --git a/test/setProtocolHandler.ts b/test/debt/setProtocolHandler.ts similarity index 73% rename from test/setProtocolHandler.ts rename to test/debt/setProtocolHandler.ts index 1503e7e..5c39651 100644 --- a/test/setProtocolHandler.ts +++ b/test/debt/setProtocolHandler.ts @@ -1,8 +1,8 @@ -import { loadFixture, time, setNextBlockBaseFeePerGas } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { time, setNextBlockBaseFeePerGas } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; -import { deploySafeContractFixture, deployHandlers } from "./deployUtils"; -import { Protocols } from "./constants"; +import { deploySafeContractFixture, deployHandlers } from "../helpers/deployUtils"; +import { DebtProtocols } from "../helpers/constants"; // Helper to get CRITICAL_ROLE bytes32 const CRITICAL_ROLE = ethers.keccak256(ethers.toUtf8Bytes("CRITICAL_ROLE")); @@ -26,7 +26,13 @@ describe("Set Protocol Handler", function () { const LeveragedPosition = await ethers.getContractFactory("LeveragedPosition"); const leveragedPosition = await LeveragedPosition.deploy( await protocolRegistry.getAddress(), - [Protocols.AAVE_V3, Protocols.COMPOUND, Protocols.MORPHO, Protocols.MOONWELL, Protocols.FLUID], + [ + DebtProtocols.AAVE_V3, + DebtProtocols.COMPOUND, + DebtProtocols.MORPHO, + DebtProtocols.MOONWELL, + DebtProtocols.FLUID, + ], [ await aaveV3Handler.getAddress(), await compoundHandler.getAddress(), @@ -45,17 +51,17 @@ describe("Set Protocol Handler", function () { } it("should allow address with CRITICAL_ROLE (timelock) to update handler via schedule/execute", async function () { - const { leveragedPosition, timelock } = await loadFixture(deployLeveragedPositionWithRegistry); + const { leveragedPosition, timelock } = await deployLeveragedPositionWithRegistry(); // Get current handler - const oldHandler = await leveragedPosition.protocolHandlers(Protocols.AAVE_V3); + const oldHandler = await leveragedPosition.protocolHandlers(DebtProtocols.AAVE_V3); // Create a new dummy handler address const newHandler = ethers.Wallet.createRandom().address; // Encode the setProtocolHandler call const callData = leveragedPosition.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -74,30 +80,29 @@ describe("Set Protocol Handler", function () { // Execute the transaction await expect(timelock.execute(target, value, callData, predecessor, salt)) .to.emit(leveragedPosition, "ProtocolHandlerUpdated") - .withArgs(Protocols.AAVE_V3, oldHandler, newHandler); + .withArgs(DebtProtocols.AAVE_V3, oldHandler, newHandler); // Verify handler was updated - expect(await leveragedPosition.protocolHandlers(Protocols.AAVE_V3)).to.equal(newHandler); + expect(await leveragedPosition.protocolHandlers(DebtProtocols.AAVE_V3)).to.equal(newHandler); }); - it("should revert if caller does not have CRITICAL_ROLE", async function () { - const { leveragedPosition } = await loadFixture(deployLeveragedPositionWithRegistry); + it("should reject a caller that is not the immutable timelock", async function () { + const { leveragedPosition } = await deployLeveragedPositionWithRegistry(); const [, nonOwner] = await ethers.getSigners(); const newHandler = ethers.Wallet.createRandom().address; - // Try to update handler as non-owner without CRITICAL_ROLE await expect( - leveragedPosition.connect(nonOwner).setProtocolHandler(Protocols.AAVE_V3, newHandler), - ).to.be.revertedWith("Caller does not have CRITICAL_ROLE"); + leveragedPosition.connect(nonOwner).setProtocolHandler(DebtProtocols.AAVE_V3, newHandler), + ).to.be.revertedWithCustomError(leveragedPosition, "OnlyTimelock"); }); it("should revert if new handler is zero address", async function () { - const { leveragedPosition, timelock } = await loadFixture(deployLeveragedPositionWithRegistry); + const { leveragedPosition, timelock } = await deployLeveragedPositionWithRegistry(); // Encode the setProtocolHandler call with zero address const callData = leveragedPosition.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, ethers.ZeroAddress, ]); @@ -118,17 +123,17 @@ describe("Set Protocol Handler", function () { }); it("should allow updating an existing handler to a new address", async function () { - const { leveragedPosition, timelock } = await loadFixture(deployLeveragedPositionWithRegistry); + const { leveragedPosition, timelock } = await deployLeveragedPositionWithRegistry(); // Get current handler for AAVE_V3 - const oldHandler = await leveragedPosition.protocolHandlers(Protocols.AAVE_V3); + const oldHandler = await leveragedPosition.protocolHandlers(DebtProtocols.AAVE_V3); expect(oldHandler).to.not.equal(ethers.ZeroAddress); const newHandler = ethers.Wallet.createRandom().address; // Encode the setProtocolHandler call const callData = leveragedPosition.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -147,40 +152,32 @@ describe("Set Protocol Handler", function () { // Execute the transaction await expect(timelock.execute(target, value, callData, predecessor, salt)) .to.emit(leveragedPosition, "ProtocolHandlerUpdated") - .withArgs(Protocols.AAVE_V3, oldHandler, newHandler); + .withArgs(DebtProtocols.AAVE_V3, oldHandler, newHandler); - expect(await leveragedPosition.protocolHandlers(Protocols.AAVE_V3)).to.equal(newHandler); + expect(await leveragedPosition.protocolHandlers(DebtProtocols.AAVE_V3)).to.equal(newHandler); }); - it("should allow admin to revoke CRITICAL_ROLE via registry", async function () { - const { leveragedPosition, protocolRegistry } = await loadFixture(deployLeveragedPositionWithRegistry); + it("should not let a CRITICAL_ROLE grantee bypass the immutable timelock", async function () { + const { leveragedPosition, protocolRegistry } = await deployLeveragedPositionWithRegistry(); const [, newAdmin] = await ethers.getSigners(); // Grant CRITICAL_ROLE to newAdmin via registry await protocolRegistry.grantRole(CRITICAL_ROLE, newAdmin.address); - // Verify newAdmin can update handler const handler1 = ethers.Wallet.createRandom().address; - await leveragedPosition.connect(newAdmin).setProtocolHandler(Protocols.AAVE_V3, handler1); - - // Revoke CRITICAL_ROLE from newAdmin via registry - await protocolRegistry.revokeRole(CRITICAL_ROLE, newAdmin.address); - - // newAdmin should no longer be able to update handler - const newHandler = ethers.Wallet.createRandom().address; await expect( - leveragedPosition.connect(newAdmin).setProtocolHandler(Protocols.AAVE_V3, newHandler), - ).to.be.revertedWith("Caller does not have CRITICAL_ROLE"); + leveragedPosition.connect(newAdmin).setProtocolHandler(DebtProtocols.AAVE_V3, handler1), + ).to.be.revertedWithCustomError(leveragedPosition, "OnlyTimelock"); }); it("should revert execution if timelock delay has not passed", async function () { - const { leveragedPosition, timelock } = await loadFixture(deployLeveragedPositionWithRegistry); + const { leveragedPosition, timelock } = await deployLeveragedPositionWithRegistry(); const newHandler = ethers.Wallet.createRandom().address; // Encode the setProtocolHandler call const callData = leveragedPosition.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -196,26 +193,25 @@ describe("Set Protocol Handler", function () { // Try to execute immediately without waiting - should revert await expect(timelock.execute(target, value, callData, predecessor, salt)).to.be.reverted; }); - }); describe("SafeDebtManager", function () { it("should allow address with CRITICAL_ROLE (timelock) to update handler via schedule/execute", async function () { - const { safeModule, protocolRegistry } = await loadFixture(deploySafeContractFixture); + const { safeModule, protocolRegistry } = await deploySafeContractFixture(); // Get the timelock contract const timelockAddress = await protocolRegistry.timelock(); const timelock = await ethers.getContractAt("TimelockController", timelockAddress); // Get current handler - const oldHandler = await safeModule.protocolHandlers(Protocols.AAVE_V3); + const oldHandler = await safeModule.protocolHandlers(DebtProtocols.AAVE_V3); // Create a new dummy handler address const newHandler = ethers.Wallet.createRandom().address; // Encode the setProtocolHandler call const callData = safeModule.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -234,26 +230,25 @@ describe("Set Protocol Handler", function () { // Execute the transaction await expect(timelock.execute(target, value, callData, predecessor, salt)) .to.emit(safeModule, "ProtocolHandlerUpdated") - .withArgs(Protocols.AAVE_V3, oldHandler, newHandler); + .withArgs(DebtProtocols.AAVE_V3, oldHandler, newHandler); // Verify handler was updated - expect(await safeModule.protocolHandlers(Protocols.AAVE_V3)).to.equal(newHandler); + expect(await safeModule.protocolHandlers(DebtProtocols.AAVE_V3)).to.equal(newHandler); }); - it("should revert if caller does not have CRITICAL_ROLE", async function () { - const { safeModule } = await loadFixture(deploySafeContractFixture); + it("should reject a caller that is not the immutable timelock", async function () { + const { safeModule } = await deploySafeContractFixture(); const [, nonOwner] = await ethers.getSigners(); const newHandler = ethers.Wallet.createRandom().address; - // Try to update handler as non-owner without CRITICAL_ROLE await expect( - safeModule.connect(nonOwner).setProtocolHandler(Protocols.AAVE_V3, newHandler), - ).to.be.revertedWith("Caller does not have CRITICAL_ROLE"); + safeModule.connect(nonOwner).setProtocolHandler(DebtProtocols.AAVE_V3, newHandler), + ).to.be.revertedWithCustomError(safeModule, "OnlyTimelock"); }); it("should revert if new handler is zero address", async function () { - const { safeModule, protocolRegistry } = await loadFixture(deploySafeContractFixture); + const { safeModule, protocolRegistry } = await deploySafeContractFixture(); // Get the timelock contract const timelockAddress = await protocolRegistry.timelock(); @@ -261,7 +256,7 @@ describe("Set Protocol Handler", function () { // Encode the setProtocolHandler call with zero address const callData = safeModule.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, ethers.ZeroAddress, ]); @@ -282,21 +277,21 @@ describe("Set Protocol Handler", function () { }); it("should allow updating an existing handler to a new address", async function () { - const { safeModule, protocolRegistry } = await loadFixture(deploySafeContractFixture); + const { safeModule, protocolRegistry } = await deploySafeContractFixture(); // Get the timelock contract const timelockAddress = await protocolRegistry.timelock(); const timelock = await ethers.getContractAt("TimelockController", timelockAddress); // Get current handler for AAVE_V3 - const oldHandler = await safeModule.protocolHandlers(Protocols.AAVE_V3); + const oldHandler = await safeModule.protocolHandlers(DebtProtocols.AAVE_V3); expect(oldHandler).to.not.equal(ethers.ZeroAddress); const newHandler = ethers.Wallet.createRandom().address; // Encode the setProtocolHandler call const callData = safeModule.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -315,34 +310,26 @@ describe("Set Protocol Handler", function () { // Execute the transaction await expect(timelock.execute(target, value, callData, predecessor, salt)) .to.emit(safeModule, "ProtocolHandlerUpdated") - .withArgs(Protocols.AAVE_V3, oldHandler, newHandler); + .withArgs(DebtProtocols.AAVE_V3, oldHandler, newHandler); - expect(await safeModule.protocolHandlers(Protocols.AAVE_V3)).to.equal(newHandler); + expect(await safeModule.protocolHandlers(DebtProtocols.AAVE_V3)).to.equal(newHandler); }); - it("should allow admin to revoke CRITICAL_ROLE via registry", async function () { - const { safeModule, protocolRegistry } = await loadFixture(deploySafeContractFixture); + it("should not let a CRITICAL_ROLE grantee bypass the immutable timelock", async function () { + const { safeModule, protocolRegistry } = await deploySafeContractFixture(); const [, newAdmin] = await ethers.getSigners(); // Grant CRITICAL_ROLE to newAdmin via registry await protocolRegistry.grantRole(CRITICAL_ROLE, newAdmin.address); - // Verify newAdmin can update handler const handler1 = ethers.Wallet.createRandom().address; - await safeModule.connect(newAdmin).setProtocolHandler(Protocols.AAVE_V3, handler1); - - // Revoke CRITICAL_ROLE from newAdmin via registry - await protocolRegistry.revokeRole(CRITICAL_ROLE, newAdmin.address); - - // newAdmin should no longer be able to update handler - const newHandler = ethers.Wallet.createRandom().address; await expect( - safeModule.connect(newAdmin).setProtocolHandler(Protocols.AAVE_V3, newHandler), - ).to.be.revertedWith("Caller does not have CRITICAL_ROLE"); + safeModule.connect(newAdmin).setProtocolHandler(DebtProtocols.AAVE_V3, handler1), + ).to.be.revertedWithCustomError(safeModule, "OnlyTimelock"); }); it("should revert execution if timelock delay has not passed", async function () { - const { safeModule, protocolRegistry } = await loadFixture(deploySafeContractFixture); + const { safeModule, protocolRegistry } = await deploySafeContractFixture(); // Get the timelock contract const timelockAddress = await protocolRegistry.timelock(); @@ -352,7 +339,7 @@ describe("Set Protocol Handler", function () { // Encode the setProtocolHandler call const callData = safeModule.interface.encodeFunctionData("setProtocolHandler", [ - Protocols.AAVE_V3, + DebtProtocols.AAVE_V3, newHandler, ]); @@ -368,6 +355,5 @@ describe("Set Protocol Handler", function () { // Try to execute immediately without waiting - should revert await expect(timelock.execute(target, value, callData, predecessor, salt)).to.be.reverted; }); - }); }); diff --git a/test/constants.ts b/test/helpers/constants.ts similarity index 83% rename from test/constants.ts rename to test/helpers/constants.ts index cf25483..337f71a 100644 --- a/test/constants.ts +++ b/test/helpers/constants.ts @@ -27,9 +27,8 @@ export { eUSD_ADDRESS, MAI_ADDRESS, sUSDS_ADDRESS, -} from "../contractAddresses"; - -export const UNISWAP_V3_SWAP_ROUTER_ADDRESS = "0x2626664c2603336E57B271c5C0b26F421741e481"; + UNISWAP_V3_SWAP_ROUTER_ADDRESS, +} from "../../contractAddresses"; export const TEST_ADDRESS = "0x50fe1109188A0B666c4d78908E3E539D73F97E33"; export const TEST_FEE_BENEFICIARY_ADDRESS = "0xF7A9C820e0dcE141AC2F56324697A9E8AA82F7Fa"; @@ -39,13 +38,11 @@ export const ETH_USDbC_POOL = "0x3B8000CD10625ABdC7370fb47eD4D4a9C6311fD5"; // 0 export const EURC_USDC_POOL = "0x7279c08a36333e12c3fc81747963264c100d66fb"; // 0.05% fee export const GHO_USDC_POOL = "0xbaF56aeD39b4583c526971Ab51f8F2D4d8e59eb7"; // 0.3% fee export const cbETH_ETH_POOL = "0xA9DaFa443a02FBc907Cb0093276B3E6F4ef02A46"; -export const cbETH_USDC_POOL = "0xa8E4C55D6dAf4D768aeBa2378c1AD94c112Ef48a"; export const cbBTC_USDC_POOL = "0xE9e25E35aa99A2A60155010802b81A25C45bA185"; export const ETH_USDC_POOL = "0xb4CB800910B228ED3d0834cF79D697127BBB00e5"; // 0.01% fee export const DAI_USDC_POOL = "0xC18F50d6A832f12F6DcAaeEe8D0c87A65B96787E"; -export const MAI_USDC_POOL = "0x903318Ca6d0d4059840cbfA3FC2891d04Ba1Ee12"; // 0.05% fee -export enum Protocols { +export enum DebtProtocols { AAVE_V3 = 0, COMPOUND = 1, MORPHO = 2, diff --git a/test/deployProtocolRegistry.ts b/test/helpers/deployProtocolRegistry.ts similarity index 94% rename from test/deployProtocolRegistry.ts rename to test/helpers/deployProtocolRegistry.ts index e276ee8..9f2616e 100644 --- a/test/deployProtocolRegistry.ts +++ b/test/helpers/deployProtocolRegistry.ts @@ -1,5 +1,5 @@ import { ethers } from "hardhat"; -import { mcbETH, mUSDC, mDAI } from "./protocols/moonwell"; +import { mcbETH, mUSDC, mDAI } from "./protocolsDebt/moonwell"; import { cbETH_ADDRESS, USDC_ADDRESS, @@ -14,10 +14,10 @@ import { PARASWAP_V6_CONTRACT_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, } from "./constants"; -import { USDC_COMET_ADDRESS, WETH_COMET_ADDRESS } from "./protocols/compound"; -import { mAERO, mcbBTC, mEURC, mWeETH, mWETH, mwstETH, USDS_COMET_ADDRESS } from "../contractAddresses"; +import { USDC_COMET_ADDRESS, WETH_COMET_ADDRESS } from "./protocolsDebt/compound"; +import { mAERO, mcbBTC, mEURC, mWeETH, mWETH, mwstETH, USDS_COMET_ADDRESS } from "../../contractAddresses"; import { getGasOptions } from "./deployUtils"; -import { FLUID_VAULT_RESOLVER } from "./protocols/fluid"; +import { FLUID_VAULT_RESOLVER } from "./protocolsDebt/fluid"; export async function deployProtocolRegistry() { const signers = await ethers.getSigners(); diff --git a/test/helpers/deployRealSafe.ts b/test/helpers/deployRealSafe.ts new file mode 100644 index 0000000..5c3f770 --- /dev/null +++ b/test/helpers/deployRealSafe.ts @@ -0,0 +1,73 @@ +import { ethers } from "hardhat"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +// Canonical Safe v1.4.1 deployments on Base mainnet. +const SAFE_PROXY_FACTORY_ADDRESS = "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67"; +const SAFE_L2_SINGLETON_ADDRESS = "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762"; +const COMPATIBILITY_FALLBACK_HANDLER_ADDRESS = "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99"; + +const FACTORY_ABI = [ + "function createProxyWithNonce(address _singleton, bytes initializer, uint256 saltNonce) returns (address proxy)", + "event ProxyCreation(address indexed proxy, address singleton)", +]; +const SAFE_ABI = [ + "function setup(address[] _owners, uint256 _threshold, address to, bytes data, address fallbackHandler, address paymentToken, uint256 payment, address paymentReceiver)", + "function enableModule(address module)", + "function isModuleEnabled(address module) view returns (bool)", + "function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)", +]; + +/// Deploys a real Safe proxy (v1.4.1 L2 singleton, CompatibilityFallbackHandler) +/// on the fork with `owner` as its sole owner at threshold 1. +export async function deployRealSafe(owner: HardhatEthersSigner): Promise { + const factory = new ethers.Contract(SAFE_PROXY_FACTORY_ADDRESS, FACTORY_ABI, owner); + const initializer = new ethers.Interface(SAFE_ABI).encodeFunctionData("setup", [ + [owner.address], + 1, + ethers.ZeroAddress, + "0x", + COMPATIBILITY_FALLBACK_HANDLER_ADDRESS, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]); + const receipt = await (await factory.createProxyWithNonce(SAFE_L2_SINGLETON_ADDRESS, initializer, 0)).wait(); + const created = receipt!.logs + .map((log) => { + try { + return factory.interface.parseLog(log); + } catch { + return null; + } + }) + .find((parsed) => parsed?.name === "ProxyCreation"); + if (!created) throw new Error("ProxyCreation event not found"); + return created.args.proxy as string; +} + +/// Enables `module` on the Safe through a real owner-executed execTransaction, +/// using the pre-validated signature scheme (v = 1, msg.sender == owner). +export async function enableModuleOnSafe( + safeAddress: string, + owner: HardhatEthersSigner, + module: string, +): Promise { + const safe = new ethers.Contract(safeAddress, SAFE_ABI, owner); + const data = safe.interface.encodeFunctionData("enableModule", [module]); + const preValidatedSignature = ethers.concat([ethers.zeroPadValue(owner.address, 32), ethers.ZeroHash, "0x01"]); + await ( + await safe.execTransaction( + safeAddress, + 0, + data, + 0, + 0, + 0, + 0, + ethers.ZeroAddress, + ethers.ZeroAddress, + preValidatedSignature, + ) + ).wait(); + if (!(await safe.isModuleEnabled(module))) throw new Error("module not enabled on Safe"); +} diff --git a/test/deployUtils.ts b/test/helpers/deployUtils.ts similarity index 80% rename from test/deployUtils.ts rename to test/helpers/deployUtils.ts index f1441fe..b9cc266 100644 --- a/test/deployUtils.ts +++ b/test/helpers/deployUtils.ts @@ -10,7 +10,7 @@ import { GHO_ADDRESS, MAI_ADDRESS, PARASWAP_V6_CONTRACT_ADDRESS, - Protocols, + DebtProtocols, sUSDS_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, USDbC_ADDRESS, @@ -19,8 +19,8 @@ import { WETH_ADDRESS, wstETH_ADDRESS, } from "./constants"; -import { MORPHO_ADDRESS } from "./protocols/morpho"; -import { COMPTROLLER_ADDRESS } from "./protocols/moonwell"; +import { MORPHO_ADDRESS } from "./protocolsDebt/morpho"; +import { COMPTROLLER_ADDRESS } from "./protocolsDebt/moonwell"; import { deployProtocolRegistry } from "./deployProtocolRegistry"; export async function deployMaliciousUniswapV3Pool(targetHandler: string) { @@ -44,8 +44,8 @@ export async function deployHandlers() { const gasOptions = await getGasOptions(); - const AaveV3Handler = await hre.ethers.getContractFactory("AaveV3Handler"); - const aaveV3Handler = await AaveV3Handler.deploy( + const AaveV3DebtHandler = await hre.ethers.getContractFactory("AaveV3DebtHandler"); + const aaveV3Handler = await AaveV3DebtHandler.deploy( AAVE_V3_POOL_ADDRESS, AAVE_V3_DATA_PROVIDER_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, @@ -53,37 +53,37 @@ export async function deployHandlers() { gasOptions, ); await aaveV3Handler.waitForDeployment(); - console.log("AaveV3Handler deployed to:", await aaveV3Handler.getAddress()); + console.log("AaveV3DebtHandler deployed to:", await aaveV3Handler.getAddress()); - const CompoundHandler = await hre.ethers.getContractFactory("CompoundHandler"); - const compoundHandler = await CompoundHandler.deploy(registryAddress, UNISWAP_V3_FACTORY_ADDRESS, gasOptions); + const CompoundDebtHandler = await hre.ethers.getContractFactory("CompoundDebtHandler"); + const compoundHandler = await CompoundDebtHandler.deploy(registryAddress, UNISWAP_V3_FACTORY_ADDRESS, gasOptions); await compoundHandler.waitForDeployment(); - console.log("CompoundHandler deployed to:", await compoundHandler.getAddress()); + console.log("CompoundDebtHandler deployed to:", await compoundHandler.getAddress()); - const MoonwellHandler = await hre.ethers.getContractFactory("MoonwellHandler"); - const moonwellHandler = await MoonwellHandler.deploy( + const MoonwellDebtHandler = await hre.ethers.getContractFactory("MoonwellDebtHandler"); + const moonwellHandler = await MoonwellDebtHandler.deploy( COMPTROLLER_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registryAddress, gasOptions, ); await moonwellHandler.waitForDeployment(); - console.log("MoonwellHandler deployed to:", await moonwellHandler.getAddress()); + console.log("MoonwellDebtHandler deployed to:", await moonwellHandler.getAddress()); - const FluidHandler = await hre.ethers.getContractFactory("FluidSafeHandler"); + const FluidHandler = await hre.ethers.getContractFactory("FluidSafeDebtHandler"); const fluidHandler = await FluidHandler.deploy(UNISWAP_V3_FACTORY_ADDRESS, registryAddress, gasOptions); await fluidHandler.waitForDeployment(); console.log("FluidHandler deployed to:", await fluidHandler.getAddress()); - const MorphoHandler = await hre.ethers.getContractFactory("MorphoHandler"); - const morphoHandler = await MorphoHandler.deploy( + const MorphoDebtHandler = await hre.ethers.getContractFactory("MorphoDebtHandler"); + const morphoHandler = await MorphoDebtHandler.deploy( MORPHO_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS, registryAddress, gasOptions, ); await morphoHandler.waitForDeployment(); - console.log("MorphoHandler deployed to:", await morphoHandler.getAddress()); + console.log("MorphoDebtHandler deployed to:", await morphoHandler.getAddress()); const whitelistTokens = [ USDC_ADDRESS, @@ -127,7 +127,7 @@ export async function deployLeveragedPositionContractFixture() { const LeveragedPosition = await hre.ethers.getContractFactory("LeveragedPosition"); const leveragedPosition = await LeveragedPosition.deploy( await protocolRegistry.getAddress(), - [Protocols.AAVE_V3, Protocols.COMPOUND, Protocols.MORPHO, Protocols.MOONWELL, Protocols.FLUID], + [DebtProtocols.AAVE_V3, DebtProtocols.COMPOUND, DebtProtocols.MORPHO, DebtProtocols.MOONWELL, DebtProtocols.FLUID], [ await aaveV3Handler.getAddress(), await compoundHandler.getAddress(), @@ -155,7 +155,7 @@ export async function deploySafeContractFixture() { const SafeModule = await hre.ethers.getContractFactory("SafeDebtManager"); const safeModule = await SafeModule.deploy( await protocolRegistry.getAddress(), - [Protocols.AAVE_V3, Protocols.COMPOUND, Protocols.MORPHO, Protocols.MOONWELL, Protocols.FLUID], + [DebtProtocols.AAVE_V3, DebtProtocols.COMPOUND, DebtProtocols.MORPHO, DebtProtocols.MOONWELL, DebtProtocols.FLUID], [ await aaveV3Handler.getAddress(), await compoundHandler.getAddress(), diff --git a/test/helpers/protocolHelperMap.ts b/test/helpers/protocolHelperMap.ts new file mode 100644 index 0000000..ba8fa5a --- /dev/null +++ b/test/helpers/protocolHelperMap.ts @@ -0,0 +1,16 @@ +import { DebtProtocols } from "./constants"; +import { AaveV3Helper } from "./protocolsDebt/aaveV3"; +import { CompoundHelper } from "./protocolsDebt/compound"; +import { FluidHelper } from "./protocolsDebt/fluid"; +import { MoonwellHelper } from "./protocolsDebt/moonwell"; +import { MorphoHelper } from "./protocolsDebt/morpho"; + +// Kept separate from utils.ts because protocol helpers import utility functions. +// Defining this map in utils.ts creates a circular import and can leave a helper undefined. +export const protocolHelperMap = new Map([ + [DebtProtocols.AAVE_V3, AaveV3Helper], + [DebtProtocols.COMPOUND, CompoundHelper], + [DebtProtocols.MORPHO, MorphoHelper], + [DebtProtocols.FLUID, FluidHelper], + [DebtProtocols.MOONWELL, MoonwellHelper], +]); diff --git a/test/protocols/aaveV3.ts b/test/helpers/protocolsDebt/aaveV3.ts similarity index 95% rename from test/protocols/aaveV3.ts rename to test/helpers/protocolsDebt/aaveV3.ts index e1f7a98..0601fa2 100644 --- a/test/protocols/aaveV3.ts +++ b/test/helpers/protocolsDebt/aaveV3.ts @@ -3,11 +3,11 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Contract, MaxUint256 } from "ethers"; import { AAVE_V3_POOL_ADDRESS, cbETH_ADDRESS, DEFAULT_SUPPLY_AMOUNT, TEST_ADDRESS } from "../constants"; const aaveV3ProtocolDataProvider = "0xd82a47fdebB5bf5329b09441C3DaB4b5df2153Ad"; -const aaveProtocolDataProviderAbi = require("../../externalAbi/aaveV3/aaveProtocolDataProvider.json"); +const aaveProtocolDataProviderAbi = require("../../../externalAbi/aaveV3/aaveProtocolDataProvider.json"); import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; import { approve, defaultProvider, formatAmount } from "../utils"; -import aaveDebtTokenJson from "../../externalAbi/aaveV3/aaveDebtToken.json"; -import aaveV3PoolJson from "../../externalAbi/aaveV3/aaveV3Pool.json"; +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 "../safeTestContext"; diff --git a/test/protocols/compound.ts b/test/helpers/protocolsDebt/compound.ts similarity index 81% rename from test/protocols/compound.ts rename to test/helpers/protocolsDebt/compound.ts index 8bd5303..5bda18c 100644 --- a/test/protocols/compound.ts +++ b/test/helpers/protocolsDebt/compound.ts @@ -1,8 +1,8 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Contract, MaxUint256 } from "ethers"; -import cometAbi from "../../externalAbi/compound/comet.json"; -import { approve, defaultProvider, formatAmount } from "../utils"; +import cometAbi from "../../../externalAbi/compound/comet.json"; +import { approve, defaultProvider, formatAmount, getDecimals } from "../utils"; import { AERO_ADDRESS, cbBTC_ADDRESS, @@ -88,30 +88,36 @@ export class CompoundHelper { collateralTokenAddress = cbETH_ADDRESS, customBorrowAmount?: bigint, ): Promise { - const cbETHContract = new ethers.Contract(cbETH_ADDRESS, ERC20_ABI, defaultProvider); + const cometAddress = cometAddressMap.get(debtTokenAddress)!; + const collateralContract = new ethers.Contract(collateralTokenAddress, ERC20_ABI, defaultProvider); const approveTransactionData: MetaTransactionData = { - to: cbETH_ADDRESS, + to: collateralTokenAddress, value: "0", - data: cbETHContract.interface.encodeFunctionData("approve", [USDC_COMET_ADDRESS, ethers.parseEther("1")]), + data: collateralContract.interface.encodeFunctionData("approve", [cometAddress, ethers.MaxUint256]), operation: OperationType.Call, }; - const cometContract = new ethers.Contract(USDC_COMET_ADDRESS, cometAbi, defaultProvider); + const cometContract = new ethers.Contract(cometAddress, cometAbi, defaultProvider); + const collateralDecimals = await getDecimals(collateralTokenAddress); + const debtDecimals = await getDecimals(debtTokenAddress); const supplyTransactionData: MetaTransactionData = { - to: USDC_COMET_ADDRESS, + to: cometAddress, value: "0", data: cometContract.interface.encodeFunctionData("supply", [ - cbETH_ADDRESS, - ethers.parseEther(DEFAULT_SUPPLY_AMOUNT), + collateralTokenAddress, + ethers.parseUnits(DEFAULT_SUPPLY_AMOUNT, collateralDecimals), ]), operation: OperationType.Call, }; const borrowTransactionData: MetaTransactionData = { - to: USDC_COMET_ADDRESS, + to: cometAddress, value: "0", - data: cometContract.interface.encodeFunctionData("withdraw", [USDC_ADDRESS, ethers.parseUnits("1", 6)]), + data: cometContract.interface.encodeFunctionData("withdraw", [ + debtTokenAddress, + customBorrowAmount ?? ethers.parseUnits("1", debtDecimals), + ]), operation: OperationType.Call, }; diff --git a/test/protocols/fluid.ts b/test/helpers/protocolsDebt/fluid.ts similarity index 81% rename from test/protocols/fluid.ts rename to test/helpers/protocolsDebt/fluid.ts index e1e8d14..a2dd722 100644 --- a/test/protocols/fluid.ts +++ b/test/helpers/protocolsDebt/fluid.ts @@ -1,8 +1,8 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Contract, MaxUint256 } from "ethers"; -import fluidAbi from "../../externalAbi/fluid/fluidVaultT1.json"; -import fluidVaultResolverAbi from "../../externalAbi/fluid/fluidVaultResolver.json"; +import fluidAbi from "../../../externalAbi/fluid/fluidVaultT1.json"; +import fluidVaultResolverAbi from "../../../externalAbi/fluid/fluidVaultResolver.json"; import { formatAmount } from "../utils"; import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; import { @@ -53,12 +53,21 @@ export class FluidHelper { async getPosition(vaultAddress: string, userAddress) { const resolver = new ethers.Contract(FLUID_VAULT_RESOLVER, fluidVaultResolverAbi, this.signer); const positions = await resolver.positionsByUser(userAddress); - const positionIndex = positions[1].findIndex((vault) => vault[0].toLowerCase() === vaultAddress); - if (positionIndex === -1) { + + // Every past run leaves its emptied position NFT behind, so the test Safe holds several on + // the same vault. Taking the FIRST match returns the oldest — supply 0 — and borrowing + // against zero collateral makes Fluid divide by zero (Panic 0x12, surfaced as GS013). + // Prefer the funded position, then the newest, so the borrow lands on what we just supplied. + const matches = positions[1] + .map((vault, i) => ({ vault, position: positions[0][i] })) + .filter(({ vault }) => vault[0].toLowerCase() === vaultAddress.toLowerCase()); + if (matches.length === 0) { console.log("No position found on vault: " + vaultAddress); return; } - return positions[0][positionIndex]; + const funded = matches.filter(({ position }) => position[9] > 0n); + const pool = funded.length > 0 ? funded : matches; + return pool.reduce((newest, c) => (c.position[0] > newest.position[0] ? c : newest)).position; } async getDebtAmount(vaultAddress: string, userAddress?: string): Promise { diff --git a/test/protocols/moonwell.ts b/test/helpers/protocolsDebt/moonwell.ts similarity index 96% rename from test/protocols/moonwell.ts rename to test/helpers/protocolsDebt/moonwell.ts index 4569084..39063e1 100644 --- a/test/protocols/moonwell.ts +++ b/test/helpers/protocolsDebt/moonwell.ts @@ -4,11 +4,11 @@ import { DEFAULT_SUPPLY_AMOUNT, TEST_ADDRESS, WETH_ADDRESS } from "../constants" import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; import { approve, defaultProvider, formatAmount, getDecimals } from "../utils"; import { MetaTransactionData, OperationType } from "@safe-global/types-kit"; -import { mContractAddressMap, mcbETH, mUSDC, mDAI } from "../../contractAddresses"; +import { mContractAddressMap, mcbETH, mUSDC, mDAI } from "../../../contractAddresses"; -const MErc20DelegatorAbi = require("../../externalAbi/moonwell/MErc20Delegator.json"); -const ComptrollerAbi = require("../../externalAbi/moonwell/comptroller.json"); -const ViewAbi = require("../../externalAbi/moonwell/moonwellViewsV3.json"); +const MErc20DelegatorAbi = require("../../../externalAbi/moonwell/MErc20Delegator.json"); +const ComptrollerAbi = require("../../../externalAbi/moonwell/comptroller.json"); +const ViewAbi = require("../../../externalAbi/moonwell/moonwellViewsV3.json"); export const COMPTROLLER_ADDRESS = "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c"; const view_address = "0x821ff3a967b39bcbe8a018a9b1563eaf878bad39"; diff --git a/test/protocols/morpho.ts b/test/helpers/protocolsDebt/morpho.ts similarity index 98% rename from test/protocols/morpho.ts rename to test/helpers/protocolsDebt/morpho.ts index 386cb22..6ddf8d1 100644 --- a/test/protocols/morpho.ts +++ b/test/helpers/protocolsDebt/morpho.ts @@ -14,8 +14,8 @@ import { import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; import { approve, defaultProvider, formatAmount } from "../utils"; -import chainAgnosticBundlerV2Abi from "../../externalAbi/morpho/chainAgnosticBundlerV2.json"; -import morphoAbi from "../../externalAbi/morpho/morpho.json"; +import chainAgnosticBundlerV2Abi from "../../../externalAbi/morpho/chainAgnosticBundlerV2.json"; +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 "../safeTestContext"; diff --git a/test/safeTestContext.ts b/test/helpers/safeTestContext.ts similarity index 100% rename from test/safeTestContext.ts rename to test/helpers/safeTestContext.ts diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts new file mode 100644 index 0000000..fa6c139 --- /dev/null +++ b/test/helpers/utils.ts @@ -0,0 +1,294 @@ +import { ethers } from "hardhat"; +import { Eip1193Provider, RequestArguments } from "@safe-global/protocol-kit"; +import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; +import { MaxUint256 } from "ethers"; +import axios from "axios"; + +// Read through Hardhat's fork provider so repeated integration-test calls share +// its pinned state and cache instead of hammering the upstream RPC directly. +export const defaultProvider = ethers.provider; + +export async function approve(tokenAddress: string, spenderAddress: string, signer: any) { + const token = new ethers.Contract(tokenAddress, ERC20_ABI, signer); + const approveTx = await token.approve(spenderAddress, MaxUint256); + await approveTx.wait(); + console.log("approve:" + tokenAddress + "token to " + spenderAddress); +} + +export async function getDecimals(tokenAddress: string): Promise { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, ethers.provider); + return await tokenContract.decimals(); +} + +export function formatAmount(amount: bigint): string { + return ethers.formatUnits(String(amount), 6); +} + +export async function getParaswapData( + destToken: string, + srcToken: string, + contractAddress: string, + amount: bigint, + // 0.01% fee by default + flashloanFee = 1n, + preferredDEX?: string, +) { + const url = "https://api.paraswap.io/swap"; + + // suppose flashloan fee is 0.05%, must be fetched dynamically + // use the Ceiling Division formula + const amountPlusFee = amount + (amount * flashloanFee + 9999n) / 10000n; + + // deal with debt amount is slightly increased after getting quote from Dex aggregator + const amountPlusBuffer = (BigInt(amountPlusFee) * 101n) / 100n; + console.log("amountPlusBuffer:", amountPlusBuffer); + + const srcDecimals = await getDecimals(srcToken); + const destDecimals = await getDecimals(destToken); + + const params = { + srcToken, + srcDecimals, + destToken, + destDecimals, + // destToken amount + amount: amountPlusBuffer, + // side must be BUY to use exactAmountOutSwap + side: "BUY", + network: "8453", + // 2% slippage, should be passed by user dynamically + slippage: "200", + userAddress: contractAddress, + // exclude Uniswap V3 to avoid conflict with flashloan pool. More sophisticated mechanism should be implemented + excludeDEXS: "UniswapV3,BalancerV3,UniswapV2", + version: 6.2, + }; + + let lastError: unknown; + // ParaSwap can report "Unable to build transaction" when its best route + // uses excluded Uniswap V3 even though another supported DEX has a route. + // Try the broad safe set first, then force known callback-safe adapters. + const { excludeDEXS: defaultExclusions, ...baseParams } = params; + const routeOptions = preferredDEX + ? [{ includeDEXS: preferredDEX }] + : [ + { includeDEXS: "UniswapV4" }, + { includeDEXS: "AerodromeSlipstream" }, + { includeDEXS: "AerodromeSlipstreamNewFactory" }, + { includeDEXS: "AerodromeSlipstreamFactory3" }, + { includeDEXS: "MaverickV2" }, + { includeDEXS: "SwapBasedV3" }, + { includeDEXS: "Alien" }, + { excludeDEXS: defaultExclusions }, + ]; + for (const routeOption of routeOptions) { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const response = await axios.get(url, { + params: { ...baseParams, ...routeOption }, + timeout: 30_000, + }); + if (!response?.data?.txParams || !response?.data?.priceRoute) { + throw new Error("Invalid response from ParaSwap API"); + } + + console.log("selected dex:", response.data.priceRoute.bestRoute[0].swaps[0].swapExchanges[0].exchange); + + // add 2% slippage(must be set by user) + const amountPlusSlippage = (BigInt(response.data.priceRoute.srcAmount) * 1020n) / 1000n; + + console.log("amountPlusSlippage:", amountPlusSlippage); + + return { + srcAmount: amountPlusSlippage, + swapData: response.data.txParams.data, + }; + } catch (error) { + lastError = error; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + } + + const detail = axios.isAxiosError(lastError) + ? `HTTP ${lastError.response?.status ?? "network error"}: ${JSON.stringify(lastError.response?.data ?? {})}` + : String(lastError); + throw new Error(`Failed to fetch ParaSwap data after retries (${detail})`); +} + +export async function fundETH(receiverAddress: string) { + const wallet = new ethers.Wallet(process.env.TESTING_SAFE_OWNER_KEY!, ethers.provider); // Replace with a funded Hardhat account + + const tx = await wallet.sendTransaction({ + to: receiverAddress, + value: ethers.parseEther("0.001"), + }); + + console.log("Transaction Hash:", tx.hash); + + const balance = await ethers.provider.getBalance(receiverAddress); + console.log(`Balance:`, ethers.formatEther(balance), "ETH"); +} + +const BALANCE_SLOT_CACHE = new Map(); + +/** + * Give `receiver` an ERC20 balance by writing the token's balance slot on the fork. + * + * The suites used to move collateral with a real `transfer` from the test EOA, which made every + * run depend on what that live wallet still held at the pinned fork block — a balance that only + * ever goes down. Once it ran dry the transfers reverted (WETH9 `require`s without a reason, so + * this surfaced as the opaque "reverted without a reason string"), and a wallet top-up would buy + * only a handful of further runs. Writing the slot removes the dependency entirely. + * + * The slot index is discovered by probing rather than hardcoded, so a token whose layout differs + * (or a proxy) is handled without a per-token table. Throws if no slot matches — silently funding + * nothing would resurface later as an unexplained revert. + */ +export async function dealToken(tokenAddress: string, receiverAddress: string, amount: bigint) { + const token = new ethers.Contract(tokenAddress, ERC20_ABI, ethers.provider); + const key = tokenAddress.toLowerCase(); + const probe = ethers.toBeHex(amount === 0n ? 1n : amount, 32); + + const write = async (slot: number) => { + const mapped = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["address", "uint256"], [receiverAddress, slot]), + ); + const previous = await ethers.provider.send("eth_getStorageAt", [tokenAddress, mapped, "latest"]); + await ethers.provider.send("hardhat_setStorageAt", [tokenAddress, mapped, probe]); + return { mapped, previous }; + }; + + const cached = BALANCE_SLOT_CACHE.get(key); + if (cached !== undefined) { + await write(cached); + return; + } + + for (let slot = 0; slot < 60; slot++) { + const { mapped, previous } = await write(slot); + if ((await token.balanceOf(receiverAddress)) === BigInt(probe)) { + BALANCE_SLOT_CACHE.set(key, slot); + return; + } + await ethers.provider.send("hardhat_setStorageAt", [tokenAddress, mapped, previous]); + } + throw new Error(`dealToken: could not locate the balance slot for ${tokenAddress}`); +} + +/** + * Deal `amount` (in whole tokens) using the token's own decimals. + * + * The suites previously sized every collateral transfer with `parseEther`, which is only correct + * for 18-decimal tokens — on cbBTC (8 decimals) `parseEther("0.001")` asks for ten million cbBTC. + */ +export async function dealTokenAmount(tokenAddress: string, receiverAddress: string, amount: string) { + const decimals = await getDecimals(tokenAddress); + await dealToken(tokenAddress, receiverAddress, ethers.parseUnits(amount, decimals)); +} + +/** + * Fund an address with ETH for gas fees using the first Hardhat signer (deployer) + * This is useful for funding impersonated accounts in tests + * @param receiverAddress The address to fund with ETH + * @param amount The amount of ETH to send (default: "1.0") + */ +export async function fundSignerWithETH(receiverAddress: string, amount: string = "1.0") { + const [deployer] = await ethers.getSigners(); + const tx = await deployer.sendTransaction({ + to: receiverAddress, + value: ethers.parseEther(amount), + }); + await tx.wait(); + console.log(`Funded ${receiverAddress} with ${amount} ETH for gas fees`); +} + +/** + * EIP-1193 Provider wrapper for Safe SDK + * This is used to wrap Hardhat's provider for use with Safe SDK + */ +export const eip1193Provider: Eip1193Provider = { + request: async (args: RequestArguments) => { + const { method, params } = args; + return ethers.provider.send(method, Array.isArray(params) ? params : []); + }, +}; + +/** + * SafeYieldManager SwapLeg builders — one shape for every yield test so a + * struct change is a single edit here, not one per test file. + */ +export function leg(amountOutMin: bigint | number, poolParam: string) { + return { amountOutMin, poolParam }; +} + +export const ZERO_LEG = { amountOutMin: 0, poolParam: "0x" } as const; + +/** + * Write a fresh observation into a Uniswap V3 TWAP reference pool. + * + * A live chain keeps writing observations as time passes, because the pool + * keeps being traded. A forked chain that only advances its clock does not, so + * after a multi-day `evm_increaseTime` the reference looks abandoned and the + * oracle correctly refuses it. + * + * The swap has to be big enough to MOVE THE TICK: Uniswap V3 writes an + * observation only when `state.tick != slot0Start.tick`, so a dust trade + * changes nothing. It does not disturb the reported average, because the + * observation it writes carries the PRE-swap tick and the extrapolated tail is + * zero in that same block. + */ +export async function pokeTwapPool(poolAddress: string) { + const { ethers, network } = require("hardhat"); + const erc20 = [ + "function transfer(address,uint256) returns (bool)", + "function approve(address,uint256) returns (bool)", + "function balanceOf(address) view returns (uint256)", + ]; + const poolAbi = [ + "function token0() view returns (address)", + "function token1() view returns (address)", + "function fee() view returns (uint24)", + "function slot0() view returns (uint160,int24,uint16 observationIndex,uint16,uint16,uint8,bool)", + "function observations(uint256) view returns (uint32 blockTimestamp,int56,uint160,bool)", + ]; + const pool = new ethers.Contract(poolAddress, poolAbi, ethers.provider); + const [token0, token1, fee] = await Promise.all([pool.token0(), pool.token1(), pool.fee()]); + const before = (await pool.observations((await pool.slot0()).observationIndex)).blockTimestamp; + + const [funder] = await ethers.getSigners(); + // The pool is a deterministic holder of its own token0 on the fork; the + // swap hands it straight back. + await network.provider.send("hardhat_setBalance", [poolAddress, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(poolAddress); + const poolToken0 = new ethers.Contract(token0, erc20, poolSigner); + const amountIn = (await poolToken0.balanceOf(poolAddress)) / 50n; + await (await poolToken0.transfer(funder.address, amountIn)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [poolAddress]); + + const routerAddress = "0x2626664c2603336E57B271c5C0b26F421741e481"; + await (await new ethers.Contract(token0, erc20, funder).approve(routerAddress, amountIn)).wait(); + const router = new ethers.Contract( + routerAddress, + [ + "function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) payable returns (uint256)", + ], + funder, + ); + await ( + await router.exactInputSingle({ + tokenIn: token0, + tokenOut: token1, + fee, + recipient: funder.address, + amountIn, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0, + }) + ).wait(); + + const after = (await pool.observations((await pool.slot0()).observationIndex)).blockTimestamp; + if (after === before) { + throw new Error(`pokeTwapPool(${poolAddress}) did not move the tick, so no observation was written`); + } +} diff --git a/test/ratehopperUniV3Positions.ts b/test/legacy/ratehopperUniV3Positions.ts similarity index 97% rename from test/ratehopperUniV3Positions.ts rename to test/legacy/ratehopperUniV3Positions.ts index 41e1a04..6e5c036 100644 --- a/test/ratehopperUniV3Positions.ts +++ b/test/legacy/ratehopperUniV3Positions.ts @@ -2,20 +2,19 @@ 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, + DebtProtocols, 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"; +} from "../helpers/constants"; +import { FLUID_VAULT_RESOLVER, FLUID_WETH_USDC_VAULT, FluidHelper } from "../helpers/protocolsDebt/fluid"; +import { fundSignerWithETH } from "../helpers/utils"; +import FluidVaultAbi from "../../externalAbi/fluid/fluidVaultT1.json"; // ───────────────────────────────────────────────────────────────────────── // Real Base mainnet contract addresses @@ -24,7 +23,44 @@ import FluidVaultAbi from "../externalAbi/fluid/fluidVaultT1.json"; const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; const WETH_USDC_500_POOL = "0xd0b53D9277642d899DF5C87A3966A349A798F224"; -const safeAddress = process.env.TESTING_SAFE_WALLET_ADDRESS!; +// Set per test in the fork suite's beforeEach: a freshly deployed +// MockSafeHarness, so the suite is self-contained (no env-configured +// on-chain Safe whose owner set can rotate out from under a pinned key). +let safeAddress: string; + +/// Protocol-kit-shaped adapter over MockSafeHarness so the existing +/// createTransaction / createEnableModuleTx / executeTransaction call sites +/// run unchanged. The harness executes any module call without registration, +/// so enableModule is a no-op; inner reverts (which the harness converts to a +/// false success flag) are re-thrown so failures stay loud. +class MockSafeWallet { + constructor(private readonly harness: any) {} + + async createEnableModuleTx(_module: string): Promise<{ transactions: MetaTransactionData[] }> { + return { transactions: [] }; + } + + async createTransaction(tx: { transactions: MetaTransactionData[] }): Promise<{ + transactions: MetaTransactionData[]; + }> { + return tx; + } + + async executeTransaction(tx: { transactions: MetaTransactionData[] }): Promise { + for (const t of tx.transactions) { + const [ok, ret] = await this.harness.execTransactionFromModuleReturnData.staticCall( + t.to, + t.value, + t.data, + 0, + ); + if (!ok) { + throw new Error(`Safe inner call to ${t.to} reverted (returndata: ${ret})`); + } + await (await this.harness.execTransactionFromModuleReturnData(t.to, t.value, t.data, 0)).wait(); + } + } +} // Fee config const MAX_FEE_BPS = 2000; @@ -38,6 +74,7 @@ const SLIPPAGE_BPS = 100; // 1% — per-call slippage tolerance for openLp/close // `SLIPPAGE_BPS > 0`. Production callers must supply a real quoter-derived // value here. const EXPECTED_SWAP_OUT = 1n; +const BASE_FORK_BLOCK = Number(process.env.BASE_FORK_BLOCK_NUMBER ?? 49_470_000); // Deadline passed to openLp / closeLp in tests. Uses MaxUint256 so the // staleness check never fires unintentionally. @@ -85,7 +122,7 @@ const UNISWAP_V3_POOL_ABI = [ ]; // ───────────────────────────────────────────────────────────────────────── -// Fixture — deploys ProtocolRegistry + FluidSafeHandler + SafeDebtManager + +// Fixture — deploys ProtocolRegistry + FluidSafeDebtHandler + 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 @@ -113,14 +150,17 @@ async function deployFixture() { 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()); + const FluidSafeDebtHandler = await ethers.getContractFactory("FluidSafeDebtHandler"); + const fluidHandler = await FluidSafeDebtHandler.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], + [DebtProtocols.FLUID], [await fluidHandler.getAddress()], pauser.address, ); @@ -848,30 +888,34 @@ describe("RatehopperUniV3Positions - integration (Base fork)", function () { this.timeout(300_000); let signer: ethers.Wallet; - let safeWallet: Awaited>; + let safeWallet: MockSafeWallet; 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. + // mainnet snapshot, then deploy a fresh MockSafeHarness as the Safe — + // without the 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" } }], + params: [ + { + forking: { + jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org", + blockNumber: BASE_FORK_BLOCK, + }, + }, + ], }); - signer = new ethers.Wallet(process.env.TESTING_SAFE_OWNER_KEY!, ethers.provider); + const Harness = await ethers.getContractFactory("MockSafeHarness"); + const harness = await Harness.deploy(); + await harness.waitForDeployment(); + safeAddress = await harness.getAddress(); + safeWallet = new MockSafeWallet(harness); + + signer = new ethers.Wallet(ethers.Wallet.createRandom().privateKey, 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 () { @@ -886,7 +930,7 @@ describe("RatehopperUniV3Positions - integration (Base fork)", function () { console.log("\n ─── Deployed addresses ───"); console.log(` RatehopperUniV3Positions: ${rhpAddress}`); console.log(` SafeDebtManager: ${safeDebtManagerAddress}`); - console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Safe (MockSafeHarness): ${safeAddress}`); console.log(` Treasury: ${treasury.address}`); // 1. Enable both modules on the Safe: SafeDebtManager (for exit's @@ -1038,7 +1082,7 @@ describe("RatehopperUniV3Positions - integration (Base fork)", function () { console.log("\n ─── openLp setup ───"); console.log(` RatehopperUniV3Positions: ${rhpAddress}`); - console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Safe (MockSafeHarness): ${safeAddress}`); // 1. Enable RatehopperUniV3Positions as a Safe module so openLp can drive // the swap + LP mint via Safe.execTransactionFromModule. @@ -1138,7 +1182,7 @@ describe("RatehopperUniV3Positions - integration (Base fork)", function () { console.log("\n ─── collectLp setup ───"); console.log(` RatehopperUniV3Positions: ${rhpAddress}`); - console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Safe (MockSafeHarness): ${safeAddress}`); console.log(` Treasury: ${treasury.address}`); console.log(` feeCollectBps: ${COLLECT_FEE_BPS} (${COLLECT_FEE_BPS / 100}%)`); @@ -1462,7 +1506,7 @@ describe("RatehopperUniV3Positions - integration (Base fork)", function () { console.log("\n ─── operator-path setup ───"); console.log(` RatehopperUniV3Positions: ${rhpAddress}`); - console.log(` Safe (env-driven): ${safeAddress}`); + console.log(` Safe (MockSafeHarness): ${safeAddress}`); console.log(` Operator EOA: ${operatorEOA.address}`); console.log(` Stranger EOA: ${stranger.address}`); diff --git a/test/ratehopperUniV3PositionsMocks.ts b/test/legacy/ratehopperUniV3PositionsMocks.ts similarity index 96% rename from test/ratehopperUniV3PositionsMocks.ts rename to test/legacy/ratehopperUniV3PositionsMocks.ts index 263e2f1..bdbb6a3 100644 --- a/test/ratehopperUniV3PositionsMocks.ts +++ b/test/legacy/ratehopperUniV3PositionsMocks.ts @@ -175,16 +175,28 @@ function collectLpCall( } describe("RatehopperUniV3Positions - mock harness (no fork)", function () { - it("openLp reverts on reentrancy", async function () { const ctx = await loadFixture(deployMockHarness); const rhpAddr = await ctx.rhp.getAddress(); const callData = ctx.rhp.interface.encodeFunctionData("openLp", [ - ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0n, 0n, 500, 1n, 1n, SLIP, DEADLINE + ctx.safeAddr, + USDC_AMOUNT, + 0, + 0, + 500, + 0n, + 0n, + 500, + 1n, + 1n, + SLIP, + DEADLINE, ]); await (await ctx.router.setCallback(rhpAddr, callData)).wait(); await expect( - ctx.rhp.connect(ctx.operatorEOA).openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0n, 0n, 500, 1n, 1n, SLIP, DEADLINE) + ctx.rhp + .connect(ctx.operatorEOA) + .openLp(ctx.safeAddr, USDC_AMOUNT, 0, 0, 500, 0n, 0n, 500, 1n, 1n, SLIP, DEADLINE), ).to.be.revertedWithCustomError(ctx.rhp, "ReentrancyGuardReentrantCall"); }); @@ -193,11 +205,23 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { const tokenId = await openLp(ctx); const rhpAddr = await ctx.rhp.getAddress(); const callData = ctx.rhp.interface.encodeFunctionData("closeLp", [ - ctx.safeAddr, tokenId, 500, 1n, 1n, SLIP, 10_000, 0, 0, DEADLINE, 0 + ctx.safeAddr, + tokenId, + 500, + 1n, + 1n, + SLIP, + 10_000, + 0, + 0, + DEADLINE, + 0, ]); await (await ctx.router.setCallback(rhpAddr, callData)).wait(); await expect( - ctx.rhp.connect(ctx.operatorEOA).closeLp(ctx.safeAddr, tokenId, 500, 1n, 1n, SLIP, 10_000, 0, 0, DEADLINE, 0) + ctx.rhp + .connect(ctx.operatorEOA) + .closeLp(ctx.safeAddr, tokenId, 500, 1n, 1n, SLIP, 10_000, 0, 0, DEADLINE, 0), ).to.be.revertedWithCustomError(ctx.rhp, "ReentrancyGuardReentrantCall"); }); @@ -207,11 +231,18 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { await (await ctx.npm.setOwed(tokenId, 100n, 100n)).wait(); const rhpAddr = await ctx.rhp.getAddress(); const callData = ctx.rhp.interface.encodeFunctionData("collectLp", [ - ctx.safeAddr, tokenId, true, 500, 1n, 1n, SLIP, DEADLINE + ctx.safeAddr, + tokenId, + true, + 500, + 1n, + 1n, + SLIP, + DEADLINE, ]); await (await ctx.router.setCallback(rhpAddr, callData)).wait(); await expect( - ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId, true, 500, 1n, 1n, SLIP, DEADLINE) + ctx.rhp.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId, true, 500, 1n, 1n, SLIP, DEADLINE), ).to.be.revertedWithCustomError(ctx.rhp, "ReentrancyGuardReentrantCall"); }); @@ -472,13 +503,12 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { // ── Access-control modifier branches ──────────────────────────────── - it("onlyOperatorOrSafe allows the Safe itself to call the functions directly", async function () { const ctx = await loadFixture(deployMockHarness); - + const safeSigner = await ethers.getImpersonatedSigner(ctx.safeAddr); await ctx.deployer.sendTransaction({ to: ctx.safeAddr, value: ethers.parseEther("1") }); - + // openLp await (await ctx.router.setOutput(WETH_OUT)).wait(); await ( @@ -492,9 +522,7 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { // collectLp await (await ctx.npm.setOwed(tokenId, 100n, 100n)).wait(); await ( - await ctx.rhp - .connect(safeSigner) - .collectLp(ctx.safeAddr, tokenId, false, 500, 1n, 1n, SLIP, DEADLINE) + await ctx.rhp.connect(safeSigner).collectLp(ctx.safeAddr, tokenId, false, 500, 1n, 1n, SLIP, DEADLINE) ).wait(); // closeLp @@ -737,6 +765,19 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { expect((await ctx.usdc.balanceOf(ctx.treasury.address)) - tUsdc0).to.equal(0n); }); + it("closeLp waives the performance fee when the treasury transfer returns false", async function () { + const ctx = await loadFixture(deployMockHarness); + const tokenId = await openLp(ctx); + await (await ctx.router.setOutput(1_000_000n)).wait(); + await (await ctx.usdc.setFalseTransferTo(ctx.treasury.address)).wait(); + + 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)).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); @@ -817,10 +858,9 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { expect(await ctx.npm.ownerOf(tokenId)).to.equal(ctx.safeAddr); }); - it("_chargeCollectFee with feeCollectBps = 10000 takes 100% of fees and forwards 0 to Safe", async function () { const ctx = await loadFixture(deployMockHarness); - + // Deploy a new RHP with MAX_FEE_BPS = 10000 const RHP = await ethers.getContractFactory("RatehopperUniV3Positions"); const rhp100 = await RHP.deploy( @@ -837,7 +877,7 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { ctx.deployer.address, ctx.deployer.address, 0, - 0 + 0, ); await rhp100.waitForDeployment(); await (await ctx.reg.connect(ctx.deployer).setOperator(ctx.operatorEOA.address)).wait(); @@ -864,7 +904,9 @@ describe("RatehopperUniV3Positions - mock harness (no fork)", function () { const sWeth0 = await ctx.weth.balanceOf(ctx.safeAddr); const sUsdc0 = await ctx.usdc.balanceOf(ctx.safeAddr); - await (await rhp100.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId, false, 500, 1n, 1n, SLIP, DEADLINE)).wait(); + await ( + await rhp100.connect(ctx.operatorEOA).collectLp(ctx.safeAddr, tokenId, false, 500, 1n, 1n, SLIP, DEADLINE) + ).wait(); expect((await ctx.weth.balanceOf(ctx.treasury.address)) - tWeth0).to.equal(owed0); expect((await ctx.weth.balanceOf(ctx.safeAddr)) - sWeth0).to.equal(0n); diff --git a/test/ratehopperUniV3PositionsTimelock.ts b/test/legacy/ratehopperUniV3PositionsTimelock.ts similarity index 99% rename from test/ratehopperUniV3PositionsTimelock.ts rename to test/legacy/ratehopperUniV3PositionsTimelock.ts index 81d22de..04312a1 100644 --- a/test/ratehopperUniV3PositionsTimelock.ts +++ b/test/legacy/ratehopperUniV3PositionsTimelock.ts @@ -7,7 +7,7 @@ import { UNISWAP_V3_SWAP_ROUTER_ADDRESS, USDC_ADDRESS, WETH_ADDRESS, -} from "./constants"; +} from "../helpers/constants"; const UNISWAP_V3_NPM_ADDRESS = "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1"; const MAX_FEE_BPS = 2000; diff --git a/test/protocolRegistry.ts b/test/registry/protocolRegistry.ts similarity index 99% rename from test/protocolRegistry.ts rename to test/registry/protocolRegistry.ts index fb24b3e..d0c9862 100644 --- a/test/protocolRegistry.ts +++ b/test/registry/protocolRegistry.ts @@ -1,10 +1,10 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { ProtocolRegistry } from "../typechain-types"; +import { ProtocolRegistry } from "../../typechain-types"; import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -import { USDC_ADDRESS, cbETH_ADDRESS, WETH_ADDRESS, DAI_ADDRESS } from "./constants"; -import { PARASWAP_V6_CONTRACT_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS } from "../contractAddresses"; +import { USDC_ADDRESS, cbETH_ADDRESS, WETH_ADDRESS, DAI_ADDRESS } from "../helpers/constants"; +import { PARASWAP_V6_CONTRACT_ADDRESS, UNISWAP_V3_FACTORY_ADDRESS } from "../../contractAddresses"; describe("ProtocolRegistry - setTokenMContract and setTokenCContract Tests", function () { let protocolRegistry: ProtocolRegistry; diff --git a/test/protocolRegistryTimelock.ts b/test/registry/protocolRegistryTimelock.ts similarity index 98% rename from test/protocolRegistryTimelock.ts rename to test/registry/protocolRegistryTimelock.ts index 3f862f8..fb0d1e3 100644 --- a/test/protocolRegistryTimelock.ts +++ b/test/registry/protocolRegistryTimelock.ts @@ -1,11 +1,11 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { ProtocolRegistry } from "../typechain-types"; -import { TimelockController } from "../typechain-types/@openzeppelin/contracts/governance/TimelockController"; +import { ProtocolRegistry } from "../../typechain-types"; +import { TimelockController } from "../../typechain-types/@openzeppelin/contracts/governance/TimelockController"; import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; -import { WETH_ADDRESS } from "./constants"; -import { UNISWAP_V3_FACTORY_ADDRESS } from "../contractAddresses"; +import { WETH_ADDRESS } from "../helpers/constants"; +import { UNISWAP_V3_FACTORY_ADDRESS } from "../../contractAddresses"; describe("ProtocolRegistry - Timelock Integration Tests", function () { const TWO_DAYS = 2 * 24 * 60 * 60; // 2 days in seconds diff --git a/test/scripts/lpMath.ts b/test/scripts/lpMath.ts new file mode 100644 index 0000000..e55ed47 --- /dev/null +++ b/test/scripts/lpMath.ts @@ -0,0 +1,413 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { + MAX_TICK, + MIN_TICK, + alignTick, + decreaseMinimums, + getAmountsForLiquidity, + getLiquidityForAmounts, + getSqrtRatioAtTick, + mintMinimums, + switchMintMinimums, +} from "../../scripts/lpMath"; + +// ───────────────────────────────────────────────────────────────────────── +// The operations scripts build transaction minima off these helpers, so the +// helpers are diffed against the pinned Uniswap libraries themselves +// (TickMath / LiquidityAmounts / SqrtPriceMath via LpMathProbe) rather than +// against numbers copied into the test. Everything is bigint end to end: a +// single Number() would round the exact quantity a minimum exists to pin. +// ───────────────────────────────────────────────────────────────────────── + +async function deployProbe() { + const Probe = await ethers.getContractFactory("LpMathProbe"); + const probe = await Probe.deploy(); + await probe.waitForDeployment(); + return { probe }; +} + +// WETH/USDC-shaped fixture: 18-decimal token0, 6-decimal token1, price ~4000. +const TICK_SPACING = 10; +const IN_RANGE_TICK = -201_770; +const WETH = 10n ** 18n; +const USDC = 10n ** 6n; + +describe("scripts/lpMath", function () { + describe("getSqrtRatioAtTick vs Uniswap TickMath", function () { + it("matches the library at the extremes, at zero, and on both signs", async function () { + const { probe } = await loadFixture(deployProbe); + const ticks = [ + MIN_TICK, + MIN_TICK + 1, + -887_000, + -201_771, + -201_770, + -60, + -1, + 0, + 1, + 60, + 201_770, + 887_000, + MAX_TICK - 1, + MAX_TICK, + ]; + for (const tick of ticks) { + expect(getSqrtRatioAtTick(tick), `tick ${tick}`).to.equal(await probe.sqrtPriceAtTick(tick)); + } + }); + + it("matches the library across every power-of-two bit of the tick", async function () { + const { probe } = await loadFixture(deployProbe); + // Each bit selects a different Q128.128 multiplier; walking them all + // is what catches a mistyped constant. + for (let bit = 0; bit < 20; bit++) { + const tick = 1 << bit; + if (tick > MAX_TICK) break; + expect(getSqrtRatioAtTick(tick), `+2^${bit}`).to.equal(await probe.sqrtPriceAtTick(tick)); + expect(getSqrtRatioAtTick(-tick), `-2^${bit}`).to.equal(await probe.sqrtPriceAtTick(-tick)); + } + }); + + it("rejects a tick outside the representable range", function () { + expect(() => getSqrtRatioAtTick(MAX_TICK + 1)).to.throw("out of range"); + expect(() => getSqrtRatioAtTick(MIN_TICK - 1)).to.throw("out of range"); + }); + + it("floors alignment toward negative infinity so a range only widens", function () { + expect(alignTick(-201_765, 10)).to.equal(-201_770); + expect(alignTick(-201_770, 10)).to.equal(-201_770); + expect(alignTick(201_765, 10)).to.equal(201_760); + expect(alignTick(-1, 60)).to.equal(-60); + }); + }); + + describe("liquidity conversions vs Uniswap LiquidityAmounts / SqrtPriceMath", function () { + it("agrees on liquidity-from-amounts and amounts-from-liquidity, in and out of range", async function () { + const { probe } = await loadFixture(deployProbe); + const sqrtLower = getSqrtRatioAtTick(IN_RANGE_TICK - 1_000); + const sqrtUpper = getSqrtRatioAtTick(IN_RANGE_TICK + 1_000); + const prices = [ + getSqrtRatioAtTick(IN_RANGE_TICK - 5_000), // below the range + getSqrtRatioAtTick(IN_RANGE_TICK), // in range + getSqrtRatioAtTick(IN_RANGE_TICK + 5_000), // above the range + ]; + + for (const sqrtPriceX96 of prices) { + const liquidity = getLiquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, WETH, 4_000n * USDC); + expect(liquidity).to.equal( + await probe.liquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, WETH, 4_000n * USDC), + ); + + const ours = getAmountsForLiquidity(sqrtPriceX96, sqrtLower, sqrtUpper, liquidity); + const [refAmount0, refAmount1] = await probe.amountsForLiquidity( + sqrtPriceX96, + sqrtLower, + sqrtUpper, + liquidity, + ); + expect(ours.amount0).to.equal(refAmount0); + expect(ours.amount1).to.equal(refAmount1); + } + }); + + it("keeps full precision on values that would break a JS number", async function () { + const { probe } = await loadFixture(deployProbe); + // 1e24 wei is ~1e8 times Number.MAX_SAFE_INTEGER, and the odd tail + // digits are exactly what a double would drop. + const sqrtLower = getSqrtRatioAtTick(IN_RANGE_TICK - 1_000); + const sqrtUpper = getSqrtRatioAtTick(IN_RANGE_TICK + 1_000); + const sqrtPriceX96 = getSqrtRatioAtTick(IN_RANGE_TICK); + const huge = 10n ** 24n + 12_345_678_901_234_567n; + + const liquidity = getLiquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, huge, huge); + expect(liquidity).to.equal(await probe.liquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, huge, huge)); + expect(liquidity).to.be.greaterThan(BigInt(Number.MAX_SAFE_INTEGER)); + + const ours = getAmountsForLiquidity(sqrtPriceX96, sqrtLower, sqrtUpper, liquidity); + const [refAmount0, refAmount1] = await probe.amountsForLiquidity( + sqrtPriceX96, + sqrtLower, + sqrtUpper, + liquidity, + ); + expect(ours.amount0).to.equal(refAmount0); + expect(ours.amount1).to.equal(refAmount1); + expect(ours.amount0).to.be.greaterThan(BigInt(Number.MAX_SAFE_INTEGER)); + }); + + it("refuses liquidity that overflows uint128, exactly where the library reverts", async function () { + const { probe } = await loadFixture(deployProbe); + // A razor-thin range at the top of the tick space: the liquidity these + // amounts imply does not fit in uint128. The library reverts, so a + // helper that returned a number here would be handing out a minimum + // the position manager could never satisfy. + const sqrtLower = getSqrtRatioAtTick(MAX_TICK - 2_000); + const sqrtUpper = getSqrtRatioAtTick(MAX_TICK - 1_000); + const sqrtPriceX96 = getSqrtRatioAtTick(MAX_TICK - 1_500); + const huge = 10n ** 24n; + + await expect(probe.liquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, huge, huge)).to.be.reverted; + expect(() => getLiquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, huge, huge)).to.throw( + "overflows uint128", + ); + }); + }); + + describe("mint minimums", function () { + const range = (offset: number) => ({ + sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK + offset), + tickLower: IN_RANGE_TICK - 1_000, + tickUpper: IN_RANGE_TICK + 1_000, + }); + + it("returns two nonzero floors in range, each below what the mint consumes", function () { + const r = range(0); + const m = mintMinimums(r, WETH, 4_000n * USDC, 100n); + expect(m.expected0).to.be.greaterThan(0n); + expect(m.expected1).to.be.greaterThan(0n); + expect(m.amount0Min).to.be.greaterThan(0n); + expect(m.amount1Min).to.be.greaterThan(0n); + expect(m.amount0Min).to.be.lessThan(m.expected0); + expect(m.amount1Min).to.be.lessThan(m.expected1); + // 1% slippage, exact integer arithmetic. + expect(m.amount0Min).to.equal((m.expected0 * 9_900n) / 10_000n); + expect(m.amount1Min).to.equal((m.expected1 * 9_900n) / 10_000n); + }); + + it("is token0-only below the range", function () { + const m = mintMinimums(range(-5_000), WETH, 4_000n * USDC, 100n); + expect(m.amount0Min).to.be.greaterThan(0n); + expect(m.amount1Min).to.equal(0n); + }); + + it("is token1-only above the range", function () { + const m = mintMinimums(range(5_000), WETH, 4_000n * USDC, 100n); + expect(m.amount0Min).to.equal(0n); + expect(m.amount1Min).to.be.greaterThan(0n); + }); + + it("sizes the floors off the limiting side, so they stay achievable", function () { + const r = range(0); + // token1 is starved: the mint can only use the liquidity it funds, + // so the token0 floor must drop with it rather than track the ask. + const balanced = mintMinimums(r, WETH, 4_000n * USDC, 100n); + const starved = mintMinimums(r, WETH, 40n * USDC, 100n); + expect(starved.liquidity).to.be.lessThan(balanced.liquidity); + expect(starved.amount0Min).to.be.lessThan(balanced.amount0Min); + expect(starved.expected0).to.be.lessThan(WETH); + }); + + it("rejects a slippage that would make the floor meaningless", function () { + expect(() => mintMinimums(range(0), WETH, USDC, 10_000n)).to.throw("slippageBps out of range"); + }); + }); + + describe("decrease minimums", function () { + const LIQUIDITY = 10n ** 15n; + const range = (offset: number) => ({ + sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK + offset), + tickLower: IN_RANGE_TICK - 1_000, + tickUpper: IN_RANGE_TICK + 1_000, + }); + + it("puts a nonzero floor on every side the position actually holds", function () { + const d = decreaseMinimums(range(0), LIQUIDITY, 10_000n, 100n); + expect(d.amount0Min).to.be.greaterThan(0n); + expect(d.amount1Min).to.be.greaterThan(0n); + expect(d.amount0Min).to.equal((d.expected0 * 9_900n) / 10_000n); + expect(d.amount1Min).to.equal((d.expected1 * 9_900n) / 10_000n); + }); + + it("zeroes only the side the position does not hold", function () { + const below = decreaseMinimums(range(-5_000), LIQUIDITY, 10_000n, 100n); + expect(below.amount0Min).to.be.greaterThan(0n); + expect(below.amount1Min).to.equal(0n); + + const above = decreaseMinimums(range(5_000), LIQUIDITY, 10_000n, 100n); + expect(above.amount0Min).to.equal(0n); + expect(above.amount1Min).to.be.greaterThan(0n); + }); + + it("prorates a partial close by exitBps only", function () { + const full = decreaseMinimums(range(0), LIQUIDITY, 10_000n, 100n); + const half = decreaseMinimums(range(0), LIQUIDITY, 5_000n, 100n); + expect(half.liquidityRemoved).to.equal(LIQUIDITY / 2n); + expect(half.expected0).to.be.lessThan(full.expected0); + expect(half.expected1).to.be.lessThan(full.expected1); + // Half the liquidity holds half the tokens, to integer rounding. + expect(full.expected0 - half.expected0 * 2n).to.be.lessThanOrEqual(2n); + expect(full.expected1 - half.expected1 * 2n).to.be.lessThanOrEqual(2n); + }); + + it("rejects an exitBps outside 1..10000", function () { + expect(() => decreaseMinimums(range(0), LIQUIDITY, 0n, 100n)).to.throw("exitBps out of range"); + expect(() => decreaseMinimums(range(0), LIQUIDITY, 10_001n, 100n)).to.throw("exitBps out of range"); + }); + }); + + describe("encoded transaction parameters", function () { + // What the scripts actually ship is calldata. These cases assert the + // helper output survives ABI encoding unchanged, for a position that is + // in range, below it, and above it. + const RANGE = { tickLower: IN_RANGE_TICK - 1_000, tickUpper: IN_RANGE_TICK + 1_000 }; + const FIXTURES = [ + { name: "in range", offset: 0 }, + { name: "below range", offset: -5_000 }, + { name: "above range", offset: 5_000 }, + ]; + + async function managerInterface() { + return (await ethers.getContractFactory("SafeYieldManager")).interface; + } + + for (const fixture of FIXTURES) { + it(`openLp calldata carries the exact mint minima (${fixture.name})`, async function () { + const iface = await managerInterface(); + const range = { sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK + fixture.offset), ...RANGE }; + const mint = mintMinimums(range, WETH, 4_000n * USDC, 100n); + + const data = iface.encodeFunctionData("openLp", [ + 0, + { + onBehalfOf: ethers.ZeroAddress, + usdcAmount: 4_000n * USDC, + tickLower: range.tickLower, + tickUpper: range.tickUpper, + mintAmount0Min: mint.amount0Min, + mintAmount1Min: mint.amount1Min, + swap0: { amountOutMin: 1n, poolParam: "0x" }, + swap1: { amountOutMin: 0n, poolParam: "0x" }, + slippageBps: 100, + deadline: 1n, + lpPoolParam: "0x", + stake: false, + }, + ]); + const decoded = iface.decodeFunctionData("openLp", data)[1]; + expect(decoded.mintAmount0Min).to.equal(mint.amount0Min); + expect(decoded.mintAmount1Min).to.equal(mint.amount1Min); + // The unused side is zero, the used side is a real floor. + expect(decoded.mintAmount0Min + decoded.mintAmount1Min).to.be.greaterThan(0n); + }); + + it(`closeLp calldata carries the exact decrease minima (${fixture.name})`, async function () { + const iface = await managerInterface(); + const range = { sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK + fixture.offset), ...RANGE }; + const decrease = decreaseMinimums(range, 10n ** 15n, 10_000n, 100n); + + const data = iface.encodeFunctionData("closeLp", [ + 0, + { + onBehalfOf: ethers.ZeroAddress, + tokenId: 1n, + exitBps: 10_000, + swap0: { amountOutMin: 1n, poolParam: "0x" }, + swap1: { amountOutMin: 0n, poolParam: "0x" }, + slippageBps: 100, + decreaseAmount0Min: decrease.amount0Min, + decreaseAmount1Min: decrease.amount1Min, + deadline: 1n, + minUsdcOut: 0n, + }, + ]); + const decoded = iface.decodeFunctionData("closeLp", data)[1]; + expect(decoded.decreaseAmount0Min).to.equal(decrease.amount0Min); + expect(decoded.decreaseAmount1Min).to.equal(decrease.amount1Min); + expect(decoded.decreaseAmount0Min + decoded.decreaseAmount1Min).to.be.greaterThan(0n); + }); + + it(`switchLp calldata carries both floors, mint sized off the withdraw floor (${fixture.name})`, async function () { + const iface = await managerInterface(); + const range = { sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK + fixture.offset), ...RANGE }; + const decrease = decreaseMinimums(range, 10n ** 15n, 10_000n, 100n); + const mint = switchMintMinimums(range, decrease.amount0Min, decrease.amount1Min, 100n); + + const data = iface.encodeFunctionData("switchLp", [ + 0, + 1, + { + onBehalfOf: ethers.ZeroAddress, + tokenId: 1n, + decreaseAmount0Min: decrease.amount0Min, + decreaseAmount1Min: decrease.amount1Min, + tickLower: range.tickLower, + tickUpper: range.tickUpper, + mintAmount0Min: mint.amount0Min, + mintAmount1Min: mint.amount1Min, + lpPoolParam: "0x", + deadline: 1n, + }, + ]); + const decoded = iface.decodeFunctionData("switchLp", data)[2]; + expect(decoded.decreaseAmount0Min).to.equal(decrease.amount0Min); + expect(decoded.decreaseAmount1Min).to.equal(decrease.amount1Min); + expect(decoded.mintAmount0Min).to.equal(mint.amount0Min); + expect(decoded.mintAmount1Min).to.equal(mint.amount1Min); + // Sized off the accepted budget, so never above the withdraw floor. + expect(decoded.mintAmount0Min).to.be.lessThanOrEqual(decoded.decreaseAmount0Min); + expect(decoded.mintAmount1Min).to.be.lessThanOrEqual(decoded.decreaseAmount1Min); + }); + } + }); + + describe("script wiring", function () { + const read = (file: string) => + require("fs").readFileSync(require("path").join(__dirname, "../../scripts", file), "utf8"); + + it("open, close and switch all build minima from the shared helpers", function () { + expect(read("openLpBySafe.ts")).to.match(/mintMinimums\(/); + expect(read("closeLpBySafe.ts")).to.match(/decreaseMinimums\(/); + const switchSrc = read("switchLpBySafe.ts"); + expect(switchSrc).to.match(/decreaseMinimums\(/); + expect(switchSrc).to.match(/switchMintMinimums\(/); + for (const file of ["openLpBySafe.ts", "closeLpBySafe.ts", "switchLpBySafe.ts"]) { + expect(read(file), file).to.match(/from "\.\/lpSafeShared"/); + } + }); + + it("no script hard-codes a zero minimum or reaches for floating-point tick math", function () { + for (const file of ["openLpBySafe.ts", "closeLpBySafe.ts", "switchLpBySafe.ts", "lpSafeShared.ts"]) { + const src = read(file); + expect(src, `${file} mintAmount0Min`).to.not.match(/mintAmount0Min:\s*0n/); + expect(src, `${file} mintAmount1Min`).to.not.match(/mintAmount1Min:\s*0n/); + expect(src, `${file} decreaseAmount0Min`).to.not.match(/decreaseAmount0Min:\s*0n/); + expect(src, `${file} decreaseAmount1Min`).to.not.match(/decreaseAmount1Min:\s*0n/); + expect(src, `${file} Math.sqrt`).to.not.match(/Math\.sqrt/); + expect(src, `${file} Math.pow`).to.not.match(/Math\.pow|\*\* tick|1\.0001 \*\*/); + } + }); + }); + + describe("switch minimums", function () { + it("builds an achievable mint floor from the worst accepted withdrawal", function () { + const source = { + sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK), + tickLower: IN_RANGE_TICK - 1_000, + tickUpper: IN_RANGE_TICK + 1_000, + }; + const destination = { + sqrtPriceX96: getSqrtRatioAtTick(IN_RANGE_TICK), + tickLower: alignTick(IN_RANGE_TICK - 1_000, TICK_SPACING), + tickUpper: alignTick(IN_RANGE_TICK + 1_000, TICK_SPACING), + }; + const decrease = decreaseMinimums(source, 10n ** 15n, 10_000n, 100n); + const mint = switchMintMinimums(destination, decrease.amount0Min, decrease.amount1Min, 100n); + + // A withdrawal landing EXACTLY on its floor must still clear the mint + // floor — the case that reverts when the mint is sized off the + // optimistic expected withdrawal instead of the accepted budget. + const worstCase = mintMinimums(destination, decrease.amount0Min, decrease.amount1Min, 0n); + expect(mint.amount0Min).to.be.lessThanOrEqual(worstCase.expected0); + expect(mint.amount1Min).to.be.lessThanOrEqual(worstCase.expected1); + expect(mint.amount0Min + mint.amount1Min).to.be.greaterThan(0n); + + // And the real withdrawal (expected, plus collected fees) is larger + // still, so the floor only gets easier to satisfy. + expect(decrease.expected0).to.be.greaterThanOrEqual(decrease.amount0Min); + expect(decrease.expected1).to.be.greaterThanOrEqual(decrease.amount1Min); + }); + }); +}); diff --git a/test/utils.ts b/test/utils.ts deleted file mode 100644 index 94b77ee..0000000 --- a/test/utils.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ethers } from "hardhat"; -import { Eip1193Provider, RequestArguments } from "@safe-global/protocol-kit"; -import { Protocols, WETH_ADDRESS } from "./constants"; -import { abi as ERC20_ABI } from "@openzeppelin/contracts/build/contracts/ERC20.json"; -import { MaxUint256 } from "ethers"; -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import WETH_ABI from "../externalAbi/weth.json"; - -import { AaveV3Helper } from "./protocols/aaveV3"; -import { CompoundHelper } from "./protocols/compound"; -import { MorphoHelper } from "./protocols/morpho"; -import { MoonwellHelper } from "./protocols/moonwell"; -import { FluidHelper } from "./protocols/fluid"; -import axios from "axios"; - -export const protocolHelperMap = new Map([ - [Protocols.AAVE_V3, AaveV3Helper], - [Protocols.COMPOUND, CompoundHelper], - [Protocols.MORPHO, MorphoHelper], - [Protocols.FLUID, FluidHelper], - [Protocols.MOONWELL, MoonwellHelper], -]); - -export const defaultProvider = new ethers.JsonRpcProvider(process.env.BASE_RPC_URL || "https://mainnet.base.org"); - -export async function approve(tokenAddress: string, spenderAddress: string, signer: any) { - const token = new ethers.Contract(tokenAddress, ERC20_ABI, signer); - const approveTx = await token.approve(spenderAddress, MaxUint256); - await approveTx.wait(); - console.log("approve:" + tokenAddress + "token to " + spenderAddress); -} - -export async function getDecimals(tokenAddress: string): Promise { - const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC_URL || "https://mainnet.base.org"); - - const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); - return await tokenContract.decimals(); -} - -export function getAmountInMax(amountOut: bigint): bigint { - // Suppose 1% slippage is allowed. must be fetched from quote to get actual slippage - const slippage = 1.01; - const scaleFactor = 100n; - const multiplier = BigInt(slippage * Number(scaleFactor)); - return (amountOut * multiplier) / scaleFactor; -} - -export function formatAmount(amount: bigint): string { - return ethers.formatUnits(String(amount), 6); -} - -export async function wrapETH(amountIn: string, signer: HardhatEthersSigner) { - const wethContract = new ethers.Contract(WETH_ADDRESS, WETH_ABI, signer); - - const amount = ethers.parseEther(amountIn); - const tx = await wethContract.deposit({ value: amount }); - await tx.wait(); - console.log("Wrapped ETH to WETH:", amount); -} - -export async function getParaswapData( - destToken: string, - srcToken: string, - contractAddress: string, - amount: bigint, - // 0.01% fee by default - flashloanFee = 1n, -) { - const url = "https://api.paraswap.io/swap"; - - // suppose flashloan fee is 0.05%, must be fetched dynamically - // use the Ceiling Division formula - const amountPlusFee = amount + (amount * flashloanFee + 9999n) / 10000n; - - // deal with debt amount is slightly increased after getting quote from Dex aggregator - const amountPlusBuffer = (BigInt(amountPlusFee) * 101n) / 100n; - console.log("amountPlusBuffer:", amountPlusBuffer); - - const srcDecimals = await getDecimals(srcToken); - const destDecimals = await getDecimals(destToken); - - const params = { - srcToken, - srcDecimals, - destToken, - destDecimals, - // destToken amount - amount: amountPlusBuffer, - // side must be BUY to use exactAmountOutSwap - side: "BUY", - network: "8453", - // 2% slippage, should be passed by user dynamically - slippage: "200", - userAddress: contractAddress, - // exclude Uniswap V3 to avoid conflict with flashloan pool. More sophisticated mechanism should be implemented - excludeDEXS: "UniswapV3,BalancerV3,UniswapV2", - version: 6.2, - }; - - try { - const response = await axios.get(url, { params }); - if (!response?.data?.txParams || !response?.data?.priceRoute) { - throw new Error("Invalid response from ParaSwap API"); - } - - console.log("selected dex:", response.data.priceRoute.bestRoute[0].swaps[0].swapExchanges[0].exchange); - - // add 2% slippage(must be set by user) - const amountPlusSlippage = (BigInt(response.data.priceRoute.srcAmount) * 1020n) / 1000n; - - console.log("amountPlusSlippage:", amountPlusSlippage); - - return { - srcAmount: amountPlusSlippage, - swapData: response.data.txParams.data, - }; - } catch (error) { - console.error("Error fetching data from ParaSwap API:", error); - throw new Error("Failed to fetch ParaSwap data"); - } -} - -export async function fundETH(receiverAddress: string) { - const wallet = new ethers.Wallet(process.env.TESTING_SAFE_OWNER_KEY!, ethers.provider); // Replace with a funded Hardhat account - - const tx = await wallet.sendTransaction({ - to: receiverAddress, - value: ethers.parseEther("0.001"), - }); - - console.log("Transaction Hash:", tx.hash); - - const balance = await ethers.provider.getBalance(receiverAddress); - console.log(`Balance:`, ethers.formatEther(balance), "ETH"); -} - -/** - * Fund an address with ETH for gas fees using the first Hardhat signer (deployer) - * This is useful for funding impersonated accounts in tests - * @param receiverAddress The address to fund with ETH - * @param amount The amount of ETH to send (default: "1.0") - */ -export async function fundSignerWithETH(receiverAddress: string, amount: string = "1.0") { - const [deployer] = await ethers.getSigners(); - const tx = await deployer.sendTransaction({ - to: receiverAddress, - value: ethers.parseEther(amount), - }); - await tx.wait(); - console.log(`Funded ${receiverAddress} with ${amount} ETH for gas fees`); -} - -/** - * EIP-1193 Provider wrapper for Safe SDK - * This is used to wrap Hardhat's provider for use with Safe SDK - */ -export const eip1193Provider: Eip1193Provider = { - request: async (args: RequestArguments) => { - const { method, params } = args; - return ethers.provider.send(method, Array.isArray(params) ? params : []); - }, -}; diff --git a/test/yield/safeYieldManager.ts b/test/yield/safeYieldManager.ts new file mode 100644 index 0000000..4df9ce2 --- /dev/null +++ b/test/yield/safeYieldManager.ts @@ -0,0 +1,3744 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { YieldProtocol, encodeAerodromePoolParam, encodeUniV3PoolParam } from "../../contractAddresses"; +import { ZERO_LEG, leg } from "../helpers/utils"; + +// ───────────────────────────────────────────────────────────────────────── +// Mock-driven suite for SafeYieldManager + UniV3YieldHandler / +// AerodromeYieldHandler (AP-4817 adapter pattern). +// +// Reuses the mocks from RatehopperMocks.sol / RatehopperAerodromeMocks.sol +// so both protocols' openLp / closeLp / collectLp lifecycles run through the +// SINGLE manager module deterministically, plus manager-specific behavior: +// handler dispatch, per-protocol basis namespacing, delegatecall-only +// handlers, pause / per-protocol disable, and the timelocked setter surface. +// ───────────────────────────────────────────────────────────────────────── + +const abi = ethers.AbiCoder.defaultAbiCoder(); +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 UNISWAP_V3 = YieldProtocol.UNISWAP_V3; +const AERODROME = YieldProtocol.AERODROME; + +// Pool params carry the pair: abi.encode(token0, token1, feeTier | tickSpacing). +// Assigned in deployYieldManagerHarness once the mock token addresses exist; +// loadFixture snapshots make the addresses stable across tests. +let FEE_TIER: string; +let BAD_FEE_TIER: string; +let TICK_SPACING: string; + +const USDC_AMOUNT = 1_000_000n; +const HALF = USDC_AMOUNT / 2n; +const WETH_OUT = 2_000_000n; // WETH produced by the openLp swap +const TWAP_WINDOW = 1800; +const TWAP_CARDINALITY = 60; + +function timelockCall(timelock: any, manager: any, functionName: string, args: any[]) { + return timelock.execute(manager.target, manager.interface.encodeFunctionData(functionName, args)); +} + +function openParams(safeAddr: string, poolParam: string, overrides: Record = {}) { + return { + onBehalfOf: safeAddr, + usdcAmount: USDC_AMOUNT, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg(WETH_OUT, poolParam), + swap1: ZERO_LEG, + slippageBps: SLIP, + deadline: DEADLINE, + lpPoolParam: poolParam, + stake: false, + ...overrides, + }; +} + +function closeParams( + safeAddr: string, + tokenId: bigint | number, + poolParam: string, + overrides: Record = {}, +) { + return { + onBehalfOf: safeAddr, + tokenId, + exitBps: 10_000, + swap0: leg(600_000n, poolParam), + swap1: ZERO_LEG, + slippageBps: SLIP, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: DEADLINE, + minUsdcOut: 0, + ...overrides, + }; +} + +function collectParams( + safeAddr: string, + tokenId: bigint | number, + poolParam: string, + overrides: Record = {}, +) { + return { + onBehalfOf: safeAddr, + tokenId, + swapFeesToUsdc: false, + swap0: leg(1, poolParam), + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: SLIP, + deadline: DEADLINE, + ...overrides, + }; +} + +// Deploy a MockStakePool for `clNpm` and register it on the voter as `clPool`'s +// stakePool — the shared preamble of every stakePool-staking test. +async function deployStakePool(clNpm, clPool, voter) { + const StakePool = await ethers.getContractFactory("MockStakePool"); + const stakePool = await StakePool.deploy(await clNpm.getAddress()); + await stakePool.waitForDeployment(); + const stakePoolAddr = await stakePool.getAddress(); + await (await voter.setStakePool(await clPool.getAddress(), stakePoolAddr)).wait(); + return { stakePool, stakePoolAddr }; +} + +async function deployYieldManagerHarness() { + const [deployer, operatorEOA, treasury, stranger, pauser] = 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(); + const [weth, usdc] = addrA < addrB ? [tokenA, tokenB] : [tokenB, tokenA]; + const wethAddr = await weth.getAddress(); + const usdcAddr = await usdc.getAddress(); + + FEE_TIER = encodeUniV3PoolParam(wethAddr, usdcAddr, 500); + BAD_FEE_TIER = encodeUniV3PoolParam(wethAddr, usdcAddr, 10000); + TICK_SPACING = encodeAerodromePoolParam(wethAddr, usdcAddr, 100); + + // Uniswap V3 side + const UniPool = await ethers.getContractFactory("MockUniswapV3Pool"); + const uniPool = await UniPool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await uniPool.waitForDeployment(); + const UniFactory = await ethers.getContractFactory("MockUniswapV3Factory"); + const uniFactory = await UniFactory.deploy(); + await uniFactory.waitForDeployment(); + const uniPoolAddr = await uniPool.getAddress(); + await (await uniFactory.setPool(uniPoolAddr)).wait(); + const UniNPM = await ethers.getContractFactory("MockNonfungiblePositionManager"); + const uniNpm = await UniNPM.deploy(); + await uniNpm.waitForDeployment(); + const UniRouter = await ethers.getContractFactory("MockSwapRouter"); + const uniRouter = await UniRouter.deploy(); + await uniRouter.waitForDeployment(); + + // Aerodrome side + const CLPool = await ethers.getContractFactory("MockCLPool"); + const clPool = await CLPool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await clPool.waitForDeployment(); + const CLFactory = await ethers.getContractFactory("MockCLFactory"); + const clFactory = await CLFactory.deploy(); + await clFactory.waitForDeployment(); + await (await clFactory.setPool(await clPool.getAddress())).wait(); + const CLNPM = await ethers.getContractFactory("MockCLNonfungiblePositionManager"); + const clNpm = await CLNPM.deploy(); + await clNpm.waitForDeployment(); + const CLRouter = await ethers.getContractFactory("MockSlipstreamSwapRouter"); + const clRouter = await CLRouter.deploy(); + await clRouter.waitForDeployment(); + const Voter = await ethers.getContractFactory("MockVoter"); + const voter = await Voter.deploy(); + await voter.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(); + await (await reg.setWhitelisted(wethAddr, true)).wait(); + await (await reg.setWhitelisted(usdcAddr, true)).wait(); + + const UniHandler = await ethers.getContractFactory("UniV3YieldHandler"); + const uniHandler = await UniHandler.deploy( + await uniNpm.getAddress(), + usdcAddr, + await uniRouter.getAddress(), + await uniFactory.getAddress(), + ); + await uniHandler.waitForDeployment(); + + const AeroHandler = await ethers.getContractFactory("AerodromeYieldHandler"); + const aeroHandler = await AeroHandler.deploy( + await clNpm.getAddress(), + usdcAddr, + await clRouter.getAddress(), + await clFactory.getAddress(), + await voter.getAddress(), + ); + await aeroHandler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await reg.getAddress(), + usdcAddr, + wethAddr, + [UNISWAP_V3, AERODROME], + [await uniHandler.getAddress(), await aeroHandler.getAddress()], + [[FEE_TIER], [TICK_SPACING]], + [0, 0], + [0, 0], + // Seeded at construction: the allow-listed params above are held to the + // same reference requirement as any later addition. + [{ token: wethAddr, config: { pool: uniPoolAddr, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY } }], + treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + deployer.address, // initialAdmin + await timelock.getAddress(), // timelock + pauser.address, + ); + await manager.waitForDeployment(); + + await (await usdc.mint(safeAddr, 10n ** 12n)).wait(); + for (const target of [uniRouter, clRouter, uniNpm, clNpm]) { + await (await weth.mint(await target.getAddress(), 10n ** 24n)).wait(); + await (await usdc.mint(await target.getAddress(), 10n ** 18n)).wait(); + } + await (await uniRouter.setOutput(WETH_OUT)).wait(); + await (await clRouter.setOutput(WETH_OUT)).wait(); + + return { + deployer, + operatorEOA, + treasury, + stranger, + pauser, + timelock, + weth, + usdc, + wethAddr, + usdcAddr, + uniPool, + uniPoolAddr, + uniFactory, + uniNpm, + uniRouter, + clPool, + clFactory, + clNpm, + clRouter, + voter, + safe, + safeAddr, + reg, + uniHandler, + aeroHandler, + manager, + }; +} + +describe("SafeYieldManager", function () { + describe("deployment", function () { + it("registers handlers, enables protocols, and stores config", async function () { + const { manager, uniHandler, aeroHandler, treasury } = await loadFixture(deployYieldManagerHarness); + + expect(await manager.yieldHandlers(UNISWAP_V3)).to.equal(await uniHandler.getAddress()); + expect(await manager.yieldHandlers(AERODROME)).to.equal(await aeroHandler.getAddress()); + expect(await manager.protocolEnabledForOpen(UNISWAP_V3)).to.equal(true); + expect(await manager.protocolEnabledForOpen(AERODROME)).to.equal(true); + expect(await manager.protocolEnabledForClose(UNISWAP_V3)).to.equal(true); + expect(await manager.protocolEnabledForClose(AERODROME)).to.equal(true); + expect(await manager.treasury()).to.equal(treasury.address); + expect(await manager.performanceFeeBps()).to.equal(PERF_FEE_BPS); + expect(await manager.feeCollectBps()).to.equal(COLLECT_FEE_BPS); + expect(await manager.maxSlippageBps()).to.equal(300); + expect(await manager.isPoolParamAllowed(UNISWAP_V3, FEE_TIER)).to.equal(true); + expect(await manager.isPoolParamAllowed(UNISWAP_V3, BAD_FEE_TIER)).to.equal(false); + expect(await manager.isPoolParamAllowed(AERODROME, TICK_SPACING)).to.equal(true); + }); + + it("reverts on constructor array length mismatch", async function () { + const { manager, reg, usdcAddr, wethAddr, uniHandler, treasury, deployer, pauser, timelock, uniPoolAddr } = + await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + await expect( + Manager.deploy( + await reg.getAddress(), + usdcAddr, + wethAddr, + [UNISWAP_V3, AERODROME], + [await uniHandler.getAddress()], + [[FEE_TIER], [TICK_SPACING]], + [0, 0], + [0, 0], + [ + { + token: wethAddr, + config: { pool: uniPoolAddr, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + deployer.address, + await timelock.getAddress(), + pauser.address, + ), + ).to.be.revertedWithCustomError(manager, "LengthMismatch"); + }); + + it("rejects mismatched and non-contract handlers in the constructor", async function () { + const { + manager, + reg, + usdcAddr, + wethAddr, + uniHandler, + treasury, + deployer, + pauser, + stranger, + timelock, + uniPoolAddr, + } = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const registryAddress = await reg.getAddress(); + const timelockAddress = await timelock.getAddress(); + const deployWithHandler = (handler: string) => + Manager.deploy( + registryAddress, + usdcAddr, + wethAddr, + [AERODROME], + [handler], + [[TICK_SPACING]], + [0], + [0], + [ + { + token: wethAddr, + config: { pool: uniPoolAddr, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + deployer.address, + timelockAddress, + pauser.address, + ); + + await expect(deployWithHandler(await uniHandler.getAddress())) + .to.be.revertedWithCustomError(manager, "HandlerProtocolMismatch") + .withArgs(AERODROME, UNISWAP_V3); + await expect(deployWithHandler(stranger.address)).to.be.revertedWithCustomError(manager, "InvalidHandler"); + }); + }); + + describe("openLp", function () { + it("opens a Uniswap V3 position through the manager module", async function () { + const { manager, operatorEOA, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, WETH_OUT, HALF, USDC_AMOUNT); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(await manager.yieldHandlers(UNISWAP_V3)); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + }); + + it("opens an Aerodrome position with a separate basis namespace", async function () { + const { manager, operatorEOA, safeAddr, clNpm } = await loadFixture(deployYieldManagerHarness); + + await expect(manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, WETH_OUT, HALF, USDC_AMOUNT); + + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + }); + + it("allows the Safe itself to call", async function () { + const { manager, safeAddr } = await loadFixture(deployYieldManagerHarness); + const safeSigner = await ethers.getImpersonatedSigner(safeAddr); + await ethers.provider.send("hardhat_setBalance", [safeAddr, "0x1000000000000000000"]); + + await expect(manager.connect(safeSigner).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).to.emit( + manager, + "PositionOpened", + ); + }); + + it("reverts for unauthorized callers", async function () { + const { manager, stranger, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(stranger).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + }); + + it("reverts on zero onBehalfOf, zero usdcAmount, and expired deadline", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(ZERO, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ZeroAddress"); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { usdcAmount: 0 })), + ).to.be.revertedWithCustomError(manager, "InvalidUsdcAmount"); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { deadline: 1 })), + ).to.be.revertedWithCustomError(manager, "DeadlineExpired"); + }); + + it("reverts TokenNotWhitelisted when a pool token leaves the registry whitelist", async function () { + const { manager, operatorEOA, safeAddr, reg, wethAddr, usdcAddr } = + await loadFixture(deployYieldManagerHarness); + + await (await reg.setWhitelisted(wethAddr, false)).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))) + .to.be.revertedWithCustomError(manager, "TokenNotWhitelisted") + .withArgs(wethAddr); + + await (await reg.setWhitelisted(wethAddr, true)).wait(); + await (await reg.setWhitelisted(usdcAddr, false)).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))) + .to.be.revertedWithCustomError(manager, "TokenNotWhitelisted") + .withArgs(usdcAddr); + + await (await reg.setWhitelisted(usdcAddr, true)).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).to.emit( + manager, + "PositionOpened", + ); + }); + + it("enforces the pool param allow-list and admin toggling", async function () { + const { manager, operatorEOA, deployer, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { lpPoolParam: BAD_FEE_TIER })), + ).to.be.revertedWithCustomError(manager, "PoolParamNotAllowed"); + + await expect(manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, BAD_FEE_TIER, true)) + .to.emit(manager, "PoolParamAllowedUpdated") + .withArgs(UNISWAP_V3, BAD_FEE_TIER, false, true); + + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { lpPoolParam: BAD_FEE_TIER })), + ).to.emit(manager, "PositionOpened"); + }); + + it("binds slippageBps to the quoter-derived swap minimum", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { slippageBps: 0 })), + ).to.be.revertedWithCustomError(manager, "SlippageTooLow"); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { slippageBps: 301 })), + ).to.be.revertedWithCustomError(manager, "SlippageAboveMax"); + }); + + // The floor a caller cannot lower. The reference pool sits at tick 0, + // so the TWAP values half the 1 USDC deposit (500_000) at 500_000 wei; + // at SLIP = 1% the router must be handed at least 495_000 no matter + // what the caller asked for. + const HALF_USDC = USDC_AMOUNT / 2n; + const TWAP_FLOOR = (HALF_USDC * (10_000n - BigInt(SLIP))) / 10_000n; + + it("raises a caller's swap minimum to the reference TWAP floor", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + // The old contract accepted this by checking the caller's minimum + // against the caller's own expectation; 1 and 1 satisfied it. + await ( + await manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, FEE_TIER, { + swap0: leg(1, FEE_TIER), + }), + ) + ).wait(); + expect(await uniRouter.lastAmountIn()).to.equal(HALF_USDC); + expect(await uniRouter.lastAmountOutMinimum()).to.equal(TWAP_FLOOR); + }); + + it("accepts a zero minimum for a swap whose size is unknown up front", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await ( + await manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, FEE_TIER, { + swap0: leg(0, FEE_TIER), + }), + ) + ).wait(); + expect(await uniRouter.lastAmountOutMinimum()).to.equal(TWAP_FLOOR); + }); + + it("keeps a caller minimum that is tighter than the floor", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + const tighter = TWAP_FLOOR * 2n; + await ( + await manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, FEE_TIER, { + swap0: leg(tighter, FEE_TIER), + }), + ) + ).wait(); + expect(await uniRouter.lastAmountOutMinimum()).to.equal(tighter); + }); + + it("reverts one unit below the effective floor and succeeds exactly at it", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await uniRouter.setEnforceMinOut(true)).wait(); + await (await uniRouter.setOutput(TWAP_FLOOR - 1n)).wait(); + + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { swap0: leg(1, FEE_TIER) })), + ).to.be.revertedWith("router: too little received"); + + await (await uniRouter.setOutput(TWAP_FLOOR)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { swap0: leg(1, FEE_TIER) })), + ).to.emit(manager, "PositionOpened"); + expect(await uniRouter.lastAmountOutMinimum()).to.equal(TWAP_FLOOR); + }); + + it("enforces the same router boundary through the Aerodrome handler", async function () { + const { manager, operatorEOA, safeAddr, clRouter } = await loadFixture(deployYieldManagerHarness); + await (await clRouter.setEnforceMinOut(true)).wait(); + await (await clRouter.setOutput(TWAP_FLOOR - 1n)).wait(); + + await expect( + manager + .connect(operatorEOA) + .openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { swap0: leg(1, TICK_SPACING) })), + ).to.be.revertedWith("router: too little received"); + + await (await clRouter.setOutput(TWAP_FLOOR)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { swap0: leg(1, TICK_SPACING) })), + ).to.emit(manager, "PositionOpened"); + }); + + it("scales the floor with slippageBps", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await ( + await manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, FEE_TIER, { + swap0: leg(0, FEE_TIER), + slippageBps: 300, + }), + ) + ).wait(); + expect(await uniRouter.lastAmountOutMinimum()).to.equal((HALF_USDC * 9_700n) / 10_000n); + }); + + it("refuses to swap a token with no price reference", async function () { + const { manager, operatorEOA, safeAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + await (await uniPool.setObserveReverts(true)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWith("OLD"); + }); + + it("reverts SwapFailed when the swap produces no WETH", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await uniRouter.setOutput(0)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { swap0: leg(1, FEE_TIER) })), + ).to.be.revertedWithCustomError(manager, "SwapFailed"); + }); + + it("enforces the minted-liquidity floor per protocol", async function () { + const { manager, operatorEOA, deployer, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(deployer).setMinPositionLiquidity(UNISWAP_V3, 10_000_000)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "PositionLiquidityTooLow"); + }); + }); + + describe("closeLp", function () { + it("fully closes a Uniswap V3 position and charges the performance fee on profit", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniNpm, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + + const treasuryBefore = await usdc.balanceOf(treasury.address); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + + expect((await usdc.balanceOf(treasury.address)) - treasuryBefore).to.equal(10_000n); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(ZERO); + expect(await uniNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("closes through the handler pinned at open even after setYieldHandler", async function () { + const { + manager, + operatorEOA, + deployer, + timelock, + safeAddr, + usdcAddr, + wethAddr, + uniNpm, + uniRouter, + uniFactory, + } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + const pinnedHandler = await manager.positionHandlerOf(UNISWAP_V3, 1); + + const NPM2 = await ethers.getContractFactory("MockNonfungiblePositionManager"); + const uniNpm2 = await NPM2.deploy(); + await uniNpm2.waitForDeployment(); + const UniHandler = await ethers.getContractFactory("UniV3YieldHandler"); + const newHandler = await UniHandler.deploy( + await uniNpm2.getAddress(), + usdcAddr, + await uniRouter.getAddress(), + await uniFactory.getAddress(), + ); + await newHandler.waitForDeployment(); + await ( + await timelockCall(timelock, manager, "setYieldHandler", [UNISWAP_V3, await newHandler.getAddress()]) + ).wait(); + + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(pinnedHandler); + await (await uniRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))).to.emit( + manager, + "PositionClosed", + ); + expect(await uniNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("fully closes an Aerodrome position", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING))).wait(); + await (await clRouter.setOutput(600_000n)).wait(); + + await expect(manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("partially closes and prorates the residual basis", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(300_000n)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp( + UNISWAP_V3, + closeParams(safeAddr, 1, FEE_TIER, { + exitBps: 5_000, + swap0: leg(300_000n, FEE_TIER), + }), + ), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, HALF, 550_000n, 5_000n, 5_000, 0n); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(HALF); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + }); + + it("rejects positions opened on a different protocol (namespace isolation)", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "UnknownPosition"); + }); + + it("reverts on invalid exitBps and unknown tokenIds", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 99, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "UnknownPosition"); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { exitBps: 0 })), + ).to.be.revertedWithCustomError(manager, "InvalidExitBps"); + await expect( + manager + .connect(operatorEOA) + .closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { exitBps: 10_001 })), + ).to.be.revertedWithCustomError(manager, "InvalidExitBps"); + }); + + it("enforces the caller's minUsdcOut floor", async function () { + const { manager, operatorEOA, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + + await expect( + manager + .connect(operatorEOA) + .closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { minUsdcOut: 2_000_000n })), + ).to.be.revertedWithCustomError(manager, "MinUsdcOutNotMet"); + }); + + it("waives the performance fee when the transfer returns false without reverting", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + await (await usdc.setFalseTransferTo(treasury.address)).wait(); + + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)); + await expect(tx).to.emit(manager, "FeeTransferFailed").withArgs(safeAddr, 1n, 10_000n); + await expect(tx) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 1_100_000n, 0n, 10_000, 0n); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("waives the performance fee when the treasury transfer fails", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + await (await usdc.setRevertTransferTo(treasury.address)).wait(); + + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "FeeTransferFailed") + .withArgs(safeAddr, 1n, 10_000n); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + }); + + describe("collectLp", function () { + it("harvests fees, skims feeCollectBps, and forwards the rest to the Safe", async function () { + const { manager, operatorEOA, safeAddr, treasury, weth, usdc, uniNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 100_000n, 40_000n)).wait(); + + const safeWethBefore = await weth.balanceOf(safeAddr); + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "FeesCollected") + .withArgs( + safeAddr, + UNISWAP_V3, + 1n, + await weth.getAddress(), + 100_000n, + 2_500n, + await usdc.getAddress(), + 40_000n, + 1_000n, + ); + + expect(await weth.balanceOf(treasury.address)).to.equal(2_500n); + expect(await usdc.balanceOf(treasury.address)).to.equal(1_000n); + expect((await weth.balanceOf(safeAddr)) - safeWethBefore).to.equal(97_500n); + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(39_000n); + }); + + it("optionally swaps the WETH remainder to USDC", async function () { + const { manager, operatorEOA, safeAddr, weth, uniNpm, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 100_000n, 0)).wait(); + await (await uniRouter.setOutput(95_000n)).wait(); + + const safeWethBefore = await weth.balanceOf(safeAddr); + await ( + await manager.connect(operatorEOA).collectLp( + UNISWAP_V3, + collectParams(safeAddr, 1, FEE_TIER, { + swapFeesToUsdc: true, + swap0: leg(95_000n, FEE_TIER), + }), + ) + ).wait(); + + expect(await weth.balanceOf(safeAddr)).to.equal(safeWethBefore); + }); + + // M-01: the collect fee swap is exactly the call that used to ship with + // `amountOutMin: 1`, because the amount collected is unknowable until + // the collect runs. It is now floored on the amount actually harvested. + it("floors the fee swap even though the collected amount is unknown up front", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + const owed0 = 100_000n; + await (await uniNpm.setOwed(1, owed0, 0)).wait(); + await (await uniRouter.setOutput(95_000n)).wait(); + + await ( + await manager.connect(operatorEOA).collectLp( + UNISWAP_V3, + collectParams(safeAddr, 1, FEE_TIER, { + swapFeesToUsdc: true, + // The 1/1 sentinel the audit named. It no longer buys + // the caller a floor-free swap. + swap0: leg(1, FEE_TIER), + }), + ) + ).wait(); + + // feeCollectBps is skimmed first, so the swap sees the net harvest. + const netHarvest = owed0 - (owed0 * COLLECT_FEE_BPS) / 10_000n; + expect(await uniRouter.lastAmountIn()).to.equal(netHarvest); + expect(await uniRouter.lastAmountOutMinimum()).to.equal((netHarvest * (10_000n - BigInt(SLIP))) / 10_000n); + }); + + it("leaves a dynamic fee delta in kind when its independent floor rounds to zero", async function () { + const { manager, operatorEOA, safeAddr, weth, uniNpm, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 1n, 0)).wait(); + const callsBefore = await uniRouter.callCount(); + const wethBefore = await weth.balanceOf(safeAddr); + + await expect( + manager.connect(operatorEOA).collectLp( + UNISWAP_V3, + collectParams(safeAddr, 1, FEE_TIER, { + swapFeesToUsdc: true, + swap0: leg(0, FEE_TIER), + }), + ), + ).to.emit(manager, "FeesCollected"); + + expect(await uniRouter.callCount()).to.equal(callsBefore); + expect(await weth.balanceOf(safeAddr)).to.equal(wethBefore + 1n); + }); + + it("rejects tokenIds without a stored basis", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, wethAddr, usdcAddr } = + await loadFixture(deployYieldManagerHarness); + await (await uniNpm.seedPosition(7, safeAddr, wethAddr, usdcAddr, 500, 1_000_000n, 0, 0)).wait(); + + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 7, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "UnknownPosition"); + }); + }); + + describe("pause and protocol switches", function () { + it("pause blocks opens but keeps exits available (exit-only mode)", async function () { + const { manager, operatorEOA, pauser, safeAddr, uniNpm, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 0, 40_000n)).wait(); + + await (await manager.connect(pauser).pause()).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "EnforcedPause"); + + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER)), + ).to.emit(manager, "FeesCollected"); + await (await uniRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))).to.emit( + manager, + "PositionClosed", + ); + + await (await manager.connect(pauser).unpause()).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).to.emit( + manager, + "PositionOpened", + ); + }); + + it("pauser can disable opens for a single protocol without affecting exits", async function () { + const { manager, operatorEOA, pauser, safeAddr, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect(manager.connect(pauser).setProtocolEnabledForOpen(UNISWAP_V3, false)) + .to.emit(manager, "ProtocolStatusChanged") + .withArgs(UNISWAP_V3, true, false); + + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + await expect(manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING))).to.emit( + manager, + "PositionOpened", + ); + await (await uniRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))).to.emit( + manager, + "PositionClosed", + ); + }); + + it("pauser can disable exits for a single protocol", async function () { + const { manager, operatorEOA, pauser, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect(manager.connect(pauser).setProtocolEnabledForClose(UNISWAP_V3, false)) + .to.emit(manager, "ProtocolStatusChanged") + .withArgs(UNISWAP_V3, false, false); + + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + + await (await manager.connect(pauser).setProtocolEnabledForClose(UNISWAP_V3, true)).wait(); + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER)), + ).to.emit(manager, "FeesCollected"); + }); + + it("rejects pause and protocol toggles from non-pausers", async function () { + const { manager, stranger } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(stranger).pause()).to.be.revertedWithCustomError(manager, "NotAuthorized"); + await expect( + manager.connect(stranger).setProtocolEnabledForOpen(UNISWAP_V3, false), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + await expect( + manager.connect(stranger).setProtocolEnabledForClose(UNISWAP_V3, false), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + }); + }); + + describe("setters and access control", function () { + it("timelock-gated setters reject non-timelock callers", async function () { + const { manager, stranger, treasury } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(stranger).setTreasury(treasury.address)).to.be.revertedWithCustomError( + manager, + "OnlyTimelock", + ); + await expect(manager.connect(stranger).setPerformanceFeeBps(0)).to.be.revertedWithCustomError( + manager, + "OnlyTimelock", + ); + await expect(manager.connect(stranger).setFeeCollectBps(0)).to.be.revertedWithCustomError( + manager, + "OnlyTimelock", + ); + await expect( + manager.connect(stranger).setYieldHandler(UNISWAP_V3, treasury.address), + ).to.be.revertedWithCustomError(manager, "OnlyTimelock"); + }); + + it("timelock can rotate treasury, fees, and handlers", async function () { + const { manager, deployer, timelock, stranger, uniHandler } = await loadFixture(deployYieldManagerHarness); + await expect(timelockCall(timelock, manager, "setTreasury", [stranger.address])).to.emit( + manager, + "TreasuryUpdated", + ); + expect(await manager.treasury()).to.equal(stranger.address); + + await expect( + timelockCall(timelock, manager, "setPerformanceFeeBps", [MAX_FEE_BPS + 1]), + ).to.be.revertedWithCustomError(manager, "FeeAboveMax"); + await (await timelockCall(timelock, manager, "setPerformanceFeeBps", [500])).wait(); + expect(await manager.performanceFeeBps()).to.equal(500); + + await expect( + timelockCall(timelock, manager, "setYieldHandler", [UNISWAP_V3, ZERO]), + ).to.be.revertedWithCustomError(manager, "InvalidHandler"); + await expect(timelockCall(timelock, manager, "setYieldHandler", [AERODROME, await uniHandler.getAddress()])) + .to.be.revertedWithCustomError(manager, "HandlerProtocolMismatch") + .withArgs(AERODROME, UNISWAP_V3); + await expect( + timelockCall(timelock, manager, "setYieldHandler", [UNISWAP_V3, stranger.address]), + ).to.be.revertedWithCustomError(manager, "InvalidHandler"); + }); + + it("rejects a handler contract without PROTOCOL()", async function () { + const { manager, deployer, timelock, usdc } = await loadFixture(deployYieldManagerHarness); + await expect( + timelockCall(timelock, manager, "setYieldHandler", [UNISWAP_V3, await usdc.getAddress()]), + ).to.be.revertedWithCustomError(manager, "InvalidHandler"); + }); + + it("admin setters enforce role and bounds", async function () { + const { manager, deployer, stranger } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(stranger).setMaxSlippageBps(500)).to.be.revertedWithCustomError( + manager, + "AccessControlUnauthorizedAccount", + ); + await expect(manager.connect(deployer).setMaxSlippageBps(1001)).to.be.revertedWithCustomError( + manager, + "SlippageAboveMax", + ); + await (await manager.connect(deployer).setMaxSlippageBps(500)).wait(); + expect(await manager.maxSlippageBps()).to.equal(500); + + await (await manager.connect(deployer).setMinPoolLiquidity(UNISWAP_V3, 123)).wait(); + expect(await manager.minPoolLiquidity(UNISWAP_V3)).to.equal(123); + expect(await manager.minPoolLiquidity(AERODROME)).to.equal(0); + + await (await manager.connect(deployer).setMinPositionLiquidity(UNISWAP_V3, 456)).wait(); + expect(await manager.minPositionLiquidity(UNISWAP_V3)).to.equal(456); + expect(await manager.minPositionLiquidity(AERODROME)).to.equal(0); + }); + + it("rescues ERC20 and ERC721 held by the manager", async function () { + const { manager, deployer, stranger, usdc } = await loadFixture(deployYieldManagerHarness); + const managerAddr = await manager.getAddress(); + await (await usdc.mint(managerAddr, 1_000n)).wait(); + await expect(manager.connect(deployer).rescueToken(await usdc.getAddress(), stranger.address, 1_000n)) + .to.emit(manager, "TokenRescued") + .withArgs(await usdc.getAddress(), stranger.address, 1_000n); + expect(await usdc.balanceOf(stranger.address)).to.equal(1_000n); + + const ERC721 = await ethers.getContractFactory("MockERC721"); + const nft = await ERC721.deploy(); + await nft.waitForDeployment(); + await (await nft.mint(managerAddr, 42)).wait(); + await (await manager.connect(deployer).rescueERC721(await nft.getAddress(), 42, stranger.address)).wait(); + expect(await nft.ownerOf(42)).to.equal(stranger.address); + }); + }); + + describe("protocol extensibility (uint8 ids)", function () { + const NEXT_PROTOCOL = 2; + + it("registers and runs a handler for an id beyond the canonical ids without redeploy", async function () { + const { + manager, + deployer, + timelock, + pauser, + operatorEOA, + safeAddr, + usdcAddr, + wethAddr, + uniFactory, + uniNpm, + uniRouter, + } = await loadFixture(deployYieldManagerHarness); + + const NextHandler = await ethers.getContractFactory("MockNextYieldHandler"); + const nextHandler = await NextHandler.deploy( + NEXT_PROTOCOL, + await uniNpm.getAddress(), + usdcAddr, + await uniRouter.getAddress(), + await uniFactory.getAddress(), + ); + await nextHandler.waitForDeployment(); + const nextHandlerAddr = await nextHandler.getAddress(); + + await expect(timelockCall(timelock, manager, "setYieldHandler", [NEXT_PROTOCOL, nextHandlerAddr])) + .to.emit(manager, "YieldHandlerUpdated") + .withArgs(NEXT_PROTOCOL, ZERO, nextHandlerAddr); + expect(await manager.yieldHandlers(NEXT_PROTOCOL)).to.equal(nextHandlerAddr); + + await expect( + manager.connect(operatorEOA).openLp(NEXT_PROTOCOL, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + + await (await manager.connect(pauser).setProtocolEnabledForOpen(NEXT_PROTOCOL, true)).wait(); + await (await manager.connect(pauser).setProtocolEnabledForClose(NEXT_PROTOCOL, true)).wait(); + await (await manager.connect(deployer).setPoolParamAllowed(NEXT_PROTOCOL, FEE_TIER, true)).wait(); + + await expect(manager.connect(operatorEOA).openLp(NEXT_PROTOCOL, openParams(safeAddr, FEE_TIER))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, NEXT_PROTOCOL, 1n, USDC_AMOUNT, WETH_OUT, HALF, USDC_AMOUNT); + expect(await manager.residualBasisUsd6Of(NEXT_PROTOCOL, 1)).to.equal(USDC_AMOUNT); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + + await (await uniRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(NEXT_PROTOCOL, closeParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, NEXT_PROTOCOL, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + expect(await manager.residualBasisUsd6Of(NEXT_PROTOCOL, 1)).to.equal(0); + expect(await manager.positionHandlerOf(NEXT_PROTOCOL, 1)).to.equal(ZERO); + }); + + it("keeps unregistered ids inert instead of reverting on decode", async function () { + const { manager, operatorEOA, pauser, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).openLp(200, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "HandlerNotSet"); + await expect(manager.connect(pauser).setProtocolEnabledForOpen(200, true)).to.be.revertedWithCustomError( + manager, + "HandlerNotSet", + ); + expect(await manager.yieldHandlers(200)).to.equal(ZERO); + expect(await manager.isPoolParamAllowed(200, FEE_TIER)).to.equal(false); + }); + + it("rejects registering a handler whose PROTOCOL id mismatches the target id", async function () { + const { manager, deployer, timelock, uniHandler } = await loadFixture(deployYieldManagerHarness); + await expect( + timelockCall(timelock, manager, "setYieldHandler", [NEXT_PROTOCOL, await uniHandler.getAddress()]), + ) + .to.be.revertedWithCustomError(manager, "HandlerProtocolMismatch") + .withArgs(NEXT_PROTOCOL, UNISWAP_V3); + }); + }); + + describe("handlers", function () { + it("rejects direct (non-delegatecall) invocation", async function () { + const { uniHandler, aeroHandler, safeAddr } = await loadFixture(deployYieldManagerHarness); + await expect(uniHandler.openLp(openParams(safeAddr, FEE_TIER))).to.be.revertedWithCustomError( + uniHandler, + "OnlyDelegatecall", + ); + await expect(uniHandler.closeLp(closeParams(safeAddr, 1, FEE_TIER), 0)).to.be.revertedWithCustomError( + uniHandler, + "OnlyDelegatecall", + ); + await expect(aeroHandler.collectLp(collectParams(safeAddr, 1, TICK_SPACING))).to.be.revertedWithCustomError( + aeroHandler, + "OnlyDelegatecall", + ); + await expect( + uniHandler.withdrawLp({ + onBehalfOf: safeAddr, + tokenId: 1, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: DEADLINE, + }), + ).to.be.revertedWithCustomError(uniHandler, "OnlyDelegatecall"); + await expect( + uniHandler.openLpInKind({ + onBehalfOf: safeAddr, + token0: safeAddr, + token1: safeAddr, + amount0: 0, + amount1: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: FEE_TIER, + deadline: DEADLINE, + }), + ).to.be.revertedWithCustomError(uniHandler, "OnlyDelegatecall"); + }); + + it("rejects zero addresses in handler constructors", async function () { + const { uniHandler, uniNpm, uniRouter, uniFactory, clNpm, clRouter, clFactory, voter, usdcAddr } = + await loadFixture(deployYieldManagerHarness); + const UniHandler = await ethers.getContractFactory("UniV3YieldHandler"); + const AeroHandler = await ethers.getContractFactory("AerodromeYieldHandler"); + const npm = await uniNpm.getAddress(); + const router = await uniRouter.getAddress(); + const factory = await uniFactory.getAddress(); + const voterAddr = await voter.getAddress(); + + await expect(UniHandler.deploy(ZERO, usdcAddr, router, factory)).to.be.revertedWithCustomError( + uniHandler, + "ZeroAddress", + ); + await expect(UniHandler.deploy(npm, ZERO, router, factory)).to.be.revertedWithCustomError( + uniHandler, + "ZeroAddress", + ); + await expect(UniHandler.deploy(npm, usdcAddr, ZERO, factory)).to.be.revertedWithCustomError( + uniHandler, + "ZeroAddress", + ); + await expect(UniHandler.deploy(npm, usdcAddr, router, ZERO)).to.be.revertedWithCustomError( + uniHandler, + "ZeroAddress", + ); + await expect( + AeroHandler.deploy(await clNpm.getAddress(), usdcAddr, await clRouter.getAddress(), ZERO, voterAddr), + ).to.be.revertedWithCustomError(uniHandler, "ZeroAddress"); + await expect( + AeroHandler.deploy( + await clNpm.getAddress(), + usdcAddr, + await clRouter.getAddress(), + await clFactory.getAddress(), + ZERO, + ), + ).to.be.revertedWithCustomError(uniHandler, "ZeroAddress"); + }); + }); + + describe("stakePool staking", function () { + it("reverts StakingNotSupported when opening a Uniswap V3 position with stake", async function () { + const { manager, operatorEOA, safeAddr, uniHandler } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER, { stake: true })), + ).to.be.revertedWithCustomError(uniHandler, "StakingNotSupported"); + }); + + it("reverts StakingNotSupported opening on Aerodrome when the pool has no stakePool", async function () { + const { manager, operatorEOA, safeAddr, aeroHandler } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })), + ).to.be.revertedWithCustomError(aeroHandler, "StakingNotSupported"); + }); + + it("stakes the minted Aerodrome NFT into its stakePool and unstakes it on close", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + + const { stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await expect( + manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })), + ).to.emit(manager, "PositionOpened"); + + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + + await (await clRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("closes normally when the NFT left the stakePool without the module", async function () { + const { manager, operatorEOA, safeAddr, safe, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + + // Direct gauge withdrawal bypassing the module: the recorded + // stakePool pin persists but no longer owns the NFT. + await ( + await safe.execTransactionFromModuleReturnData( + stakePoolAddr, + 0, + stakePool.interface.encodeFunctionData("withdraw", [1]), + 0, + ) + ).wait(); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + + await (await clRouter.setOutput(600_000n)).wait(); + await expect( + manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING)), + ).to.emit(manager, "PositionClosed"); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("unstakes from the original stakePool after the Voter gauge rotates", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePoolAddr: originalStakePool } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + expect(await clNpm.ownerOf(1)).to.equal(originalStakePool); + + const { stakePoolAddr: replacementStakePool } = await deployStakePool(clNpm, clPool, voter); + expect(replacementStakePool).to.not.equal(originalStakePool); + await (await clRouter.setOutput(600_000n)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING)), + ).to.emit(manager, "PositionClosed"); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("claims stakePool rewards only for a staked Aerodrome position and leaves it staked", async function () { + const { manager, operatorEOA, safeAddr, treasury, weth, usdc, clNpm, clPool, voter } = + await loadFixture(deployYieldManagerHarness); + + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + + // Staked liquidity earns emissions instead of trading fees, so any owed + // amounts on the NFT must stay untouched — no temporary unstake to + // collect them, no FeesCollected, no treasury skim. + await (await clNpm.setOwed(1, 100_000n, 40_000n)).wait(); + const safeWethBefore = await weth.balanceOf(safeAddr); + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + + const collectTx = manager + .connect(operatorEOA) + .collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)); + await expect(collectTx).to.emit(stakePool, "RewardClaimed").withArgs(1n, safeAddr); + await expect(collectTx).to.not.emit(manager, "FeesCollected"); + + expect(await weth.balanceOf(treasury.address)).to.equal(0); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + expect(await weth.balanceOf(safeAddr)).to.equal(safeWethBefore); + expect(await usdc.balanceOf(safeAddr)).to.equal(safeUsdcBefore); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + }); + + it("swaps the claimed stakePool reward to USDC when swapRewardToUsdc is set", async function () { + const { + manager, + deployer, + timelock, + operatorEOA, + safeAddr, + treasury, + usdc, + clNpm, + clPool, + clFactory, + clRouter, + voter, + } = await loadFixture(deployYieldManagerHarness); + + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + // Arm the stakePool with a mock AERO payout and give the reward its own + // allowed AERO/USDC pool so the swap leg validates. + const ERC = await ethers.getContractFactory("MockERC20"); + const aero = await ERC.deploy("Mock Aero", "AERO", 18); + await aero.waitForDeployment(); + const aeroAddr = await aero.getAddress(); + const usdcAddr = await usdc.getAddress(); + await (await aero.mint(stakePoolAddr, 10n ** 24n)).wait(); + await (await stakePool.setReward(aeroAddr, 500_000n)).wait(); + + const [r0, r1] = + aeroAddr.toLowerCase() < usdcAddr.toLowerCase() ? [aeroAddr, usdcAddr] : [usdcAddr, aeroAddr]; + const AERO_PARAM = encodeAerodromePoolParam(r0, r1, 50); + const CLPool = await ethers.getContractFactory("MockCLPool"); + const aeroPool = await CLPool.deploy(r0, r1, Q96, 10n ** 18n); + await aeroPool.waitForDeployment(); + await (await clFactory.setPoolFor(r0, r1, 50, aeroPool)).wait(); + // The reward swap executes on Aerodrome but is priced off a Uniswap + // V3 reference — one price per token, independent of venue. + const UniPoolFactory = await ethers.getContractFactory("MockUniswapV3Pool"); + const aeroRef = await UniPoolFactory.deploy(r0, r1, Q96, 10n ** 18n); + await aeroRef.waitForDeployment(); + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + aeroAddr, + await aeroRef.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + await (await manager.connect(deployer).setPoolParamAllowed(AERODROME, AERO_PARAM, true)).wait(); + await (await clRouter.setOutputFor(usdcAddr, 123_456n)).wait(); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + const collectTx = manager.connect(operatorEOA).collectLp( + AERODROME, + collectParams(safeAddr, 1, TICK_SPACING, { + swapRewardToUsdc: true, + rewardSwap: leg(100_000n, AERO_PARAM), + }), + ); + await expect(collectTx).to.emit(stakePool, "RewardClaimed").withArgs(1n, safeAddr); + await expect(collectTx).to.not.emit(manager, "FeesCollected"); + + // M-03: emissions are yield, so the claim pays feeCollectBps FIRST and + // only the net reaches the router — the Safe keeps no AERO either way. + const rewardFee = (500_000n * COLLECT_FEE_BPS) / 10_000n; + await expect(collectTx) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, aeroAddr, 500_000n, rewardFee); + expect(await aero.balanceOf(treasury.address)).to.equal(rewardFee); + expect(await aero.balanceOf(safeAddr)).to.equal(0); + expect(await aero.balanceOf(await clRouter.getAddress())).to.equal(500_000n - rewardFee); + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(123_456n); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + }); + + it("collectLp on an unstaked Aerodrome position runs the normal fee collect", async function () { + const { manager, operatorEOA, safeAddr, clNpm } = await loadFixture(deployYieldManagerHarness); + + // No stakePool set on the voter → the position is unstaked and owned by the Safe. + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING)); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + + // Falls through to the normal LP-fee collect (no stakePool reward path); NFT + // stays on the Safe, and swapRewardToUsdc is ignored for unstaked positions. + await manager + .connect(operatorEOA) + .collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING, { swapRewardToUsdc: true })); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + }); + + it("ignores a live stakePool for a position that opted out of staking", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + + // The pool HAS a stakePool, but the position never staked — the staked-stakePool + // detection must key off custody (ownerOf == stakePool), not stakePool existence. + const { stakePool } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING)); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + + const collectTx = manager + .connect(operatorEOA) + .collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)); + await expect(collectTx).to.emit(manager, "FeesCollected"); + await expect(collectTx).to.not.emit(stakePool, "RewardClaimed"); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + + await (await clRouter.setOutput(600_000n)).wait(); + await expect(manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + }); + + // L-01: a partial close must not silently stop emissions. The surviving NFT + // goes back into the pool it came out of, and the pin keeps pointing there. + it("restakes the surviving NFT into the same stakePool after a partial close", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + const liquidityBefore = (await clNpm.positionsData(1))[4]; + await (await clRouter.setOutput(300_000n)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ), + ).to.emit(manager, "PositionClosed"); + + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(stakePoolAddr); + const liquidityAfter = (await clNpm.positionsData(1))[4]; + expect(liquidityAfter).to.equal(liquidityBefore / 2n); + expect(liquidityAfter).to.be.lessThan(liquidityBefore); + }); + + it("keeps claiming rewards after a partial close, and clears the pin on the final close", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + await (await clRouter.setOutput(300_000n)).wait(); + await ( + await manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ) + ).wait(); + + // Still staked, so a later collect still routes to the stakePool. + await expect(manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING))) + .to.emit(stakePool, "RewardClaimed") + .withArgs(1n, safeAddr); + + await (await clRouter.setOutput(600_000n)).wait(); + await expect( + manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING)), + ).to.emit(manager, "PositionClosed"); + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(ZERO); + void stakePoolAddr; + }); + + it("does not stake a never-staked position on a partial close", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING)); + await (await clRouter.setOutput(300_000n)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ), + ).to.emit(manager, "PositionClosed"); + + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + expect(await clNpm.ownerOf(1)).to.not.equal(stakePoolAddr); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(ZERO); + }); + + it("restakes into the pinned stakePool even after the Voter gauge rotates", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePoolAddr: originalStakePool } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + + const { stakePoolAddr: replacementStakePool } = await deployStakePool(clNpm, clPool, voter); + expect(replacementStakePool).to.not.equal(originalStakePool); + await (await clRouter.setOutput(300_000n)).wait(); + + await ( + await manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ) + ).wait(); + + // The rotation must not move the user's position to the new gauge. + expect(await clNpm.ownerOf(1)).to.equal(originalStakePool); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(originalStakePool); + }); + + it("reverts the whole partial close when the restake fails", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, clRouter, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + const liquidityBefore = (await clNpm.positionsData(1))[4]; + const basisBefore = await manager.residualBasisUsd6Of(AERODROME, 1); + await (await clRouter.setOutput(300_000n)).wait(); + await (await stakePool.setDepositFails(true)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ), + ).to.be.reverted; + + // Nothing moved: still staked in the same pool, same liquidity, same basis. + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + expect((await clNpm.positionsData(1))[4]).to.equal(liquidityBefore); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(basisBefore); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(stakePoolAddr); + }); + + // M-03: emissions are fee-bearing yield on EVERY route that can claim them + // — an explicit collect, and the gauge withdrawal inside a close or switch. + describe("staked reward fee (M-03)", function () { + const REWARD = 500_000n; + const REWARD_FEE = (REWARD * COLLECT_FEE_BPS) / 10_000n; + + /// Stake a position and arm its gauge with `rewardToken`/`REWARD`. + async function stakeWithReward(f: any, rewardToken?: any, rewardAmount = REWARD) { + const { manager, operatorEOA, safeAddr, clNpm, clPool, voter } = f; + const { stakePool, stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + let reward = rewardToken; + if (!reward) { + const ERC = await ethers.getContractFactory("MockERC20"); + reward = await ERC.deploy("Mock Aero", "AERO", 18); + await reward.waitForDeployment(); + } + const rewardAddr = await reward.getAddress(); + await (await reward.mint(stakePoolAddr, 10n ** 24n)).wait(); + await (await stakePool.setReward(rewardAddr, rewardAmount)).wait(); + await manager + .connect(operatorEOA) + .openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })); + return { stakePool, stakePoolAddr, reward, rewardAddr }; + } + + it("leaves a one-unit claimed reward in kind when its TWAP floor rounds to zero", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, weth, clRouter } = f; + await stakeWithReward(f, weth, 1n); + const callsBefore = await clRouter.callCount(); + const wethBefore = await weth.balanceOf(safeAddr); + + await expect( + manager.connect(operatorEOA).collectLp( + AERODROME, + collectParams(safeAddr, 1, TICK_SPACING, { + swapRewardToUsdc: true, + rewardSwap: leg(0, TICK_SPACING), + }), + ), + ) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, await weth.getAddress(), 1n, 0n); + + expect(await clRouter.callCount()).to.equal(callsBefore); + expect(await weth.balanceOf(safeAddr)).to.equal(wethBefore + 1n); + }); + + it("charges the collect fee on a claim even when the reward is not swapped", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury } = f; + const { reward, rewardAddr } = await stakeWithReward(f); + + await expect( + manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, REWARD_FEE); + + expect(await reward.balanceOf(treasury.address)).to.equal(REWARD_FEE); + expect(await reward.balanceOf(safeAddr)).to.equal(REWARD - REWARD_FEE); + }); + + it("taxes only the newly claimed delta, never a reward the Safe already held", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury } = f; + const { reward, rewardAddr } = await stakeWithReward(f); + + // Pre-existing balance: not part of this claim, so not fee-bearing. + await (await reward.mint(safeAddr, 9_000_000n)).wait(); + + await expect( + manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, REWARD_FEE); + + expect(await reward.balanceOf(treasury.address)).to.equal(REWARD_FEE); + expect(await reward.balanceOf(safeAddr)).to.equal(9_000_000n + REWARD - REWARD_FEE); + }); + + it("charges the reward paid out by the gauge on a full close", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, clRouter } = f; + const { reward, rewardAddr } = await stakeWithReward(f); + await (await clRouter.setOutput(600_000n)).wait(); + + await expect(manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING))) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, REWARD_FEE); + + expect(await reward.balanceOf(treasury.address)).to.equal(REWARD_FEE); + }); + + it("keeps a USDC-denominated gauge reward out of the close's realized value", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, usdc, clRouter } = f; + // Reward token == USDC is the sharpest probe: if the claim were + // settled after the close snapshot, it would inflate + // currentValueUsd6 and overstate the performance fee. + await stakeWithReward(f, usdc); + await (await clRouter.setOutput(600_000n)).wait(); + + await expect(manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + + // Treasury got the reward fee, and only the LP value drove the perf fee. + expect(await usdc.balanceOf(treasury.address)).to.equal(REWARD_FEE + 10_000n); + }); + + it("charges a partial close's gauge reward exactly once and restakes", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, clNpm, clRouter } = f; + const { reward, stakePoolAddr } = await stakeWithReward(f); + await (await clRouter.setOutput(300_000n)).wait(); + + await ( + await manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(300_000n, TICK_SPACING), + }), + ) + ).wait(); + + // One withdrawal → one claim → one fee. The restake must not claim again. + expect(await reward.balanceOf(treasury.address)).to.equal(REWARD_FEE); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + }); + + it("charges a switch's gauge reward once and keeps it out of the moved amounts", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, usdc } = f; + // USDC reward again: the switch moves token amounts in kind, so a + // leaked reward would show up as an inflated withdrawn1. + await stakeWithReward(f, usdc); + + await expect( + manager.connect(operatorEOA).switchLp(AERODROME, UNISWAP_V3, { + onBehalfOf: safeAddr, + tokenId: 1, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: FEE_TIER, + deadline: DEADLINE, + }), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, AERODROME, UNISWAP_V3, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await usdc.balanceOf(treasury.address)).to.equal(REWARD_FEE); + }); + + it("handles a zero fee rate, a fee that rounds to zero, and no reward at all", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, timelock, operatorEOA, safeAddr, treasury } = f; + const { stakePool, reward, rewardAddr } = await stakeWithReward(f); + + // Rounds to zero: 3 * 250 / 10_000 == 0 — claimed, reported, untaxed. + await (await stakePool.setReward(rewardAddr, 3n)).wait(); + await expect( + manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, 3n, 0n); + expect(await reward.balanceOf(treasury.address)).to.equal(0); + + // Zero reward: nothing claimed, so nothing to report. + await (await stakePool.setReward(rewardAddr, 0n)).wait(); + await expect( + manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)), + ).to.not.emit(manager, "StakedRewardCollected"); + + // Fee rate zero: the claim still lands, still reported, still untaxed. + await (await stakePool.setReward(rewardAddr, REWARD)).wait(); + await (await timelockCall(timelock, manager, "setFeeCollectBps", [0])).wait(); + await expect( + manager.connect(operatorEOA).collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, 0n); + expect(await reward.balanceOf(treasury.address)).to.equal(0); + }); + + it("waives the reward fee — without blocking the collect — when the treasury cannot receive it", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury } = f; + const { reward, rewardAddr } = await stakeWithReward(f); + + // A token that REVERTS for the treasury (blacklist-style). + await (await reward.setRevertTransferTo(treasury.address)).wait(); + const collectTx = manager + .connect(operatorEOA) + .collectLp(AERODROME, collectParams(safeAddr, 1, TICK_SPACING)); + await expect(collectTx) + .to.emit(manager, "CollectFeeTransferFailed") + .withArgs(safeAddr, 1n, rewardAddr, REWARD_FEE); + await expect(collectTx) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, 0n); + expect(await reward.balanceOf(treasury.address)).to.equal(0); + expect(await reward.balanceOf(safeAddr)).to.equal(REWARD); + }); + + it("waives the reward fee on a silent false return and still closes", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, clRouter } = f; + const { reward, rewardAddr } = await stakeWithReward(f); + await (await clRouter.setOutput(600_000n)).wait(); + + // A non-compliant token that returns false instead of reverting. + await (await reward.setFalseTransferTo(treasury.address)).wait(); + const closeTx = manager.connect(operatorEOA).closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING)); + await expect(closeTx) + .to.emit(manager, "CollectFeeTransferFailed") + .withArgs(safeAddr, 1n, rewardAddr, REWARD_FEE); + await expect(closeTx) + .to.emit(manager, "StakedRewardCollected") + .withArgs(safeAddr, AERODROME, 1n, rewardAddr, REWARD, 0n); + await expect(closeTx).to.emit(manager, "PositionClosed"); + expect(await reward.balanceOf(treasury.address)).to.equal(0); + }); + }); + }); + + describe("constructor validation", function () { + async function baseArgs(f: Awaited>) { + return [ + await f.reg.getAddress(), + f.usdcAddr, + f.wethAddr, + [UNISWAP_V3, AERODROME], + [await f.uniHandler.getAddress(), await f.aeroHandler.getAddress()], + [[FEE_TIER], [TICK_SPACING]], + [0, 0], + [0, 0], + [ + { + token: f.wethAddr, + config: { pool: f.uniPoolAddr, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + f.treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + f.deployer.address, + await f.timelock.getAddress(), + f.pauser.address, + ]; + } + + it("rejects zero addresses and out-of-range fees", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const base = await baseArgs(f); + const deployWith = (index: number, value: any) => { + const args = [...base]; + args[index] = value; + return (Manager as any).deploy(...args); + }; + + await expect(deployWith(0, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(1, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(2, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(13, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(14, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(15, ZERO)).to.be.revertedWithCustomError(f.manager, "ZeroAddress"); + await expect(deployWith(9, ZERO)).to.be.revertedWithCustomError(f.manager, "InvalidTreasury"); + await expect(deployWith(12, 10_001)).to.be.revertedWithCustomError(f.manager, "FeeAboveMax"); + await expect(deployWith(10, MAX_FEE_BPS + 1)).to.be.revertedWithCustomError(f.manager, "FeeAboveMax"); + await expect(deployWith(11, MAX_FEE_BPS + 1)).to.be.revertedWithCustomError(f.manager, "FeeAboveMax"); + }); + + // The invariant the seeded references exist to hold: a deployment + // cannot allow-list a pool param whose non-USDC side has no live + // reference. Without this the constructor would be the one path into + // an allow-listed-but-unpriceable pool, and openLp would only discover + // it at the first swap. + it("refuses to allow-list a seeded pool param with no reference behind it", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const args = await baseArgs(f); + args[8] = []; + await expect((Manager as any).deploy(...args)) + .to.be.revertedWithCustomError(f.manager, "TwapNotConfigured") + .withArgs(f.wethAddr); + }); + + it("holds a seeded reference to the same standard as a replaced one", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const seedWith = async (overrides: Record) => { + const args = await baseArgs(f); + args[8] = [ + { + token: f.wethAddr, + config: { + pool: f.uniPoolAddr, + window: TWAP_WINDOW, + minCardinality: TWAP_CARDINALITY, + ...overrides, + }, + }, + ]; + return (Manager as any).deploy(...args); + }; + + await expect(seedWith({ window: TWAP_WINDOW - 1 })).to.be.revertedWithCustomError( + f.manager, + "TwapWindowTooShort", + ); + await expect(seedWith({ minCardinality: TWAP_CARDINALITY - 1 })).to.be.revertedWithCustomError( + f.manager, + "TwapCardinalityBelowFloor", + ); + await expect(seedWith({ pool: f.stranger.address })).to.be.revertedWithCustomError( + f.manager, + "InvalidTwapReferencePool", + ); + + // A pool that does not trade the pair, and one that trades it but + // cannot answer over the window, are both rejected at deploy time + // rather than at the first swap. + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const wrongPair = await Pool.deploy(f.wethAddr, f.wethAddr, Q96, 10n ** 18n); + await expect(seedWith({ pool: await wrongPair.getAddress() })).to.be.revertedWithCustomError( + f.manager, + "TwapPoolPairMismatch", + ); + + const thinHistory = await Pool.deploy(f.wethAddr, f.usdcAddr, Q96, 10n ** 18n); + await (await thinHistory.setObservationCardinality(TWAP_CARDINALITY - 1)).wait(); + await expect(seedWith({ pool: await thinHistory.getAddress() })).to.be.revertedWithCustomError( + f.manager, + "TwapCardinalityTooLow", + ); + }); + + it("rejects every constructor array length mismatch", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const base = await baseArgs(f); + const deployWith = (index: number, value: any) => { + const args = [...base]; + args[index] = value; + return (Manager as any).deploy(...args); + }; + + await expect(deployWith(5, [[FEE_TIER]])).to.be.revertedWithCustomError(f.manager, "LengthMismatch"); + await expect(deployWith(6, [0])).to.be.revertedWithCustomError(f.manager, "LengthMismatch"); + await expect(deployWith(7, [0])).to.be.revertedWithCustomError(f.manager, "LengthMismatch"); + }); + }); + + describe("pool validation", function () { + it("rejects missing, mispaired, uninitialized, and thin pools", async function () { + const { manager, operatorEOA, deployer, safeAddr, uniFactory, uniPool, wethAddr, usdcAddr } = + await loadFixture(deployYieldManagerHarness); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const open = () => manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + + await (await uniFactory.setPool(ZERO)).wait(); + await expect(open()).to.be.revertedWithCustomError(manager, "PoolDoesNotExist"); + + const mispaired0 = await Pool.deploy(usdcAddr, wethAddr, Q96, 10n ** 18n); + await (await uniFactory.setPool(await mispaired0.getAddress())).wait(); + await expect(open()).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + + const mispaired1 = await Pool.deploy(wethAddr, wethAddr, Q96, 10n ** 18n); + await (await uniFactory.setPool(await mispaired1.getAddress())).wait(); + await expect(open()).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + + const uninitialized = await Pool.deploy(wethAddr, usdcAddr, 0, 10n ** 18n); + await (await uniFactory.setPool(await uninitialized.getAddress())).wait(); + await expect(open()).to.be.revertedWithCustomError(manager, "PoolNotInitialized"); + + await (await uniFactory.setPool(await uniPool.getAddress())).wait(); + await (await manager.connect(deployer).setMinPoolLiquidity(UNISWAP_V3, 10n ** 19n)).wait(); + await expect(open()).to.be.revertedWithCustomError(manager, "PoolTooThin"); + }); + }); + + describe("defensive branches", function () { + it("reverts LpNotOnSafe when the minted NFT lands elsewhere", async function () { + const { manager, operatorEOA, stranger, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await uniNpm.setMintOwnerOverride(stranger.address)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "LpNotOnSafe"); + }); + + it("reverts InvalidExitBps when a partial close would round to zero liquidity", async function () { + const { manager, operatorEOA, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await uniNpm.setMintLiquidity(1)).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await expect( + manager + .connect(operatorEOA) + .closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { exitBps: 5_000 })), + ).to.be.revertedWithCustomError(manager, "InvalidExitBps"); + }); + + it("full-closes a zero-liquidity position without decrease or swap and charges no fee", async function () { + const { manager, operatorEOA, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await uniNpm.setMintLiquidity(0)).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 0n, 0n, 10_000, 0n); + }); + + it("charges no performance fee when a close realizes a loss", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(300_000n)).wait(); + + const tx = manager + .connect(operatorEOA) + .closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { swap0: leg(300_000n, FEE_TIER) })); + await expect(tx) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 800_000n, 0n, 10_000, 0n); + await expect(tx).to.not.emit(manager, "FeeTransferFailed"); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("closeLp enforces the deadline", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER, { deadline: 1 })), + ).to.be.revertedWithCustomError(manager, "DeadlineExpired"); + }); + + it("closeLp and collectLp reject unauthorized callers", async function () { + const { manager, operatorEOA, stranger, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await expect( + manager.connect(stranger).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + await expect( + manager.connect(stranger).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + }); + + it("collectLp enforces the deadline on the swap path", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await expect( + manager + .connect(operatorEOA) + .collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER, { swapFeesToUsdc: true, deadline: 1 })), + ).to.be.revertedWithCustomError(manager, "DeadlineExpired"); + }); + + it("reverts WrongTokenPair when a swap leg's pool does not trade the position token against USDC", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, usdcAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + const ERC = await ethers.getContractFactory("MockERC20"); + const foreign = await ERC.deploy("Foreign", "FRN", 18); + await foreign.waitForDeployment(); + + // Position token0 no longer matches the {token0, USDC} pair of the + // pool the leg routes through — the leg validation must refuse it. + await (await uniNpm.setTokens(1, await foreign.getAddress(), usdcAddr)).wait(); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + + await expect( + manager + .connect(operatorEOA) + .collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER, { swapFeesToUsdc: true })), + ).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + }); + + it("reverts LpNotOnSafe when the position left the Safe after open", async function () { + const { manager, operatorEOA, stranger, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwner(1, stranger.address)).wait(); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "LpNotOnSafe"); + }); + + it("surfaces typed ModuleCallFailed when the Safe fails with empty returndata", async function () { + const { manager, operatorEOA, safeAddr, safe, uniRouter } = await loadFixture(deployYieldManagerHarness); + await (await safe.setFail(await uniRouter.getAddress(), 1)).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))) + .to.be.revertedWithCustomError(manager, "ModuleCallFailed") + .withArgs(3); + }); + + it("bubbles the Safe's revert data when present", async function () { + const { manager, operatorEOA, safeAddr, safe, uniRouter } = await loadFixture(deployYieldManagerHarness); + const errData = ethers.concat(["0x08c379a0", abi.encode(["string"], ["router boom"])]); + await (await safe.setFail(await uniRouter.getAddress(), 2)).wait(); + await (await safe.setFailData(errData)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWith("router boom"); + }); + + it("wraps empty handler reverts in HandlerCallFailed", async function () { + const { manager, deployer, timelock, pauser, operatorEOA, safeAddr } = + await loadFixture(deployYieldManagerHarness); + const Reverting = await ethers.getContractFactory("MockRevertingYieldHandler"); + const reverting = await Reverting.deploy(3); + await reverting.waitForDeployment(); + await (await timelockCall(timelock, manager, "setYieldHandler", [3, await reverting.getAddress()])).wait(); + await (await manager.connect(pauser).setProtocolEnabledForOpen(3, true)).wait(); + await (await manager.connect(deployer).setPoolParamAllowed(3, FEE_TIER, true)).wait(); + await expect( + manager.connect(operatorEOA).openLp(3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "HandlerCallFailed"); + }); + + it("reverts HandlerNotSet when the pinned handler is missing (defensive invariant)", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + const baseSlot = BigInt("0x53ba738b9a2829dfda910cf4e864fcd3f84e03854b49244f3d159a473ee6a400"); + const inner = ethers.keccak256(abi.encode(["uint256", "uint256"], [UNISWAP_V3, baseSlot + 5n])); + const slot = ethers.keccak256(abi.encode(["uint256", "bytes32"], [1, inner])); + await ethers.provider.send("hardhat_setStorageAt", [await manager.getAddress(), slot, ethers.ZeroHash]); + + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "HandlerNotSet"); + }); + + it("blocks reentrant calls through the swap callback", async function () { + const { manager, operatorEOA, safeAddr, uniRouter, uniNpm } = await loadFixture(deployYieldManagerHarness); + const managerAddr = await manager.getAddress(); + + await ( + await uniRouter.setCallback( + managerAddr, + manager.interface.encodeFunctionData("openLp", [UNISWAP_V3, openParams(safeAddr, FEE_TIER)]), + ) + ).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ReentrancyGuardReentrantCall"); + + await (await uniRouter.setCallback(ZERO, "0x")).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await ( + await uniRouter.setCallback( + managerAddr, + manager.interface.encodeFunctionData("closeLp", [UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)]), + ) + ).wait(); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWithCustomError(manager, "ReentrancyGuardReentrantCall"); + + await (await uniNpm.setOwed(1, 100_000n, 0)).wait(); + await ( + await uniRouter.setCallback( + managerAddr, + manager.interface.encodeFunctionData("collectLp", [ + UNISWAP_V3, + collectParams(safeAddr, 1, FEE_TIER), + ]), + ) + ).wait(); + await expect( + manager.connect(operatorEOA).collectLp( + UNISWAP_V3, + collectParams(safeAddr, 1, FEE_TIER, { + swapFeesToUsdc: true, + swap0: leg(95_000n, FEE_TIER), + }), + ), + ).to.be.revertedWithCustomError(manager, "ReentrancyGuardReentrantCall"); + }); + + it("treats empty transfer returndata as fee-transfer success", async function () { + const { manager, operatorEOA, safeAddr, safe, usdcAddr, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + await (await safe.setFail(usdcAddr, 3)).wait(); + await (await safe.setFailData("0x")).wait(); + + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)); + await expect(tx) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + await expect(tx).to.not.emit(manager, "FeeTransferFailed"); + }); + + it("treats short transfer returndata as fee-transfer failure", async function () { + const { manager, operatorEOA, safeAddr, safe, usdcAddr, uniRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniRouter.setOutput(600_000n)).wait(); + await (await safe.setFail(usdcAddr, 3)).wait(); + await (await safe.setFailData("0x01")).wait(); + + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "FeeTransferFailed") + .withArgs(safeAddr, 1n, 10_000n); + }); + }); + + describe("collect fee edge cases", function () { + it("skips the collect fee when it rounds to zero", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 0, 10n)).wait(); + + const safeBefore = await usdc.balanceOf(safeAddr); + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER))) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V3, 1n, anyValue, 0n, 0n, anyValue, 10n, 0n); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + expect((await usdc.balanceOf(safeAddr)) - safeBefore).to.equal(10n); + }); + + it("waives the collect fee when the treasury transfer returns false or reverts", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, weth, uniNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniNpm.setOwed(1, 100_000n, 40_000n)).wait(); + await (await usdc.setFalseTransferTo(treasury.address)).wait(); + await (await weth.setRevertTransferTo(treasury.address)).wait(); + + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + const tx = manager.connect(operatorEOA).collectLp(UNISWAP_V3, collectParams(safeAddr, 1, FEE_TIER)); + await expect(tx) + .to.emit(manager, "CollectFeeTransferFailed") + .withArgs(safeAddr, 1n, await usdc.getAddress(), 1_000n); + await expect(tx) + .to.emit(manager, "CollectFeeTransferFailed") + .withArgs(safeAddr, 1n, await weth.getAddress(), 2_500n); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(40_000n); + }); + + it("forwards nothing to the Safe when feeCollectBps consumes the whole harvest", async function () { + const f = await loadFixture(deployYieldManagerHarness); + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await f.reg.getAddress(), + f.usdcAddr, + f.wethAddr, + [UNISWAP_V3], + [await f.uniHandler.getAddress()], + [[FEE_TIER]], + [0], + [0], + [ + { + token: f.wethAddr, + config: { pool: f.uniPoolAddr, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + f.treasury.address, + 0, + 10_000, + 10_000, + f.deployer.address, + await f.timelock.getAddress(), + f.pauser.address, + ); + await manager.waitForDeployment(); + + await (await manager.connect(f.operatorEOA).openLp(UNISWAP_V3, openParams(f.safeAddr, FEE_TIER))).wait(); + await (await f.uniNpm.setOwed(1, 0, 40_000n)).wait(); + + const safeBefore = await f.usdc.balanceOf(f.safeAddr); + await expect(manager.connect(f.operatorEOA).collectLp(UNISWAP_V3, collectParams(f.safeAddr, 1, FEE_TIER))) + .to.emit(manager, "FeesCollected") + .withArgs(f.safeAddr, UNISWAP_V3, 1n, anyValue, 0n, 0n, anyValue, 40_000n, 40_000n); + expect(await f.usdc.balanceOf(f.treasury.address)).to.equal(40_000n); + expect(await f.usdc.balanceOf(f.safeAddr)).to.equal(safeBefore); + }); + }); + + describe("setter edge cases", function () { + it("setTreasury rejects the zero address", async function () { + const { manager, timelock } = await loadFixture(deployYieldManagerHarness); + await expect(timelockCall(timelock, manager, "setTreasury", [ZERO])).to.be.revertedWithCustomError( + manager, + "InvalidTreasury", + ); + }); + + it("setFeeCollectBps bounds and updates", async function () { + const { manager, timelock } = await loadFixture(deployYieldManagerHarness); + await expect( + timelockCall(timelock, manager, "setFeeCollectBps", [MAX_FEE_BPS + 1]), + ).to.be.revertedWithCustomError(manager, "FeeAboveMax"); + await expect(timelockCall(timelock, manager, "setFeeCollectBps", [100])) + .to.emit(manager, "FeeCollectBpsUpdated") + .withArgs(COLLECT_FEE_BPS, 100); + expect(await manager.feeCollectBps()).to.equal(100); + }); + + it("admin-only setters reject non-admins", async function () { + const { manager, stranger, usdcAddr } = await loadFixture(deployYieldManagerHarness); + const calls = [ + manager.connect(stranger).setPoolParamAllowed(UNISWAP_V3, FEE_TIER, true), + manager.connect(stranger).setMinPoolLiquidity(UNISWAP_V3, 1), + manager.connect(stranger).setMinPositionLiquidity(UNISWAP_V3, 1), + manager.connect(stranger).setPauser(stranger.address), + manager.connect(stranger).rescueToken(usdcAddr, stranger.address, 1), + manager.connect(stranger).rescueERC721(usdcAddr, 1, stranger.address), + ]; + for (const call of calls) { + await expect(call).to.be.revertedWithCustomError(manager, "AccessControlUnauthorizedAccount"); + } + }); + + it("setPauser validates and rotates", async function () { + const { manager, deployer, stranger } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(deployer).setPauser(ZERO)).to.be.revertedWithCustomError( + manager, + "ZeroAddress", + ); + await expect(manager.connect(deployer).setPauser(stranger.address)).to.emit(manager, "PauserUpdated"); + await expect(manager.connect(stranger).pause()).to.emit(manager, "Paused"); + }); + + it("rescue functions reject zero token and recipient", async function () { + const { manager, deployer, stranger, usdcAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(deployer).rescueToken(ZERO, stranger.address, 1), + ).to.be.revertedWithCustomError(manager, "ZeroAddress"); + await expect(manager.connect(deployer).rescueToken(usdcAddr, ZERO, 1)).to.be.revertedWithCustomError( + manager, + "ZeroAddress", + ); + await expect( + manager.connect(deployer).rescueERC721(ZERO, 1, stranger.address), + ).to.be.revertedWithCustomError(manager, "ZeroAddress"); + await expect(manager.connect(deployer).rescueERC721(usdcAddr, 1, ZERO)).to.be.revertedWithCustomError( + manager, + "ZeroAddress", + ); + }); + + it("setProtocolEnabledForClose requires a registered handler", async function () { + const { manager, pauser } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(pauser).setProtocolEnabledForClose(200, true)).to.be.revertedWithCustomError( + manager, + "HandlerNotSet", + ); + }); + + it("unpause rejects non-pausers", async function () { + const { manager, stranger } = await loadFixture(deployYieldManagerHarness); + await expect(manager.connect(stranger).unpause()).to.be.revertedWithCustomError(manager, "NotAuthorized"); + }); + }); + + describe("arbitrary pairs (two-leg swaps)", function () { + async function deployNonUsdcPair(harness: { manager: any; deployer: any; uniFactory: any; reg: any }) { + const ERC = await ethers.getContractFactory("MockERC20"); + const a = await ERC.deploy("Wrapped BTC", "WBTC", 8); + const b = await ERC.deploy("Tether USD", "USDT", 6); + await a.waitForDeployment(); + await b.waitForDeployment(); + const [tokenX, tokenY] = + (await a.getAddress()).toLowerCase() < (await b.getAddress()).toLowerCase() ? [a, b] : [b, a]; + const xAddr = await tokenX.getAddress(); + const yAddr = await tokenY.getAddress(); + + const LP_PARAM = encodeUniV3PoolParam(xAddr, yAddr, 500); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const lpPool = await Pool.deploy(xAddr, yAddr, Q96, 10n ** 18n); + await (await harness.uniFactory.setPoolFor(xAddr, yAddr, 500, await lpPool.getAddress())).wait(); + await (await harness.reg.setWhitelisted(xAddr, true)).wait(); + await (await harness.reg.setWhitelisted(yAddr, true)).wait(); + return { tokenX, tokenY, xAddr, yAddr, LP_PARAM, Pool }; + } + + it("reverts the whole open when a token rejects the final approval reset", async function () { + const { manager, operatorEOA, safeAddr, weth, uniNpm, uniHandler } = + await loadFixture(deployYieldManagerHarness); + + await (await weth.setFalseApproveZero(true)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)), + ).to.be.revertedWithCustomError(uniHandler, "TokenApprovalFailed"); + + // The revert rolls the mint and the initial router approval back. + expect(await uniNpm.nextId()).to.equal(1n); + expect(await weth.allowance(safeAddr, await uniNpm.getAddress())).to.equal(0n); + }); + + it("opens and closes a non-USDC pair position by swapping both legs through USDC", async function () { + const harness = await loadFixture(deployYieldManagerHarness); + const { + manager, + deployer, + timelock, + operatorEOA, + safeAddr, + usdc, + usdcAddr, + uniFactory, + uniNpm, + uniRouter, + } = harness; + const { tokenX, tokenY, xAddr, yAddr, LP_PARAM, Pool } = await deployNonUsdcPair(harness); + + const [swapX0, swapX1] = + xAddr.toLowerCase() < usdcAddr.toLowerCase() ? [xAddr, usdcAddr] : [usdcAddr, xAddr]; + const [swapY0, swapY1] = + yAddr.toLowerCase() < usdcAddr.toLowerCase() ? [yAddr, usdcAddr] : [usdcAddr, yAddr]; + const SWAP_X_PARAM = encodeUniV3PoolParam(swapX0, swapX1, 500); + const SWAP_Y_PARAM = encodeUniV3PoolParam(swapY0, swapY1, 500); + + const xPool = await Pool.deploy(swapX0, swapX1, Q96, 10n ** 18n); + const yPool = await Pool.deploy(swapY0, swapY1, Q96, 10n ** 18n); + await (await uniFactory.setPoolFor(swapX0, swapX1, 500, await xPool.getAddress())).wait(); + await (await uniFactory.setPoolFor(swapY0, swapY1, 500, await yPool.getAddress())).wait(); + + for (const [token, pool] of [ + [xAddr, xPool], + [yAddr, yPool], + ] as const) { + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + token, + await pool.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + } + for (const param of [LP_PARAM, SWAP_X_PARAM, SWAP_Y_PARAM]) { + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, param, true)).wait(); + } + + const X_OUT = 3_000n; + const Y_OUT = 480_000n; + for (const token of [tokenX, tokenY]) { + await (await token.mint(await uniRouter.getAddress(), 10n ** 18n)).wait(); + await (await token.mint(await uniNpm.getAddress(), 10n ** 18n)).wait(); + } + await (await uniRouter.setOutputFor(xAddr, X_OUT)).wait(); + await (await uniRouter.setOutputFor(yAddr, Y_OUT)).wait(); + + await expect( + manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, LP_PARAM, { + swap0: leg(X_OUT, SWAP_X_PARAM), + swap1: leg(Y_OUT, SWAP_Y_PARAM), + }), + ), + ) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, X_OUT, Y_OUT, USDC_AMOUNT); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + await (await uniRouter.setOutputFor(usdcAddr, 450_000n)).wait(); + await expect( + manager.connect(operatorEOA).closeLp( + UNISWAP_V3, + closeParams(safeAddr, 1, LP_PARAM, { + swap0: leg(450_000n, SWAP_X_PARAM), + swap1: leg(450_000n, SWAP_Y_PARAM), + }), + ), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, 900_000n, 0n, 10_000, 0n); + + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(900_000n); + expect(await tokenX.balanceOf(safeAddr)).to.equal(0); + expect(await tokenY.balanceOf(safeAddr)).to.equal(0); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await uniNpm.ownerOf(1)).to.equal(ZERO); + }); + + it("handles a pair where USDC itself is token0 (skips leg0, swaps only leg1)", async function () { + const { + manager, + deployer, + timelock, + operatorEOA, + safeAddr, + usdc, + usdcAddr, + uniFactory, + uniNpm, + uniRouter, + reg, + } = await loadFixture(deployYieldManagerHarness); + + // Mock deploy addresses are nonce-derived and can land anywhere, so + // place MockERC20 code at usdc + 1 directly — deterministically the + // next address up, making USDC token0 of the pair. + const highAddr = ethers.getAddress(ethers.toBeHex(BigInt(usdcAddr) + 1n, 20)); + await ethers.provider.send("hardhat_setCode", [highAddr, await ethers.provider.getCode(usdcAddr)]); + const high = await ethers.getContractAt("MockERC20", highAddr); + + const LP_PARAM = encodeUniV3PoolParam(usdcAddr, highAddr, 500); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const lpPool = await Pool.deploy(usdcAddr, highAddr, Q96, 10n ** 18n); + await (await uniFactory.setPoolFor(usdcAddr, highAddr, 500, await lpPool.getAddress())).wait(); + await (await reg.setWhitelisted(highAddr, true)).wait(); + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + highAddr, + await lpPool.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, LP_PARAM, true)).wait(); + + const HIGH_OUT = 400_000n; + await (await high.mint(await uniRouter.getAddress(), 10n ** 24n)).wait(); + await (await high.mint(await uniNpm.getAddress(), 10n ** 24n)).wait(); + await (await uniRouter.setOutputFor(highAddr, HIGH_OUT)).wait(); + + // token0 == USDC → swap0 is the ignored zero leg; the LP pool doubles + // as the USDC/high swap venue for leg1. + await expect( + manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, LP_PARAM, { + swap0: ZERO_LEG, + swap1: leg(HIGH_OUT, LP_PARAM), + }), + ), + ) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, HALF, HIGH_OUT, USDC_AMOUNT); + + await (await uniNpm.setOwed(1, 30_000n, 10_000n)).wait(); + await (await uniRouter.setOutputFor(usdcAddr, 9_000n)).wait(); + await expect( + manager.connect(operatorEOA).collectLp( + UNISWAP_V3, + collectParams(safeAddr, 1, LP_PARAM, { + swapFeesToUsdc: true, + swap0: ZERO_LEG, + swap1: leg(9_000n, LP_PARAM), + }), + ), + ).to.emit(manager, "FeesCollected"); + expect(await high.balanceOf(safeAddr)).to.equal(0); + + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + await (await uniRouter.setOutputFor(usdcAddr, 450_000n)).wait(); + await expect( + manager.connect(operatorEOA).closeLp( + UNISWAP_V3, + closeParams(safeAddr, 1, LP_PARAM, { + swap0: ZERO_LEG, + swap1: leg(450_000n, LP_PARAM), + }), + ), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V3, 1n, USDC_AMOUNT, HALF + 450_000n, 0n, 10_000, 0n); + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(HALF + 450_000n); + expect(await high.balanceOf(safeAddr)).to.equal(0); + }); + + it("rejects a leg whose swap pool does not contain USDC", async function () { + const harness = await loadFixture(deployYieldManagerHarness); + const { manager, deployer, timelock, operatorEOA, safeAddr, usdcAddr, uniRouter } = harness; + const { xAddr, yAddr, LP_PARAM, Pool } = await deployNonUsdcPair(harness); + for (const token of [xAddr, yAddr]) { + const [r0, r1] = token.toLowerCase() < usdcAddr.toLowerCase() ? [token, usdcAddr] : [usdcAddr, token]; + const reference = await Pool.deploy(r0, r1, Q96, 10n ** 18n); + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + token, + await reference.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + } + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, LP_PARAM, true)).wait(); + await (await uniRouter.setOutputFor(xAddr, 1n)).wait(); + await (await uniRouter.setOutputFor(yAddr, 1n)).wait(); + + // Both legs route through the X/Y pool itself — it never trades + // USDC, so the leg validation must refuse it for both sides. + await expect( + manager.connect(operatorEOA).openLp( + UNISWAP_V3, + openParams(safeAddr, LP_PARAM, { + swap0: leg(1n, LP_PARAM), + swap1: leg(1n, LP_PARAM), + }), + ), + ).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + }); + }); + + describe("switchLp", function () { + function switchParams( + safeAddr: string, + tokenId: bigint | number, + openPoolParam: string, + overrides: Record = {}, + ) { + return { + onBehalfOf: safeAddr, + tokenId, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: openPoolParam, + deadline: DEADLINE, + ...overrides, + }; + } + + it("moves a position across protocols in kind, carrying the basis without swaps or fee", async function () { + const { + manager, + operatorEOA, + safeAddr, + treasury, + usdc, + weth, + uniNpm, + clNpm, + uniRouter, + clRouter, + aeroHandler, + } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + const uniRouterWethBefore = await weth.balanceOf(await uniRouter.getAddress()); + const clRouterWethBefore = await weth.balanceOf(await clRouter.getAddress()); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V3, AERODROME, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(ZERO); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(AERODROME, 1)).to.equal(await aeroHandler.getAddress()); + expect(await uniNpm.ownerOf(1)).to.equal(ZERO); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + expect(await weth.balanceOf(await uniRouter.getAddress())).to.equal(uniRouterWethBefore); + expect(await weth.balanceOf(await clRouter.getAddress())).to.equal(clRouterWethBefore); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("reverts TokenNotWhitelisted when the switch destination pool token is de-listed", async function () { + const { manager, operatorEOA, safeAddr, reg, wethAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await (await reg.setWhitelisted(wethAddr, false)).wait(); + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.be.revertedWithCustomError(manager, "TokenNotWhitelisted") + .withArgs(wethAddr); + }); + + it("settles the performance fee against the original basis at the real exit", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, clRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + + await (await clRouter.setOutput(600_000n)).wait(); + await expect( + manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + swap0: leg(600_000n, TICK_SPACING), + }), + ), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, AERODROME, 1n, USDC_AMOUNT, 1_100_000n, 10_000n, 10_000, 0n); + expect(await usdc.balanceOf(treasury.address)).to.equal(10_000n); + }); + + it("supports switching pool params within the same protocol", async function () { + const { manager, deployer, operatorEOA, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, BAD_FEE_TIER, true)).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V3, switchParams(safeAddr, 1, BAD_FEE_TIER)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V3, UNISWAP_V3, 1n, 2n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 2)).to.equal(USDC_AMOUNT); + expect(await uniNpm.ownerOf(2)).to.equal(safeAddr); + }); + + it("moves a position from Aerodrome back to Uniswap V3", async function () { + const { manager, operatorEOA, safeAddr, clNpm, uniNpm, uniHandler } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING))).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(AERODROME, UNISWAP_V3, switchParams(safeAddr, 1, FEE_TIER)), + ).to.emit(manager, "PositionSwitched"); + + expect(await clNpm.ownerOf(1)).to.equal(ZERO); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(await uniHandler.getAddress()); + }); + + it("rolls the withdraw leg and bookkeeping back when the replacement open fails", async function () { + const { manager, operatorEOA, stranger, safeAddr, uniNpm, clNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintOwnerOverride(stranger.address)).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "LpNotOnSafe"); + + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.not.equal(ZERO); + expect(await clNpm.nextId()).to.equal(1); + }); + + // ───────────────────────────────────────────────────────────── + // M-02: residue an in-kind switch could not redeploy + // ───────────────────────────────────────────────────────────── + + // With mint usage at 50%, the destination consumes half of each side + // and the rest lands on the Safe: 1_000_000 wei of WETH and 250_000 + // USDC. The reference sits at tick 0, so the WETH side values 1:1 and + // the residue is worth 1_250_000 against a 1_000_000 basis. + const HALF_USAGE_RESIDUE_WETH = WETH_OUT / 2n; + const HALF_USAGE_RESIDUE_USDC = HALF / 2n; + const HALF_USAGE_RESIDUE_USD6 = HALF_USAGE_RESIDUE_WETH + HALF_USAGE_RESIDUE_USDC; + + it("books residue above the basis as carried profit", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, weth, clNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(5_000)).wait(); + const safeUsdcBefore = await usdc.balanceOf(safeAddr); + + const carry = HALF_USAGE_RESIDUE_USD6 - USDC_AMOUNT; + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "SwitchResidueSettled") + .withArgs( + safeAddr, + AERODROME, + 1n, + HALF_USAGE_RESIDUE_WETH, + HALF_USAGE_RESIDUE_USDC, + HALF_USAGE_RESIDUE_USD6, + 0n, + carry, + ); + + // The residue repaid the whole basis, so the replacement position + // starts with none and owes a fee on the excess instead. + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(carry); + expect(await weth.balanceOf(safeAddr)).to.equal(HALF_USAGE_RESIDUE_WETH); + expect((await usdc.balanceOf(safeAddr)) - safeUsdcBefore).to.equal(HALF_USAGE_RESIDUE_USDC); + // Nothing is charged at the switch itself; the fee is settled on exit. + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + async function runPinnedCarryVector(finalValue: bigint) { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, usdc, uniNpm, clNpm, clRouter } = f; + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + // Audit vector: B=1,000,000; R=2,000,000; D=500,000. + // The mock position exits entirely as token0 and the destination + // consumes 25%, leaving U=1,500,000 on the Safe. + await (await uniNpm.setPrincipal(1, 2_000_000n, 0n)).wait(); + await (await clNpm.setMintUsageBps(2_500)).wait(); + const treasuryBeforeSwitch = await usdc.balanceOf(treasury.address); + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "SwitchResidueSettled") + .withArgs(safeAddr, AERODROME, 1n, 1_500_000n, 0n, 1_500_000n, 0n, 500_000n); + expect(await usdc.balanceOf(treasury.address)).to.equal(treasuryBeforeSwitch); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0n); + expect(await manager.carryProfitUsd6Of(UNISWAP_V3, 1)).to.equal(0n); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0n); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(500_000n); + + await (await clRouter.setOutput(finalValue)).wait(); + const treasuryBeforeClose = await usdc.balanceOf(treasury.address); + const receipt = await ( + await manager + .connect(operatorEOA) + .closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING, { swap0: leg(0, TICK_SPACING) })) + ).wait(); + const closed = receipt!.logs + .map((log: any) => { + try { + return manager.interface.parseLog(log); + } catch { + return null; + } + }) + .find((event: any) => event?.name === "PositionClosed")!; + const expectedProfit = finalValue + 500_000n; + const expectedFee = (expectedProfit * PERF_FEE_BPS) / 10_000n; + expect(closed.args.basisUsd6).to.equal(0n); + expect(closed.args.currentValueUsd6).to.equal(finalValue); + expect(closed.args.carryForExitUsd6).to.equal(500_000n); + expect(closed.args.feeUsd6).to.equal(expectedFee); + expect((await usdc.balanceOf(treasury.address)) - treasuryBeforeClose).to.equal(expectedFee); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0n); + } + + it("matches the pinned carry vector when the replacement closes at 500,000", async function () { + await runPinnedCarryVector(500_000n); + }); + + it("reflects replacement loss when the pinned vector closes at 100,000", async function () { + await runPinnedCarryVector(100_000n); + }); + + it("preserves the lifecycle invariant across seeded multi-switch sequences", async function () { + // Deterministic generated cases make failures reproducible while + // covering different hop counts and integer divisions. + let seed = 0x4817n; + const next = () => { + seed = (seed * 1_103_515_245n + 12_345n) & 0x7fff_ffffn; + return seed; + }; + + for (let caseIndex = 0; caseIndex < 6; caseIndex++) { + const f = await loadFixture(deployYieldManagerHarness); + const { manager, operatorEOA, safeAddr, treasury, usdc, uniNpm, clNpm, uniRouter, clRouter } = f; + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + let protocol = UNISWAP_V3; + let tokenId = 1n; + let basis = USDC_AMOUNT; + let carry = 0n; + let totalResidue = 0n; + const hopCount = 1 + Number(next() % 3n); + + for (let hop = 0; hop < hopCount; hop++) { + const realized = 1_000_000n + (next() % 1_000_001n); + const usageBps = 2_000n + (next() % 7_501n); + const destination = protocol === UNISWAP_V3 ? AERODROME : UNISWAP_V3; + const sourceNpm = protocol === UNISWAP_V3 ? uniNpm : clNpm; + const destinationNpm = destination === UNISWAP_V3 ? uniNpm : clNpm; + await (await sourceNpm.setPrincipal(tokenId, realized, 0n)).wait(); + await (await destinationNpm.setMintUsageBps(usageBps)).wait(); + + const destinationParam = destination === UNISWAP_V3 ? FEE_TIER : TICK_SPACING; + const receipt = await ( + await manager + .connect(operatorEOA) + .switchLp(protocol, destination, switchParams(safeAddr, tokenId, destinationParam)) + ).wait(); + const switched = receipt!.logs + .map((log: any) => { + try { + return manager.interface.parseLog(log); + } catch { + return null; + } + }) + .find((event: any) => event?.name === "PositionSwitched")!; + const deployed = (realized * usageBps) / 10_000n; + const residue = realized - deployed; + totalResidue += residue; + if (residue >= basis) { + carry += residue - basis; + basis = 0n; + } else { + basis -= residue; + } + + expect(await manager.residualBasisUsd6Of(destination, switched.args.newTokenId)).to.equal(basis); + expect(await manager.carryProfitUsd6Of(destination, switched.args.newTokenId)).to.equal(carry); + expect(await manager.residualBasisUsd6Of(protocol, tokenId)).to.equal(0n); + expect(await manager.carryProfitUsd6Of(protocol, tokenId)).to.equal(0n); + protocol = destination; + tokenId = switched.args.newTokenId; + } + + const finalValue = 1_000_000n + (next() % 500_001n); + const closeRouter = protocol === UNISWAP_V3 ? uniRouter : clRouter; + const closeParam = protocol === UNISWAP_V3 ? FEE_TIER : TICK_SPACING; + await (await closeRouter.setOutput(finalValue)).wait(); + const treasuryBefore = await usdc.balanceOf(treasury.address); + const receipt = await ( + await manager + .connect(operatorEOA) + .closeLp(protocol, closeParams(safeAddr, tokenId, closeParam, { swap0: leg(0, closeParam) })) + ).wait(); + const closed = receipt!.logs + .map((log: any) => { + try { + return manager.interface.parseLog(log); + } catch { + return null; + } + }) + .find((event: any) => event?.name === "PositionClosed")!; + const lifecycleProfit = + totalResidue + finalValue > USDC_AMOUNT ? totalResidue + finalValue - USDC_AMOUNT : 0n; + const expectedFee = (lifecycleProfit * PERF_FEE_BPS) / 10_000n; + expect(closed.args.feeUsd6).to.equal(expectedFee); + expect((await usdc.balanceOf(treasury.address)) - treasuryBefore).to.equal(expectedFee); + expect(await manager.residualBasisUsd6Of(protocol, tokenId)).to.equal(0n); + expect(await manager.carryProfitUsd6Of(protocol, tokenId)).to.equal(0n); + } + }); + + it("repays basis first when the residue is smaller than it", async function () { + const { manager, operatorEOA, safeAddr, clNpm } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(9_000)).wait(); + + // 10% of each side left behind: 200_000 wei WETH + 50_000 USDC. + const residue = WETH_OUT / 10n + HALF / 10n; + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT - residue); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0); + }); + + it("needs no price at all when the switch redeploys everything", async function () { + const { manager, operatorEOA, safeAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniPool.setObserveReverts(true)).wait(); + + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0); + }); + + it("refuses to guess at a residue it cannot price", async function () { + const { manager, operatorEOA, safeAddr, uniPool, clNpm } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(5_000)).wait(); + await (await uniPool.setObserveReverts(true)).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWith("OLD"); + }); + + it("charges the carried profit at the eventual close", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, clNpm, clRouter } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(5_000)).wait(); + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + const carry = await manager.carryProfitUsd6Of(AERODROME, 1); + expect(carry).to.be.greaterThan(0); + + await (await clRouter.setOutput(600_000n)).wait(); + const treasuryBefore = await usdc.balanceOf(treasury.address); + const receipt = await ( + await manager + .connect(operatorEOA) + .closeLp(AERODROME, closeParams(safeAddr, 1, TICK_SPACING, { swap0: leg(600_000n, TICK_SPACING) })) + ).wait(); + + const closed = receipt!.logs + .map((l: any) => { + try { + return manager.interface.parseLog(l); + } catch { + return null; + } + }) + .find((e: any) => e && e.name === "PositionClosed")!; + const [, , , basisForExit, currentValue, feeUsd6, , carryForExit] = closed.args as unknown as [ + string, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + ]; + + expect(basisForExit).to.equal(0); + expect(carryForExit).to.equal(carry); + // The fee follows lifecycle profit, not just this exit's proceeds. + const expectedFee = ((currentValue + carryForExit - basisForExit) * PERF_FEE_BPS) / 10_000n; + expect(feeUsd6).to.equal(expectedFee); + expect((await usdc.balanceOf(treasury.address)) - treasuryBefore).to.equal(expectedFee); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0); + }); + + it("prorates the carried profit across a partial close", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clRouter } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(5_000)).wait(); + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + const carry = await manager.carryProfitUsd6Of(AERODROME, 1); + + await (await clRouter.setOutput(600_000n)).wait(); + await ( + await manager.connect(operatorEOA).closeLp( + AERODROME, + closeParams(safeAddr, 1, TICK_SPACING, { + exitBps: 5_000, + swap0: leg(600_000n, TICK_SPACING), + }), + ) + ).wait(); + + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(carry - carry / 2n); + }); + + it("accumulates carry across successive switches", async function () { + const { manager, deployer, operatorEOA, safeAddr, uniNpm, clNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, BAD_FEE_TIER, true)).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await (await clNpm.setMintUsageBps(5_000)).wait(); + await ( + await manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)) + ).wait(); + const firstCarry = await manager.carryProfitUsd6Of(AERODROME, 1); + expect(firstCarry).to.be.greaterThan(0); + + // Second hop: basis is already zero, so every unit of residue is + // profit and the carry can only grow. + await (await uniNpm.setMintUsageBps(5_000)).wait(); + await ( + await manager.connect(operatorEOA).switchLp(AERODROME, UNISWAP_V3, switchParams(safeAddr, 1, FEE_TIER)) + ).wait(); + + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0); + expect(await manager.carryProfitUsd6Of(UNISWAP_V3, 2)).to.be.greaterThan(firstCarry); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 2)).to.equal(0); + }); + + it("enforces gating: pause, per-protocol switches, and missing handlers", async function () { + const { manager, operatorEOA, pauser, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + const params = switchParams(safeAddr, 1, TICK_SPACING); + const call = () => manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, params); + + await (await manager.connect(pauser).pause()).wait(); + await expect(call()).to.be.revertedWithCustomError(manager, "EnforcedPause"); + await (await manager.connect(pauser).unpause()).wait(); + + await (await manager.connect(pauser).setProtocolEnabledForClose(UNISWAP_V3, false)).wait(); + await expect(call()).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + await (await manager.connect(pauser).setProtocolEnabledForClose(UNISWAP_V3, true)).wait(); + + await (await manager.connect(pauser).setProtocolEnabledForOpen(AERODROME, false)).wait(); + await expect(call()).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + await (await manager.connect(pauser).setProtocolEnabledForOpen(AERODROME, true)).wait(); + + await expect(manager.connect(operatorEOA).switchLp(UNISWAP_V3, 200, params)).to.be.revertedWithCustomError( + manager, + "HandlerNotSet", + ); + }); + + it("rejects unauthorized callers, expired deadlines, and unknown positions", async function () { + const { manager, operatorEOA, stranger, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager.connect(stranger).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + await expect( + manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING, { deadline: 1 })), + ).to.be.revertedWithCustomError(manager, "DeadlineExpired"); + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 99, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "UnknownPosition"); + }); + + it("enforces the withdraw leg's decrease minimums", async function () { + const { manager, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager + .connect(operatorEOA) + .switchLp( + UNISWAP_V3, + AERODROME, + switchParams(safeAddr, 1, TICK_SPACING, { decreaseAmount0Min: WETH_OUT + 1n }), + ), + ).to.be.revertedWith("Price slippage check"); + }); + + it("rejects a destination mint below the position-liquidity floor", async function () { + const { manager, deployer, operatorEOA, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await manager.connect(deployer).setMinPositionLiquidity(AERODROME, 2_000_000)).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "PositionLiquidityTooLow"); + }); + + it("rejects an in-kind open whose withdrawn tokens do not match the destination pool", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, wethAddr, usdcAddr } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + const ERC = await ethers.getContractFactory("MockERC20"); + const tokenC = await ERC.deploy("Token C", "TKC", 18); + await tokenC.waitForDeployment(); + const tokenCAddr = await tokenC.getAddress(); + await (await tokenC.mint(await uniNpm.getAddress(), 10n ** 24n)).wait(); + + // token0 mismatch: the withdrawal delivers tokenC, the destination trades WETH. + await (await uniNpm.setTokens(1, tokenCAddr, usdcAddr)).wait(); + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + + // token1 mismatch: token0 lines up, so only the second side rejects it. + await (await uniNpm.setTokens(1, wethAddr, tokenCAddr)).wait(); + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "WrongTokenPair"); + }); + + it("withdraws a zero-liquidity position without a decrease and still switches", async function () { + const { manager, operatorEOA, safeAddr, uniNpm, clNpm } = await loadFixture(deployYieldManagerHarness); + await (await uniNpm.setMintLiquidity(0)).wait(); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V3, AERODROME, 1n, 1n, USDC_AMOUNT, 0n, 0n, 0n, 0n); + + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT); + }); + + it("blocks reentrant switchLp through a position-manager callback", async function () { + const { manager, operatorEOA, safeAddr, uniNpm } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await ( + await uniNpm.setCallback( + await manager.getAddress(), + manager.interface.encodeFunctionData("switchLp", [ + UNISWAP_V3, + AERODROME, + switchParams(safeAddr, 1, TICK_SPACING), + ]), + ) + ).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ).to.be.revertedWithCustomError(manager, "ReentrancyGuardReentrantCall"); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // H-01: reference TWAP + // ───────────────────────────────────────────────────────────────────── + + describe("TwapOracle", function () { + async function deployProbe() { + const harness = await loadFixture(deployYieldManagerHarness); + const Probe = await ethers.getContractFactory("TwapOracleProbe"); + const probe = await Probe.deploy(); + await probe.waitForDeployment(); + return { ...harness, probe, poolAddr: await harness.uniPool.getAddress() }; + } + + it("returns the pool's mean tick over the window", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + await (await uniPool.setTwapTick(-201_770)).wait(); + expect(await probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)).to.equal(-201_770); + }); + + it("rounds a negative mean toward negative infinity", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + // -3 ticks over the window, one second short of an exact multiple: + // truncation would give -2, which reads as a BETTER price for the + // side being quoted than the pool actually traded at. + await (await uniPool.setCumulativeDelta(-3n * BigInt(TWAP_WINDOW) + 1n)).wait(); + expect(await probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)).to.equal(-3); + }); + + it("truncates a positive mean without adjustment", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + await (await uniPool.setCumulativeDelta(3n * BigInt(TWAP_WINDOW) + 1n)).wait(); + expect(await probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)).to.equal(3); + }); + + it("refuses an unconfigured pool and a zero window", async function () { + const { probe, poolAddr } = await deployProbe(); + await expect( + probe.meanTick(ethers.ZeroAddress, TWAP_WINDOW, TWAP_CARDINALITY), + ).to.be.revertedWithCustomError(probe, "TwapNotConfigured"); + await expect(probe.meanTick(poolAddr, 0, TWAP_CARDINALITY)).to.be.revertedWithCustomError( + probe, + "TwapWindowZero", + ); + }); + + it("refuses a pool whose observation array is too small to hold the window", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + await (await uniPool.setObservationCardinality(59)).wait(); + await expect(probe.meanTick(poolAddr, TWAP_WINDOW, 60)) + .to.be.revertedWithCustomError(probe, "TwapCardinalityTooLow") + .withArgs(poolAddr, 59, 60); + }); + + // The case a bare `observe()` call cannot detect: an idle pool answers + // without reverting, and every second of the window resolves after its + // newest observation, so the "average" IS the live tick. + it("refuses a pool whose newest observation is stale", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + const maxAge = TWAP_WINDOW / Number(await probe.maxStalenessDivisor()); + + await (await uniPool.setObservationAge(maxAge)).wait(); + expect(await probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)).to.equal(0); + + await (await uniPool.setObservationAge(maxAge + 1)).wait(); + await expect(probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)) + .to.be.revertedWithCustomError(probe, "TwapObservationStale") + .withArgs(poolAddr, maxAge + 1, maxAge); + }); + + it("lets the pool's own OLD revert through rather than falling back to spot", async function () { + const { probe, uniPool, poolAddr } = await deployProbe(); + await (await uniPool.setObserveReverts(true)).wait(); + await expect(probe.meanTick(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY)).to.be.revertedWith("OLD"); + }); + + it("quotes both directions of the pair", async function () { + const { probe, uniPool, poolAddr, wethAddr, usdcAddr } = await deployProbe(); + // token0 = WETH, token1 = USDC. Tick 0 is 1 raw unit for 1 raw unit. + expect(await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, wethAddr, usdcAddr, 10n ** 9n)).to.equal( + 10n ** 9n, + ); + expect(await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, usdcAddr, wethAddr, 10n ** 9n)).to.equal( + 10n ** 9n, + ); + + // One tick up is +1bp on token1 per token0, and the inverse holds. + await (await uniPool.setTwapTick(1)).wait(); + expect(await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, wethAddr, usdcAddr, 10n ** 9n)).to.equal( + 1_000_100_000n, + ); + expect(await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, usdcAddr, wethAddr, 10n ** 9n)).to.equal( + 999_900_009n, + ); + }); + + // Above ~tick 443_636 the sqrt price exceeds uint128 and squaring it + // would overflow Q192, so the library carries the ratio in Q128. + it("quotes both directions past the Q192 overflow threshold", async function () { + const { probe, uniPool, poolAddr, wethAddr, usdcAddr } = await deployProbe(); + await (await uniPool.setTwapTick(500_000)).wait(); + const up = await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, wethAddr, usdcAddr, 1_000n); + const down = await probe.quote(poolAddr, TWAP_WINDOW, TWAP_CARDINALITY, usdcAddr, wethAddr, 10n ** 30n); + expect(up).to.be.greaterThan(10n ** 12n); + expect(down).to.be.greaterThan(0n); + expect(down).to.be.lessThan(10n ** 12n); + }); + }); + + describe("setTwapConfig", function () { + it("stores and atomically replaces a reference, but never clears it", async function () { + const { manager, deployer, timelock, wethAddr, usdcAddr, uniPool } = + await loadFixture(deployYieldManagerHarness); + const poolAddr = await uniPool.getAddress(); + + const stored = await manager.twapConfigOf(wethAddr); + expect(stored.pool).to.equal(poolAddr); + expect(stored.window).to.equal(TWAP_WINDOW); + expect(stored.minCardinality).to.equal(TWAP_CARDINALITY); + + await expect( + manager.connect(deployer).setTwapConfig(wethAddr, ethers.ZeroAddress, 0, 0), + ).to.be.revertedWithCustomError(manager, "OnlyTimelock"); + await expect(timelockCall(timelock, manager, "setTwapConfig", [wethAddr, ethers.ZeroAddress, 0, 0])) + .to.be.revertedWithCustomError(manager, "TwapReferenceRemovalNotAllowed") + .withArgs(wethAddr); + + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const replacement = await Pool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + await replacement.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ) + .to.emit(manager, "TwapConfigUpdated") + .withArgs(wethAddr, await replacement.getAddress(), TWAP_WINDOW, TWAP_CARDINALITY); + expect((await manager.twapConfigOf(wethAddr)).pool).to.equal(await replacement.getAddress()); + }); + + it("refuses a window or cardinality below the floors no role can lower", async function () { + const { manager, timelock, wethAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + const poolAddr = await uniPool.getAddress(); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + poolAddr, + TWAP_WINDOW - 1, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "TwapWindowTooShort"); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + poolAddr, + TWAP_WINDOW, + TWAP_CARDINALITY - 1, + ]), + ).to.be.revertedWithCustomError(manager, "TwapCardinalityBelowFloor"); + }); + + it("refuses a pool that does not trade the pair", async function () { + const { manager, timelock, wethAddr, usdcAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const wrong = await Pool.deploy(wethAddr, wethAddr, Q96, 10n ** 18n); + await wrong.waitForDeployment(); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + await wrong.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "TwapPoolPairMismatch"); + // Right pool, wrong token to key it under. + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + usdcAddr, + await uniPool.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "TwapPoolPairMismatch"); + }); + + it("refuses a reference that cannot answer today", async function () { + const { manager, timelock, wethAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + await (await uniPool.setObservationCardinality(TWAP_CARDINALITY - 1)).wait(); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + await uniPool.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "TwapCardinalityTooLow"); + }); + + it("uses address(0) as the native key and validates it against WETH", async function () { + const { manager, timelock, wethAddr, usdcAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + const poolAddr = await uniPool.getAddress(); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + ethers.ZeroAddress, + poolAddr, + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ) + .to.emit(manager, "TwapConfigUpdated") + .withArgs(ethers.ZeroAddress, poolAddr, TWAP_WINDOW, TWAP_CARDINALITY); + expect((await manager.twapConfigOf(ethers.ZeroAddress)).pool).to.equal(poolAddr); + expect(await manager.twapQuote(ethers.ZeroAddress, usdcAddr, 500_000n)).to.equal(500_000n); + + const ERC = await ethers.getContractFactory("MockERC20"); + const other = await ERC.deploy("Other", "OTHER", 18); + const otherAddr = await other.getAddress(); + const [p0, p1] = + otherAddr.toLowerCase() < usdcAddr.toLowerCase() ? [otherAddr, usdcAddr] : [usdcAddr, otherAddr]; + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const wrong = await Pool.deploy(p0, p1, Q96, 10n ** 18n); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + ethers.ZeroAddress, + await wrong.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "TwapPoolPairMismatch"); + expect(await manager.WETH()).to.equal(wethAddr); + }); + + it("rejects direct DEFAULT_ADMIN calls, codeless pools, and uninitialized pools", async function () { + const { manager, deployer, stranger, timelock, wethAddr, usdcAddr, uniPool } = + await loadFixture(deployYieldManagerHarness); + const poolAddr = await uniPool.getAddress(); + for (const caller of [deployer, stranger]) { + await expect( + manager.connect(caller).setTwapConfig(wethAddr, poolAddr, TWAP_WINDOW, TWAP_CARDINALITY), + ).to.be.revertedWithCustomError(manager, "OnlyTimelock"); + } + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + stranger.address, + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ) + .to.be.revertedWithCustomError(manager, "InvalidTwapReferencePool") + .withArgs(stranger.address); + + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const uninitialized = await Pool.deploy(wethAddr, usdcAddr, 0, 10n ** 18n); + await expect( + timelockCall(timelock, manager, "setTwapConfig", [ + wethAddr, + await uninitialized.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]), + ).to.be.revertedWithCustomError(manager, "PoolNotInitialized"); + }); + + it("quotes and previews the exact effective floor", async function () { + const { manager, wethAddr, usdcAddr } = await loadFixture(deployYieldManagerHarness); + expect(await manager.twapQuote(usdcAddr, wethAddr, 500_000n)).to.equal(500_000n); + expect(await manager.twapMinimumOut(usdcAddr, wethAddr, 500_000n, 100)).to.equal(495_000n); + }); + + // The allow-list gained a parallel array plus an index map so protocol + // re-enablement can walk it. Swap-and-pop bookkeeping is easy to get + // wrong in a way nothing else notices, so it is pinned directly. + it("keeps the enumerable allow-list consistent through add, no-op and swap-and-pop removal", async function () { + const { manager, deployer, uniPool, wethAddr, usdcAddr } = await loadFixture(deployYieldManagerHarness); + const poolAddr = await uniPool.getAddress(); + const extraA = encodeUniV3PoolParam(wethAddr, usdcAddr, 3000); + const extraB = encodeUniV3PoolParam(wethAddr, usdcAddr, 10000); + + // The fixture allow-lists exactly one param for UNISWAP_V3. + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(1); + expect(await manager.allowedPoolParamAt(UNISWAP_V3, 0)).to.equal(FEE_TIER); + + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraA, true)).wait(); + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraB, true)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(3); + + // Re-allowing an already-allowed param must not duplicate the entry. + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraA, true)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(3); + // Disallowing something never allowed is likewise a no-op, not an + // underflow on the index map. + const unseen = encodeUniV3PoolParam(wethAddr, usdcAddr, 100); + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, unseen, false)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(3); + + // Remove from the MIDDLE: the last entry has to move into the hole + // and its index entry has to follow it. + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraA, false)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(2); + expect(await manager.isPoolParamAllowed(UNISWAP_V3, extraA)).to.equal(false); + const remaining = [ + await manager.allowedPoolParamAt(UNISWAP_V3, 0), + await manager.allowedPoolParamAt(UNISWAP_V3, 1), + ]; + expect(remaining).to.have.members([FEE_TIER, extraB]); + + // Removing the moved entry exercises the index == lastIndex path, + // and the survivor must still be addressable afterwards. + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraB, false)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(1); + expect(await manager.allowedPoolParamAt(UNISWAP_V3, 0)).to.equal(FEE_TIER); + expect(await manager.isPoolParamAllowed(UNISWAP_V3, FEE_TIER)).to.equal(true); + + // Re-enabling the protocol still walks a coherent list. + await (await manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, extraA, true)).wait(); + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(2); + expect(poolAddr).to.properAddress; + }); + + it("refuses to allow-list a param for a protocol with no handler", async function () { + const { manager, deployer, wethAddr, usdcAddr } = await loadFixture(deployYieldManagerHarness); + await expect( + manager.connect(deployer).setPoolParamAllowed(99, encodeUniV3PoolParam(wethAddr, usdcAddr, 500), true), + ).to.be.revertedWithCustomError(manager, "HandlerNotSet"); + }); + + it("checks a degenerate same-token pool param only once", async function () { + const { manager, deployer, wethAddr } = await loadFixture(deployYieldManagerHarness); + // token0 == token1: the second reference lookup is skipped rather + // than repeated. WETH has a reference, so this is allowed. + await ( + await manager + .connect(deployer) + .setPoolParamAllowed(UNISWAP_V3, encodeUniV3PoolParam(wethAddr, wethAddr, 500), true) + ).wait(); + expect( + await manager.isPoolParamAllowed(UNISWAP_V3, encodeUniV3PoolParam(wethAddr, wethAddr, 500)), + ).to.equal(true); + }); + + it("bounds slippage and requires a reference when previewing the floor", async function () { + const { manager, wethAddr, usdcAddr } = await loadFixture(deployYieldManagerHarness); + await expect(manager.twapMinimumOut(usdcAddr, wethAddr, 1_000n, 0)).to.be.revertedWithCustomError( + manager, + "SlippageTooLow", + ); + await expect(manager.twapMinimumOut(usdcAddr, wethAddr, 1_000n, 301)).to.be.revertedWithCustomError( + manager, + "SlippageAboveMax", + ); + const unpriced = ethers.Wallet.createRandom().address; + await expect(manager.twapMinimumOut(usdcAddr, unpriced, 1_000n, 100)).to.be.revertedWithCustomError( + manager, + "TwapNotConfigured", + ); + }); + + it("quotes the native key in both directions once it is configured", async function () { + const { manager, timelock, usdcAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + ethers.ZeroAddress, + await uniPool.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + + // Native in and native out both resolve through WETH for the tick + // math while keeping address(0) as the configuration key. + expect(await manager.twapQuote(ethers.ZeroAddress, usdcAddr, 1_000_000n)).to.equal(1_000_000n); + expect(await manager.twapQuote(usdcAddr, ethers.ZeroAddress, 1_000_000n)).to.equal(1_000_000n); + }); + + it("guards new allow-list entries and protocol re-enablement with live references", async function () { + const { manager, deployer, pauser, timelock, usdcAddr, uniHandler, uniPool } = + await loadFixture(deployYieldManagerHarness); + const ERC = await ethers.getContractFactory("MockERC20"); + const token = await ERC.deploy("New Token", "NEW", 18); + const tokenAddr = await token.getAddress(); + const [p0, p1] = + tokenAddr.toLowerCase() < usdcAddr.toLowerCase() ? [tokenAddr, usdcAddr] : [usdcAddr, tokenAddr]; + const param = encodeUniV3PoolParam(p0, p1, 500); + + await expect(manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, param, true)) + .to.be.revertedWithCustomError(manager, "TwapNotConfigured") + .withArgs(tokenAddr); + + const Pool = await ethers.getContractFactory("MockUniswapV3Pool"); + const reference = await Pool.deploy(p0, p1, Q96, 10n ** 18n); + await ( + await timelockCall(timelock, manager, "setTwapConfig", [ + tokenAddr, + await reference.getAddress(), + TWAP_WINDOW, + TWAP_CARDINALITY, + ]) + ).wait(); + await expect(manager.connect(deployer).setPoolParamAllowed(UNISWAP_V3, param, true)) + .to.emit(manager, "PoolParamAllowedUpdated") + .withArgs(UNISWAP_V3, param, false, true); + + expect(await manager.allowedPoolParamCount(UNISWAP_V3)).to.equal(2); + expect(await manager.allowedPoolParamAt(UNISWAP_V3, 1)).to.equal(param); + + await (await manager.connect(pauser).setProtocolEnabledForOpen(UNISWAP_V3, false)).wait(); + await (await uniPool.setObserveReverts(true)).wait(); + await expect(manager.connect(pauser).setProtocolEnabledForOpen(UNISWAP_V3, true)).to.be.revertedWith("OLD"); + expect(await manager.protocolEnabledForOpen(UNISWAP_V3)).to.equal(false); + await (await uniPool.setObserveReverts(false)).wait(); + await expect(manager.connect(pauser).setProtocolEnabledForOpen(UNISWAP_V3, true)) + .to.emit(manager, "ProtocolStatusChanged") + .withArgs(UNISWAP_V3, true, true); + + expect(await uniHandler.PROTOCOL()).to.equal(UNISWAP_V3); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // H-01: the exit that needs no oracle + // ───────────────────────────────────────────────────────────────────── + + describe("withdrawLp", function () { + function withdrawParams(safeAddr: string, tokenId: bigint | number, overrides: Record = {}) { + return { + onBehalfOf: safeAddr, + tokenId, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: DEADLINE, + ...overrides, + }; + } + + it("exits in kind, releases the basis, and charges no fee", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, uniNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + + const treasuryBefore = await usdc.balanceOf(treasury.address); + await expect(manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1))).to.emit( + manager, + "PositionWithdrawn", + ); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(ethers.ZeroAddress); + expect(await usdc.balanceOf(treasury.address)).to.equal(treasuryBefore); + // Burned: the mock clears the owner rather than reverting. + expect(await uniNpm.ownerOf(1)).to.equal(ethers.ZeroAddress); + }); + + // The reason this entry point exists: a position must not depend on a + // price reference to get out. + it("still exits when the price reference is gone", async function () { + const { manager, operatorEOA, safeAddr, uniPool } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await uniPool.setObserveReverts(true)).wait(); + + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V3, closeParams(safeAddr, 1, FEE_TIER)), + ).to.be.revertedWith("OLD"); + await expect(manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1))).to.emit( + manager, + "PositionWithdrawn", + ); + }); + + it("stays available while the manager is paused", async function () { + const { manager, operatorEOA, pauser, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await manager.connect(pauser).pause()).wait(); + await expect(manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1))).to.emit( + manager, + "PositionWithdrawn", + ); + }); + + // The emergency exit has to work for the position type that is hardest + // to get out of: one the stakePool owns rather than the Safe. + it("unstakes a staked position and clears its pin", async function () { + const { manager, operatorEOA, safeAddr, clNpm, clPool, voter } = + await loadFixture(deployYieldManagerHarness); + const { stakePoolAddr } = await deployStakePool(clNpm, clPool, voter); + await ( + await manager + .connect(operatorEOA) + .openLp(AERODROME, openParams(safeAddr, TICK_SPACING, { stake: true })) + ).wait(); + expect(await clNpm.ownerOf(1)).to.equal(stakePoolAddr); + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(stakePoolAddr); + + await expect(manager.connect(operatorEOA).withdrawLp(AERODROME, withdrawParams(safeAddr, 1))).to.emit( + manager, + "PositionWithdrawn", + ); + + expect(await manager.stakePoolOf(AERODROME, 1)).to.equal(ethers.ZeroAddress); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0); + }); + + it("reports the carried profit it releases without charging it", async function () { + const { manager, operatorEOA, safeAddr, treasury, usdc, clNpm } = + await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + await (await clNpm.setMintUsageBps(5_000)).wait(); + await ( + await manager.connect(operatorEOA).switchLp(UNISWAP_V3, AERODROME, { + onBehalfOf: safeAddr, + tokenId: 1, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: TICK_SPACING, + deadline: DEADLINE, + }) + ).wait(); + const carry = await manager.carryProfitUsd6Of(AERODROME, 1); + expect(carry).to.be.greaterThan(0); + + const treasuryBefore = await usdc.balanceOf(treasury.address); + await expect(manager.connect(operatorEOA).withdrawLp(AERODROME, withdrawParams(safeAddr, 1))) + .to.emit(manager, "PositionWithdrawn") + .withArgs(safeAddr, AERODROME, 1n, 0n, carry, anyValue, anyValue); + + // Waived, not collected — and the waiver is on the record. + expect(await usdc.balanceOf(treasury.address)).to.equal(treasuryBefore); + expect(await manager.carryProfitUsd6Of(AERODROME, 1)).to.equal(0); + }); + + it("honours the same gates as every other exit", async function () { + const { manager, operatorEOA, pauser, stranger, safeAddr } = await loadFixture(deployYieldManagerHarness); + await (await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER))).wait(); + + await expect( + manager.connect(stranger).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1)), + ).to.be.revertedWithCustomError(manager, "NotAuthorized"); + await expect( + manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1, { deadline: 1 })), + ).to.be.revertedWithCustomError(manager, "DeadlineExpired"); + await expect( + manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 999)), + ).to.be.revertedWithCustomError(manager, "UnknownPosition"); + + await (await manager.connect(pauser).setProtocolEnabledForClose(UNISWAP_V3, false)).wait(); + await expect( + manager.connect(operatorEOA).withdrawLp(UNISWAP_V3, withdrawParams(safeAddr, 1)), + ).to.be.revertedWithCustomError(manager, "ProtocolDisabled"); + }); + }); +}); diff --git a/test/yield/safeYieldManagerAerodromeFork.ts b/test/yield/safeYieldManagerAerodromeFork.ts new file mode 100644 index 0000000..d4b640a --- /dev/null +++ b/test/yield/safeYieldManagerAerodromeFork.ts @@ -0,0 +1,1083 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { + AERO_ADDRESS, + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_VOTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + TWAP_REF_WETH_USDC_POOL, + TWAP_REF_AERO_USDC_POOL, + TWAP_WINDOW, + TWAP_CARDINALITY, +} from "../../contractAddresses"; +import { encodeAerodromePoolParam } from "../../contractAddresses"; +import { ZERO_LEG, leg, pokeTwapPool } from "../helpers/utils"; +import { deployRealSafe, enableModuleOnSafe } from "../helpers/deployRealSafe"; + +const AERODROME = 1; +const TICK_SPACING = 100; +const AERO_TICK_SPACING = 50; // tick spacing of the live USDC/AERO CL pool +const FORK_BLOCK = Number(process.env.BASE_FORK_BLOCK_NUMBER ?? 49_470_000); +const POOL_PARAM = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); +// USDC < AERO, so USDC is token0 of the USDC/AERO CL pools — the pair +// ordering the WETH/USDC tests never exercise (funding token as token0, +// swap1 leg doing the real work). The ts-50 pool is nearly empty; ts 200 +// is the deep USDC/AERO pool that can absorb LP-sized swaps. +const AERO_LP_TICK_SPACING = 200; +const AERO_POOL_PARAM = encodeAerodromePoolParam(USDC_ADDRESS, AERO_ADDRESS, AERO_LP_TICK_SPACING); + +const ERC20_ABI = [ + "function balanceOf(address) view returns (uint256)", + "function transfer(address,uint256) returns (bool)", +]; +const FACTORY_ABI = ["function getPool(address,address,int24) view returns (address)"]; +const POOL_ABI = [ + "function token0() view returns (address)", + "function token1() view returns (address)", + "function slot0() view returns (uint160,int24,uint16,uint16,uint16,bool)", +]; +const NPM_ABI = ["function ownerOf(uint256) view returns (address)"]; +const VOTER_ABI = ["function gauges(address) view returns (address)"]; + +async function deployAeroStack() { + const [admin, operator, treasury, pauser] = await ethers.getSigners(); + + const Registry = await ethers.getContractFactory("MockRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + await (await registry.setOperator(operator.address)).wait(); + await (await registry.setWhitelisted(WETH_ADDRESS, true)).wait(); + await (await registry.setWhitelisted(USDC_ADDRESS, true)).wait(); + + const safeAddress = await deployRealSafe(admin); + + const Handler = await ethers.getContractFactory("AerodromeYieldHandler"); + const handler = await Handler.deploy( + AERODROME_SLIPSTREAM_NPM_ADDRESS, + USDC_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_VOTER_ADDRESS, + ); + await handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await registry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + [AERODROME], + [await handler.getAddress()], + [[POOL_PARAM]], + [0], + [0], + [ + { + token: WETH_ADDRESS, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + { + token: AERO_ADDRESS, + config: { pool: TWAP_REF_AERO_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + 1_000, + 250, + 2_000, + admin.address, + await timelock.getAddress(), + pauser.address, + ); + await manager.waitForDeployment(); + await enableModuleOnSafe(safeAddress, admin, await manager.getAddress()); + + return { operator, treasury, safeAddress, handler, manager, registry }; +} + +async function readAeroPool() { + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, TICK_SPACING); + expect(poolAddress).to.not.equal(ethers.ZeroAddress); + const pool = new ethers.Contract(poolAddress, POOL_ABI, ethers.provider); + const [sqrtPriceRaw, tick] = await pool.slot0(); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + const alignedTick = Math.floor(Number(tick) / TICK_SPACING) * TICK_SPACING; + return { poolAddress, pool, sqrtPriceX96, alignedTick }; +} + +const ROUTER_ABI = [ + "function exactInputSingle((address,address,int24,address,uint256,uint256,uint256,uint160)) payable returns (uint256)", +]; +const WETH_DEPOSIT_ABI = [ + "function deposit() payable", + "function approve(address,uint256) returns (bool)", + "function balanceOf(address) view returns (uint256)", +]; + +// Ping-pong real swaps through the WETH/USDC pool so in-range positions +// accrue genuine trading fees on the fork. +async function accrueSwapFees(rounds: number, wethPerSwap: bigint) { + const trader = (await ethers.getSigners())[4]; + const weth = new ethers.Contract(WETH_ADDRESS, WETH_DEPOSIT_ABI, trader); + const usdc = new ethers.Contract( + USDC_ADDRESS, + [...ERC20_ABI, "function approve(address,uint256) returns (bool)"], + trader, + ); + const router = new ethers.Contract(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ROUTER_ABI, trader); + await (await weth.deposit({ value: wethPerSwap * 2n })).wait(); + await (await weth.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ethers.MaxUint256)).wait(); + await (await usdc.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ethers.MaxUint256)).wait(); + for (let i = 0; i < rounds; i++) { + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 600); + await ( + await router.exactInputSingle([ + WETH_ADDRESS, + USDC_ADDRESS, + TICK_SPACING, + trader.address, + deadline, + wethPerSwap, + 0n, + 0n, + ]) + ).wait(); + const usdcBal: bigint = await usdc.balanceOf(trader.address); + await ( + await router.exactInputSingle([ + USDC_ADDRESS, + WETH_ADDRESS, + TICK_SPACING, + trader.address, + deadline, + usdcBal, + 0n, + 0n, + ]) + ).wait(); + } +} + +async function pushAeroSpotDown(wethIn: bigint) { + const trader = (await ethers.getSigners())[4]; + const weth = new ethers.Contract(WETH_ADDRESS, WETH_DEPOSIT_ABI, trader); + const router = new ethers.Contract(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ROUTER_ABI, trader); + await (await weth.deposit({ value: wethIn })).wait(); + await (await weth.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, wethIn)).wait(); + const block = await ethers.provider.getBlock("latest"); + await ( + await router.exactInputSingle([ + WETH_ADDRESS, + USDC_ADDRESS, + TICK_SPACING, + trader.address, + BigInt(block!.timestamp + 600), + wethIn, + 0n, + 0n, + ]) + ).wait(); +} + +// Same ping-pong, but through the USDC/AERO ts-200 pool, with the trader's +// USDC capital pulled from the (separate) WETH/USDC pool. +async function accrueAeroSwapFees(wethUsdcPool: string, rounds: number, usdcCapital: bigint) { + const trader = (await ethers.getSigners())[4]; + await fundSafeUsdc(wethUsdcPool, trader.address, usdcCapital); + const approveAbi = ["function approve(address,uint256) returns (bool)"]; + const usdc = new ethers.Contract(USDC_ADDRESS, [...ERC20_ABI, ...approveAbi], trader); + const aero = new ethers.Contract(AERO_ADDRESS, [...ERC20_ABI, ...approveAbi], trader); + const router = new ethers.Contract(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ROUTER_ABI, trader); + await (await usdc.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ethers.MaxUint256)).wait(); + await (await aero.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, ethers.MaxUint256)).wait(); + for (let i = 0; i < rounds; i++) { + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 600); + const usdcBal: bigint = await usdc.balanceOf(trader.address); + await ( + await router.exactInputSingle([ + USDC_ADDRESS, + AERO_ADDRESS, + AERO_LP_TICK_SPACING, + trader.address, + deadline, + usdcBal, + 0n, + 0n, + ]) + ).wait(); + const aeroBal: bigint = await aero.balanceOf(trader.address); + await ( + await router.exactInputSingle([ + AERO_ADDRESS, + USDC_ADDRESS, + AERO_LP_TICK_SPACING, + trader.address, + deadline, + aeroBal, + 0n, + 0n, + ]) + ).wait(); + } +} + +// The live pool is a convenient deterministic USDC holder on the fork. +// Impersonation only mutates the disposable fork state. +async function fundSafeUsdc(poolAddress: string, safeAddress: string, amount: bigint) { + await network.provider.send("hardhat_setBalance", [poolAddress, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(poolAddress); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, ethers.provider); + await (await (usdc.connect(poolSigner) as any).transfer(safeAddress, amount)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [poolAddress]); + return usdc; +} + +describe("SafeYieldManager + Aerodrome - integration (Base fork)", function () { + this.timeout(300_000); + + beforeEach(async function () { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org", + blockNumber: FORK_BLOCK, + }, + }, + ], + }); + }); + + it("opens, collects, partially closes, and fully closes a real Slipstream position", async function () { + const { operator, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, pool, sqrtPriceX96, alignedTick } = await readAeroPool(); + expect(await pool.token0()).to.equal(WETH_ADDRESS); + expect(await pool.token1()).to.equal(USDC_ADDRESS); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdc(poolAddress, safeAddress, input); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: false, + }; + + await expect( + manager.connect(operator).openLp(AERODROME, { + ...openParams, + swap0: leg((expectedSwapOut * 2n * 9_900n) / 10_000n, POOL_PARAM), + }), + ).to.be.revertedWith("Too little received"); + + await expect(manager.connect(operator).openLp(AERODROME, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + const initialBasis = await manager.residualBasisUsd6Of(AERODROME, tokenId); + expect(initialBasis).to.be.greaterThan(0); + + await expect( + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.emit(manager, "FeesCollected"); + + const wethToLp: bigint = opened.args.amount0ToLp; + const usdcToLp: bigint = opened.args.amount1ToLp; + // shareOfOriginalBps: fraction of the ORIGINAL position this close + // removes, driving spot-price estimates of the swap and total output. + const closeParams = (exitBps: number, shareOfOriginalBps: bigint) => { + const wethShare = (wethToLp * shareOfOriginalBps) / 10_000n; + const usdcShare = (usdcToLp * shareOfOriginalBps) / 10_000n; + const expectedOut = spotWethToUsdc(wethShare); + return { + onBehalfOf: safeAddress, + tokenId, + exitBps, + swap0: leg((expectedOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcShare + expectedOut) * 9_500n) / 10_000n, + }; + }; + + const manipulationSnapshot = await network.provider.send("evm_snapshot"); + const tickBeforeManipulation = Number((await pool.slot0())[1]); + await pushAeroSpotDown(ethers.parseEther("1000")); + const tickAfterManipulation = Number((await pool.slot0())[1]); + expect(tickAfterManipulation).to.be.lessThan(tickBeforeManipulation - 300); + await expect( + manager.connect(operator).closeLp(AERODROME, { + ...closeParams(10_000, 10_000n), + swap0: leg(0, POOL_PARAM), + minUsdcOut: 0, + }), + ).to.be.revertedWith("Too little received"); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await network.provider.send("evm_revert", [manipulationSnapshot])).to.equal(true); + + await expect(manager.connect(operator).closeLp(AERODROME, closeParams(5_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.be.lessThan(initialBasis); + + await expect(manager.connect(operator).closeLp(AERODROME, closeParams(10_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.equal(0); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); + + it("stakes the minted position into the real Voter's stakePool and unstakes it on close", async function () { + const { operator, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, sqrtPriceX96, alignedTick } = await readAeroPool(); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + const voter = new ethers.Contract(AERODROME_VOTER_ADDRESS, VOTER_ABI, ethers.provider); + const stakePoolAddress: string = await voter.gauges(poolAddress); + expect(stakePoolAddress).to.not.equal(ethers.ZeroAddress); + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdc(poolAddress, safeAddress, input); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: true, + }; + + await expect(manager.connect(operator).openLp(AERODROME, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + + // Staked: the stakePool, not the Safe, holds the NFT after open. + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); + const initialBasis = await manager.residualBasisUsd6Of(AERODROME, tokenId); + expect(initialBasis).to.be.greaterThan(0); + + const wethToLp: bigint = opened.args.amount0ToLp; + const usdcToLp: bigint = opened.args.amount1ToLp; + const expectedOut = spotWethToUsdc(wethToLp); + const closeParams = { + onBehalfOf: safeAddress, + tokenId, + exitBps: 10_000, + swap0: leg((expectedOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcToLp + expectedOut) * 9_500n) / 10_000n, + }; + + // The handler unstakes from the stakePool before running the normal close + // flow, so a fully staked position closes exactly like an unstaked one. + await expect(manager.connect(operator).closeLp(AERODROME, closeParams)).to.emit(manager, "PositionClosed"); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.equal(0); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + // A burned position keeps no staking pin. + expect(await manager.stakePoolOf(AERODROME, tokenId)).to.equal(ethers.ZeroAddress); + }); + + // M-03: real emissions, real gauge. Every route that can pay a reward — an + // explicit collect, the gauge withdrawal inside a partial close, and the one + // inside a full close — must hand feeCollectBps of the CLAIMED amount to the + // treasury. Gross is measured as (Safe delta + treasury delta) so the assertion + // is exact regardless of how much accrued. + it("charges the collect fee on real gauge emissions for collect, partial close, and full close", async function () { + const { operator, treasury, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, sqrtPriceX96, alignedTick } = await readAeroPool(); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + const voter = new ethers.Contract(AERODROME_VOTER_ADDRESS, VOTER_ABI, ethers.provider); + const stakePoolAddress: string = await voter.gauges(poolAddress); + expect(stakePoolAddress).to.not.equal(ethers.ZeroAddress); + const stakePool = new ethers.Contract( + stakePoolAddress, + [ + "function rewardToken() view returns (address)", + "function earned(address,uint256) view returns (uint256)", + "function periodFinish() view returns (uint256)", + ], + ethers.provider, + ); + const aero = new ethers.Contract(await stakePool.rewardToken(), ERC20_ABI, ethers.provider); + + const input = ethers.parseUnits("10", 6); + await fundSafeUsdc(poolAddress, safeAddress, input); + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 30 * 86_400); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + + await expect( + manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: true, + }), + ).to.emit(manager, "PositionOpened"); + + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + + /// Run `action`, then assert the treasury took exactly feeCollectBps of + /// everything the gauge newly paid out. + async function expectRewardFeeCharged(action: () => Promise) { + const safeBefore: bigint = await aero.balanceOf(safeAddress); + const treasuryBefore: bigint = await aero.balanceOf(treasury.address); + await (await action()).wait(); + const treasuryDelta: bigint = (await aero.balanceOf(treasury.address)) - treasuryBefore; + const claimed: bigint = (await aero.balanceOf(safeAddress)) - safeBefore + treasuryDelta; + expect(claimed).to.be.greaterThan(0n); + expect(treasuryDelta).to.equal((claimed * 250n) / 10_000n); + } + + // Emissions only accrue until the gauge's current epoch ends, so the three + // claims below share the time that is actually left rather than warping a + // fixed span and silently claiming nothing after the period finishes. + const nowTs = (await ethers.provider.getBlock("latest"))!.timestamp; + const periodFinish = Number(await stakePool.periodFinish()); + expect(periodFinish).to.be.greaterThan(nowTs + 4); + const warpStep = Math.floor((periodFinish - nowTs) / 4); + const warp = async () => { + await network.provider.send("evm_increaseTime", [warpStep]); + await network.provider.send("evm_mine"); + // A live chain trades while time passes; a warped fork does not, so + // the references would look abandoned to the oracle. + await pokeTwapPool(TWAP_REF_WETH_USDC_POOL); + await pokeTwapPool(TWAP_REF_AERO_USDC_POOL); + }; + + const npmOwner = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npmOwner.ownerOf(tokenId)).to.equal(stakePoolAddress); + + await warp(); + expect(await stakePool.earned(safeAddress, tokenId)).to.be.greaterThan(0n); + await expectRewardFeeCharged(() => + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ); + + // Partial close: the gauge pays out on withdrawal, so that claim is taxed too. + await warp(); + const halfOut = spotWethToUsdc(opened.args.amount0ToLp / 2n); + await expectRewardFeeCharged(() => + manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + exitBps: 5_000, + swap0: leg((halfOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: 0, + }), + ); + + // Still staked after the partial close, so it keeps accruing for the final exit. + await warp(); + const restOut = spotWethToUsdc(opened.args.amount0ToLp / 2n); + await expectRewardFeeCharged(() => + manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + exitBps: 10_000, + swap0: leg((restOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: 0, + }), + ); + }); + + // L-01: the mock can prove the restake call happens; only the fork can prove the + // REAL gauge takes the NFT back and keeps paying emissions on the survivor. + it("restakes into the same real gauge after a partial close and keeps accruing emissions", async function () { + const { operator, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, sqrtPriceX96, alignedTick } = await readAeroPool(); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + const voter = new ethers.Contract(AERODROME_VOTER_ADDRESS, VOTER_ABI, ethers.provider); + const stakePoolAddress: string = await voter.gauges(poolAddress); + expect(stakePoolAddress).to.not.equal(ethers.ZeroAddress); + + const input = ethers.parseUnits("10", 6); + await fundSafeUsdc(poolAddress, safeAddress, input); + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + + await expect( + manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: true, + }), + ).to.emit(manager, "PositionOpened"); + + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + const npmPositions = new ethers.Contract( + AERODROME_SLIPSTREAM_NPM_ADDRESS, + [ + "function positions(uint256) view returns (uint96,address,address,address,int24,int24,int24,uint128,uint256,uint256,uint128,uint128)", + ], + ethers.provider, + ); + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); + expect(await manager.stakePoolOf(AERODROME, tokenId)).to.equal(stakePoolAddress); + const liquidityBefore: bigint = (await npmPositions.positions(tokenId))[7]; + + const halfWethOut = spotWethToUsdc(opened.args.amount0ToLp / 2n); + await expect( + manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + exitBps: 5_000, + swap0: leg((halfWethOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: 0, + }), + ).to.emit(manager, "PositionClosed"); + + // The survivor is back in the SAME gauge, with the pin still pointing there. + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); + expect(await manager.stakePoolOf(AERODROME, tokenId)).to.equal(stakePoolAddress); + const liquidityAfter: bigint = (await npmPositions.positions(tokenId))[7]; + expect(liquidityAfter).to.be.greaterThan(0n); + expect(liquidityAfter).to.be.lessThan(liquidityBefore); + + // Emissions resume: only a staked NFT earns, so this would read zero if the + // partial close had left the position on the Safe. + await network.provider.send("evm_increaseTime", [5 * 86_400]); + await network.provider.send("evm_mine"); + await pokeTwapPool(TWAP_REF_WETH_USDC_POOL); + await pokeTwapPool(TWAP_REF_AERO_USDC_POOL); + const stakePool = new ethers.Contract( + stakePoolAddress, + ["function earned(address,uint256) view returns (uint256)"], + ethers.provider, + ); + expect(await stakePool.earned(safeAddress, tokenId)).to.be.greaterThan(0n); + }); + + it("collectLp on a staked position claims stakePool rewards only and leaves the NFT staked", async function () { + const { operator, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, sqrtPriceX96, alignedTick } = await readAeroPool(); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + + const voter = new ethers.Contract(AERODROME_VOTER_ADDRESS, VOTER_ABI, ethers.provider); + const stakePoolAddress: string = await voter.gauges(poolAddress); + expect(stakePoolAddress).to.not.equal(ethers.ZeroAddress); + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdc(poolAddress, safeAddress, input); + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + + await expect( + manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: true, + }), + ).to.emit(manager, "PositionOpened"); + + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const tokenId = openedEvents[openedEvents.length - 1].args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); + const basisBefore = await manager.residualBasisUsd6Of(AERODROME, tokenId); + + // collectLp on a STAKED position goes through the real stakePool with + // getReward ONLY: staked liquidity earns AERO emissions instead of + // trading fees, so there is nothing to collect and no FeesCollected — + // and, the assertion a mock can't prove, the NFT never leaves the + // stakePool so the position keeps earning emissions. + await expect( + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.not.emit(manager, "FeesCollected"); + + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); // still staked + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.equal(basisBefore); // collect doesn't touch basis + + // Accrue real AERO emissions, then harvest again with swapRewardToUsdc: + // the newly claimed AERO — and ONLY the claimed delta — is swapped to + // USDC through the allowed live USDC/AERO CL pool. + await network.provider.send("evm_increaseTime", [5 * 86_400]); + await network.provider.send("evm_mine"); + await pokeTwapPool(TWAP_REF_WETH_USDC_POOL); + await pokeTwapPool(TWAP_REF_AERO_USDC_POOL); + + const stakePool = new ethers.Contract( + stakePoolAddress, + [ + "function rewardToken() view returns (address)", + "function earned(address,uint256) view returns (uint256)", + ], + ethers.provider, + ); + const aeroAddr: string = await stakePool.rewardToken(); + const aero = new ethers.Contract(aeroAddr, ERC20_ABI, ethers.provider); + const earned: bigint = await stakePool.earned(safeAddress, tokenId); + expect(earned).to.be.greaterThan(0n); + + const AERO_PARAM = encodeAerodromePoolParam(USDC_ADDRESS, aeroAddr, AERO_TICK_SPACING); + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const aeroPoolAddress: string = await factory.getPool(USDC_ADDRESS, aeroAddr, AERO_TICK_SPACING); + expect(aeroPoolAddress).to.not.equal(ethers.ZeroAddress); + const aeroPool = new ethers.Contract(aeroPoolAddress, POOL_ABI, ethers.provider); + const [aeroSqrtRaw] = await aeroPool.slot0(); + const aeroSqrt = BigInt(aeroSqrtRaw); + // AERO is token1 of the USDC/AERO pool: USDC out = AERO in * 2^192 / sqrtP^2. + const expectedUsdcOut = (earned << 192n) / (aeroSqrt * aeroSqrt); + expect(expectedUsdcOut).to.be.greaterThan(0n); + await (await manager.setPoolParamAllowed(AERODROME, AERO_PARAM, true)).wait(); + + const warpBlock = await ethers.provider.getBlock("latest"); + const deadline2 = BigInt(warpBlock!.timestamp + 3_600); + const safeAeroBefore: bigint = await aero.balanceOf(safeAddress); + const safeUsdcBefore: bigint = await usdc.balanceOf(safeAddress); + await expect( + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: true, + rewardSwap: leg((expectedUsdcOut * 9_700n) / 10_000n, AERO_PARAM), + slippageBps: 300, + deadline: deadline2, + }), + ).to.not.emit(manager, "FeesCollected"); + + // Only the claimed delta was swapped: AERO from the earlier claim stays + // on the Safe, and the USDC output cleared the slippage floor. + expect(await aero.balanceOf(safeAddress)).to.equal(safeAeroBefore); + expect((await usdc.balanceOf(safeAddress)) - safeUsdcBefore).to.be.greaterThanOrEqual( + (expectedUsdcOut * 9_700n) / 10_000n, + ); + expect(await npm.ownerOf(tokenId)).to.equal(stakePoolAddress); + + // A full close still works from the staked state (unstake → close). + const wethToLp: bigint = openedEvents[openedEvents.length - 1].args.amount0ToLp; + const usdcToLp: bigint = openedEvents[openedEvents.length - 1].args.amount1ToLp; + const expectedOut = (wethToLp * sqrtPriceX96 * sqrtPriceX96) >> 192n; + await expect( + manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + exitBps: 10_000, + swap0: leg((expectedOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: deadline2, + minUsdcOut: ((usdcToLp + expectedOut) * 9_500n) / 10_000n, + }), + ).to.emit(manager, "PositionClosed"); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); + + it("opens, collects, partially closes, and fully closes a USDC/AERO position (USDC as token0)", async function () { + const { operator, safeAddress, manager, registry } = await deployAeroStack(); + await (await registry.setWhitelisted(AERO_ADDRESS, true)).wait(); + await (await manager.setPoolParamAllowed(AERODROME, AERO_POOL_PARAM, true)).wait(); + + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const aeroPoolAddress: string = await factory.getPool(USDC_ADDRESS, AERO_ADDRESS, AERO_LP_TICK_SPACING); + expect(aeroPoolAddress).to.not.equal(ethers.ZeroAddress); + const aeroPool = new ethers.Contract(aeroPoolAddress, POOL_ABI, ethers.provider); + expect(await aeroPool.token0()).to.equal(USDC_ADDRESS); + expect(await aeroPool.token1()).to.equal(ethers.getAddress(AERO_ADDRESS)); + const [sqrtPriceRaw, tick] = await aeroPool.slot0(); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + const alignedTick = Math.floor(Number(tick) / AERO_LP_TICK_SPACING) * AERO_LP_TICK_SPACING; + // token0 == USDC here, so the spot formulas flip vs the WETH/USDC tests. + const spotUsdcToAero = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + const spotAeroToUsdc = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + + const { poolAddress: wethUsdcPool } = await readAeroPool(); + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdc(wethUsdcPool, safeAddress, input); + const aero = new ethers.Contract(AERO_ADDRESS, ERC20_ABI, ethers.provider); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToAero(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + // USDC is token0: the funding half stays put, swap1 acquires AERO. + swap0: ZERO_LEG, + swap1: leg((expectedSwapOut * 9_700n) / 10_000n, AERO_POOL_PARAM), + slippageBps: 300, + deadline, + lpPoolParam: AERO_POOL_PARAM, + stake: false, + }; + + await expect(manager.connect(operator).openLp(AERODROME, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + const initialBasis = await manager.residualBasisUsd6Of(AERODROME, tokenId); + expect(initialBasis).to.be.greaterThan(0); + // AERO the mint could not consume stays on the Safe as dust. + const aeroDustAfterOpen: bigint = await aero.balanceOf(safeAddress); + + await expect( + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.emit(manager, "FeesCollected"); + + const usdcToLp: bigint = opened.args.amount0ToLp; + const aeroToLp: bigint = opened.args.amount1ToLp; + const closeParams = (exitBps: number, shareOfOriginalBps: bigint) => { + const usdcShare = (usdcToLp * shareOfOriginalBps) / 10_000n; + const aeroShare = (aeroToLp * shareOfOriginalBps) / 10_000n; + const expectedOut = spotAeroToUsdc(aeroShare); + return { + onBehalfOf: safeAddress, + tokenId, + exitBps, + swap0: ZERO_LEG, + swap1: leg((expectedOut * 9_700n) / 10_000n, AERO_POOL_PARAM), + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcShare + expectedOut) * 9_500n) / 10_000n, + }; + }; + + await expect(manager.connect(operator).closeLp(AERODROME, closeParams(5_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.be.lessThan(initialBasis); + + await expect(manager.connect(operator).closeLp(AERODROME, closeParams(10_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.equal(0); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + // Closes swap only the AERO they withdraw (delta-measured), so the + // open-mint AERO dust is left untouched; realized USDC lands on the Safe. + expect(await aero.balanceOf(safeAddress)).to.equal(aeroDustAfterOpen); + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); + + it("skims feeCollectBps of real accrued fees to the treasury and swaps the rest to USDC", async function () { + const { operator, treasury, safeAddress, manager } = await deployAeroStack(); + const { poolAddress, sqrtPriceX96, alignedTick } = await readAeroPool(); + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + + const input = ethers.parseUnits("10000", 6); + const usdc = await fundSafeUsdc(poolAddress, safeAddress, input); + const weth = new ethers.Contract(WETH_ADDRESS, ERC20_ABI, ethers.provider); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + // A narrow in-range position so the Safe's liquidity earns a + // meaningful share of the wash-trade fees below. + await expect( + manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - TICK_SPACING, + tickUpper: alignedTick + 2 * TICK_SPACING, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_700n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + deadline, + lpPoolParam: POOL_PARAM, + stake: false, + }), + ).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const tokenId = openedEvents[openedEvents.length - 1].args.tokenId; + + await accrueSwapFees(10, ethers.parseEther("5")); + + const safeWethBefore: bigint = await weth.balanceOf(safeAddress); + const safeUsdcBefore: bigint = await usdc.balanceOf(safeAddress); + const treasuryWethBefore: bigint = await weth.balanceOf(treasury.address); + const treasuryUsdcBefore: bigint = await usdc.balanceOf(treasury.address); + + const collectBlock = await ethers.provider.getBlock("latest"); + // The collected fee amounts are unknowable up front, so the legs carry + // nominal floors; the assertions below pin the exact skim instead. + await expect( + manager.connect(operator).collectLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: true, + swap0: leg(1n, POOL_PARAM), + swap1: leg(1n, POOL_PARAM), + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 300, + deadline: BigInt(collectBlock!.timestamp + 3_600), + }), + ).to.emit(manager, "FeesCollected"); + + const feeEvents = await manager.queryFilter(manager.filters.FeesCollected(safeAddress), -5); + const collected = feeEvents[feeEvents.length - 1].args; + expect(collected.collected0).to.be.greaterThan(0n); + expect(collected.collected1).to.be.greaterThan(0n); + expect(collected.fee0).to.equal((collected.collected0 * 250n) / 10_000n); + expect(collected.fee1).to.equal((collected.collected1 * 250n) / 10_000n); + + // feeCollectBps is skimmed in kind before the swap-to-USDC leg runs. + expect((await weth.balanceOf(treasury.address)) - treasuryWethBefore).to.equal(collected.fee0); + expect((await usdc.balanceOf(treasury.address)) - treasuryUsdcBefore).to.equal(collected.fee1); + // The Safe's WETH share was swapped away entirely; USDC grew by its + // own fee share plus the swap output. + expect(await weth.balanceOf(safeAddress)).to.equal(safeWethBefore); + expect((await usdc.balanceOf(safeAddress)) - safeUsdcBefore).to.be.greaterThan( + collected.collected1 - collected.fee1, + ); + }); + + // Runs against the USDC/AERO ts-200 pool: its 0.3% fee and moderate depth + // let a narrow $10k position earn fees that clearly outrun the open/close + // swap costs — the deep WETH/USDC pool dilutes the Safe's fee share so far + // that no realistic wash volume produces a profit there. + it("charges the performance fee on a profitable close", async function () { + const { operator, treasury, safeAddress, manager, registry } = await deployAeroStack(); + await (await registry.setWhitelisted(AERO_ADDRESS, true)).wait(); + await (await manager.setPoolParamAllowed(AERODROME, AERO_POOL_PARAM, true)).wait(); + + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const aeroPoolAddress: string = await factory.getPool(USDC_ADDRESS, AERO_ADDRESS, AERO_LP_TICK_SPACING); + const aeroPool = new ethers.Contract(aeroPoolAddress, POOL_ABI, ethers.provider); + const [sqrtPriceRaw, tick] = await aeroPool.slot0(); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + const alignedTick = Math.floor(Number(tick) / AERO_LP_TICK_SPACING) * AERO_LP_TICK_SPACING; + const spotUsdcToAero = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + const { poolAddress: wethUsdcPool } = await readAeroPool(); + const input = ethers.parseUnits("10000", 6); + const usdc = await fundSafeUsdc(wethUsdcPool, safeAddress, input); + const aero = new ethers.Contract(AERO_ADDRESS, ERC20_ABI, ethers.provider); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToAero(input / 2n); + await expect( + manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 2 * AERO_LP_TICK_SPACING, + tickUpper: alignedTick + 3 * AERO_LP_TICK_SPACING, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: ZERO_LEG, + swap1: leg((expectedSwapOut * 9_700n) / 10_000n, AERO_POOL_PARAM), + slippageBps: 300, + deadline, + lpPoolParam: AERO_POOL_PARAM, + stake: false, + }), + ).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + + // Enough wash-trade volume that the position's fee income outruns the + // open/close swap costs and realizes an actual profit over basis. + await accrueAeroSwapFees(wethUsdcPool, 25, ethers.parseUnits("30000", 6)); + + const safeUsdcBefore: bigint = await usdc.balanceOf(safeAddress); + const treasuryAeroBefore: bigint = await aero.balanceOf(treasury.address); + const treasuryUsdcBefore: bigint = await usdc.balanceOf(treasury.address); + + // Re-read the price after the wash trades for the close estimates. + const [postSqrtRaw] = await aeroPool.slot0(); + const postSqrt = BigInt(postSqrtRaw); + const spotAeroToUsdcPost = (amount: bigint) => (amount << 192n) / (postSqrt * postSqrt); + const usdcToLp: bigint = opened.args.amount0ToLp; + const aeroToLp: bigint = opened.args.amount1ToLp; + const expectedOut = spotAeroToUsdcPost(aeroToLp); + const closeBlock = await ethers.provider.getBlock("latest"); + await expect( + manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId, + exitBps: 10_000, + swap0: ZERO_LEG, + swap1: leg((expectedOut * 9_700n) / 10_000n, AERO_POOL_PARAM), + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: BigInt(closeBlock!.timestamp + 3_600), + minUsdcOut: ((usdcToLp + expectedOut) * 9_500n) / 10_000n, + }), + ).to.emit(manager, "PositionClosed"); + + const closedEvents = await manager.queryFilter(manager.filters.PositionClosed(safeAddress), -5); + const closed = closedEvents[closedEvents.length - 1].args; + expect(closed.currentValueUsd6).to.be.greaterThan(closed.basisUsd6); + expect(closed.feeUsd6).to.be.greaterThan(0n); + expect(closed.feeUsd6).to.equal(((closed.currentValueUsd6 - closed.basisUsd6) * 1_000n) / 10_000n); + + // The close harvests pending fees first (in-kind feeCollectBps skim — + // token0 is USDC here, token1 is AERO), then pulls the USDC + // performance fee: the treasury receives both. + const feeEvents = await manager.queryFilter(manager.filters.FeesCollected(safeAddress), -5); + const collected = feeEvents[feeEvents.length - 1].args; + expect((await aero.balanceOf(treasury.address)) - treasuryAeroBefore).to.equal(collected.fee1); + expect((await usdc.balanceOf(treasury.address)) - treasuryUsdcBefore).to.equal(collected.fee0 + closed.feeUsd6); + + // The Safe keeps the realized value net of the performance fee. + expect((await usdc.balanceOf(safeAddress)) - safeUsdcBefore).to.equal(closed.currentValueUsd6 - closed.feeUsd6); + expect(await manager.residualBasisUsd6Of(AERODROME, tokenId)).to.equal(0); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + }); +}); diff --git a/test/yield/safeYieldManagerSwitchFork.ts b/test/yield/safeYieldManagerSwitchFork.ts new file mode 100644 index 0000000..ddc545d --- /dev/null +++ b/test/yield/safeYieldManagerSwitchFork.ts @@ -0,0 +1,751 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_SLIPSTREAM_NPM_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_VOTER_ADDRESS, + PERMIT2_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + TWAP_REF_WETH_USDC_POOL, + TWAP_WINDOW, + TWAP_CARDINALITY, +} from "../../contractAddresses"; +import { encodeAerodromePoolParam, encodeUniV3PoolParam, encodeUniV4PoolParam } from "../../contractAddresses"; +import { ZERO_LEG, leg } from "../helpers/utils"; +import { deployRealSafe, enableModuleOnSafe } from "../helpers/deployRealSafe"; + +const UNISWAP_V3 = 0; +const AERODROME = 1; +const UNISWAP_V4 = 2; +const FEE_TIER = 500; +const FEE_TIER_3000 = 3_000; +const UNIV3_TICK_SPACING = 10; +const UNIV3_TICK_SPACING_3000 = 60; +const AERO_TICK_SPACING = 100; +const UNIV4_TICK_SPACING = 10; +const FORK_BLOCK = Number(process.env.BASE_FORK_BLOCK_NUMBER ?? 49_470_000); +const UNIV3_POOL_PARAM = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); +const UNIV3_POOL_PARAM_3000 = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER_3000); +const AERO_POOL_PARAM = encodeAerodromePoolParam(WETH_ADDRESS, USDC_ADDRESS, AERO_TICK_SPACING); +// Native ETH/USDC — the deepest V4 pool on Base; currency0 == address(0). +const UNIV4_POOL_PARAM = encodeUniV4PoolParam( + ethers.ZeroAddress, + USDC_ADDRESS, + FEE_TIER, + UNIV4_TICK_SPACING, + ethers.ZeroAddress, +); +const UNIV4_POOL_ID = ethers.keccak256(UNIV4_POOL_PARAM); + +const ERC20_ABI = [ + "function balanceOf(address) view returns (uint256)", + "function transfer(address,uint256) returns (bool)", +]; +const UNIV3_FACTORY_ABI = ["function getPool(address,address,uint24) view returns (address)"]; +const AERO_FACTORY_ABI = ["function getPool(address,address,int24) view returns (address)"]; +const UNIV3_POOL_ABI = ["function slot0() view returns (uint160,int24,uint16,uint16,uint16,uint8,bool)"]; +const AERO_POOL_ABI = ["function slot0() view returns (uint160,int24,uint16,uint16,uint16,bool)"]; +const NPM_ABI = ["function ownerOf(uint256) view returns (address)"]; +const STATE_VIEW_ABI = ["function getSlot0(bytes32) view returns (uint160,int24,uint24,uint24)"]; +const V4_PM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function getPositionLiquidity(uint256) view returns (uint128)", +]; +const AERO_ROUTER_ABI = [ + "function exactInputSingle((address,address,int24,address,uint256,uint256,uint256,uint160)) payable returns (uint256)", +]; + +const spotUsdcToWeth = (amount: bigint, sqrtP: bigint) => (amount << 192n) / (sqrtP * sqrtP); + +// In-kind switch: no swap legs, no USDC realization — the withdraw leg's token +// amounts are redeployed directly. Only decrease/mint minimums remain. +function switchParams( + safeAddress: string, + tokenId: bigint, + tickLower: number, + tickUpper: number, + lpPoolParam: string, + deadline: bigint, +) { + return { + onBehalfOf: safeAddress, + tokenId, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + tickLower, + tickUpper, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam, + deadline, + }; +} + +async function deployStack(uniPoolParams: string[], aeroPoolParams: string[]) { + const [admin, operator, treasury, pauser] = await ethers.getSigners(); + + const Registry = await ethers.getContractFactory("MockRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + await (await registry.setOperator(operator.address)).wait(); + await (await registry.setWhitelisted(WETH_ADDRESS, true)).wait(); + await (await registry.setWhitelisted(USDC_ADDRESS, true)).wait(); + + const safeAddress = await deployRealSafe(admin); + + const UniHandler = await ethers.getContractFactory("UniV3YieldHandler"); + const uniHandler = await UniHandler.deploy( + UNISWAP_V3_NPM_ADDRESS, + USDC_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + ); + await uniHandler.waitForDeployment(); + + const AeroHandler = await ethers.getContractFactory("AerodromeYieldHandler"); + const aeroHandler = await AeroHandler.deploy( + AERODROME_SLIPSTREAM_NPM_ADDRESS, + USDC_ADDRESS, + AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, + AERODROME_CL_FACTORY_ADDRESS, + AERODROME_VOTER_ADDRESS, + ); + await aeroHandler.waitForDeployment(); + + const V4Handler = await ethers.getContractFactory("UniV4YieldHandler"); + const v4Handler = await V4Handler.deploy( + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + PERMIT2_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + ); + await v4Handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await registry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + [UNISWAP_V3, AERODROME, UNISWAP_V4], + [await uniHandler.getAddress(), await aeroHandler.getAddress(), await v4Handler.getAddress()], + [uniPoolParams, aeroPoolParams, [UNIV4_POOL_PARAM]], + [0, 0, 0], + [0, 0, 0], + // The V4 native pool param below is allow-listed here, so its + // address(0) reference has to exist too — the constructor holds seeded + // params to the same standard as any later addition. + [ + { + token: WETH_ADDRESS, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + { + token: ethers.ZeroAddress, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + 1_000, + 250, + 2_000, + admin.address, + await timelock.getAddress(), + pauser.address, + ); + await manager.waitForDeployment(); + await enableModuleOnSafe(safeAddress, admin, await manager.getAddress()); + + return { admin, operator, treasury, pauser, safeAddress, uniHandler, aeroHandler, v4Handler, manager }; +} + +async function readUniPool(feeTier: number) { + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, UNIV3_FACTORY_ABI, ethers.provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, feeTier); + const pool = new ethers.Contract(poolAddress, UNIV3_POOL_ABI, ethers.provider); + const [sqrtP, tick] = await pool.slot0(); + return { poolAddress, sqrtP: sqrtP as bigint, tick: Number(tick) }; +} + +async function readAeroPool() { + const factory = new ethers.Contract(AERODROME_CL_FACTORY_ADDRESS, AERO_FACTORY_ABI, ethers.provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, AERO_TICK_SPACING); + const pool = new ethers.Contract(poolAddress, AERO_POOL_ABI, ethers.provider); + const [sqrtP, tick] = await pool.slot0(); + return { poolAddress, sqrtP: sqrtP as bigint, tick: Number(tick) }; +} + +async function readV4Pool() { + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, STATE_VIEW_ABI, ethers.provider); + const [sqrtP, tick] = await stateView.getSlot0(UNIV4_POOL_ID); + if (BigInt(sqrtP) === 0n) { + throw new Error( + `Uniswap V4 pool ${UNIV4_POOL_ID} is not initialized at fork block ${FORK_BLOCK}; refusing to skip the integration test`, + ); + } + return { sqrtP: sqrtP as bigint, tick: Number(tick) }; +} + +async function fundSafeUsdcFromPool(poolAddress: string, safeAddress: string, amount: bigint) { + await network.provider.send("hardhat_setBalance", [poolAddress, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(poolAddress); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, ethers.provider); + await (await (usdc.connect(poolSigner) as any).transfer(safeAddress, amount)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [poolAddress]); + return usdc; +} + +async function pushAeroSpotUp(fundingPool: string, usdcIn: bigint) { + const trader = (await ethers.getSigners())[4]; + const usdc = await fundSafeUsdcFromPool(fundingPool, trader.address, usdcIn); + const usdcWithApprove = new ethers.Contract( + USDC_ADDRESS, + [...ERC20_ABI, "function approve(address,uint256) returns (bool)"], + trader, + ); + await (await usdcWithApprove.approve(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, usdcIn)).wait(); + const router = new ethers.Contract(AERODROME_SLIPSTREAM_SWAP_ROUTER_ADDRESS, AERO_ROUTER_ABI, trader); + const block = await ethers.provider.getBlock("latest"); + await ( + await router.exactInputSingle([ + USDC_ADDRESS, + WETH_ADDRESS, + AERO_TICK_SPACING, + trader.address, + BigInt(block!.timestamp + 600), + usdcIn, + 0n, + 0n, + ]) + ).wait(); + return usdc; +} + +async function openUniV3( + manager: any, + operator: any, + safeAddress: string, + input: bigint, + sqrtP: bigint, + poolParam: string, + tickLower: number, + tickUpper: number, + deadline: bigint, +) { + const openExpectedOut = spotUsdcToWeth(input / 2n, sqrtP); + await ( + await manager.connect(operator).openLp(UNISWAP_V3, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower, + tickUpper, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((openExpectedOut * 9_700n) / 10_000n, poolParam), + swap1: ZERO_LEG, + slippageBps: 300, + deadline, + lpPoolParam: poolParam, + stake: false, + }) + ).wait(); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + return openedEvents[openedEvents.length - 1]; +} + +async function lastSwitched(manager: any, safeAddress: string) { + const events = await manager.queryFilter(manager.filters.PositionSwitched(safeAddress), -5); + return events[events.length - 1]; +} + +/** + * M-02 on real numbers. A concentrated-liquidity mint consumes the two sides + * only in the ratio its range demands, so a real in-kind switch always leaves + * one side behind — 4.8%-5.3% of the position's basis in these fixtures, which + * is money, not dust. The invariant that has to hold is conservation: what the + * replacement position still owes plus what the Safe was handed back equals + * what the original position owed. + */ +async function expectResidueConserved(manager: any, safeAddress: string, previousBasis: bigint) { + const events = await manager.queryFilter(manager.filters.SwitchResidueSettled(safeAddress), -5); + const settled = events[events.length - 1]; + const { residualUsd6, newBasisUsd6, newCarryUsd6 } = settled.args; + + if (residualUsd6 <= previousBasis) { + expect(newBasisUsd6 + residualUsd6).to.equal(previousBasis); + expect(newCarryUsd6).to.equal(0n); + } else { + // Residue beyond the basis is profit already withdrawn; it must survive + // as carry so the eventual close still charges a fee on it. + expect(newBasisUsd6).to.equal(0n); + expect(newCarryUsd6).to.equal(residualUsd6 - previousBasis); + } + return newBasisUsd6 as bigint; +} + +describe("SafeYieldManager switchLp - integration (Base fork)", function () { + this.timeout(300_000); + + beforeEach(async function () { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org", + blockNumber: FORK_BLOCK, + }, + }, + ], + }); + }); + + it("moves real positions in both directions in kind while carrying the basis", async function () { + const { operator, treasury, safeAddress, uniHandler, aeroHandler, manager } = await deployStack( + [UNIV3_POOL_PARAM], + [AERO_POOL_PARAM], + ); + + const { poolAddress: uniPoolAddress, sqrtP: uniSqrtP, tick: uniTick } = await readUniPool(FEE_TIER); + const uniAlignedTick = Math.floor(uniTick / UNIV3_TICK_SPACING) * UNIV3_TICK_SPACING; + const { tick: aeroTick } = await readAeroPool(); + const aeroAlignedTick = Math.floor(aeroTick / AERO_TICK_SPACING) * AERO_TICK_SPACING; + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdcFromPool(uniPoolAddress, safeAddress, input); + const weth = new ethers.Contract(WETH_ADDRESS, ERC20_ABI, ethers.provider); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const opened = await openUniV3( + manager, + operator, + safeAddress, + input, + uniSqrtP, + UNIV3_POOL_PARAM, + uniAlignedTick - 1_000, + uniAlignedTick + 1_000, + deadline, + ); + const oldTokenId = opened.args.tokenId; + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId); + expect(initialBasis).to.be.greaterThan(0); + + const safeWethBefore = await weth.balanceOf(safeAddress); + const safeUsdcBefore = await usdc.balanceOf(safeAddress); + + await expect( + manager + .connect(operator) + .switchLp( + UNISWAP_V3, + AERODROME, + switchParams( + safeAddress, + oldTokenId, + aeroAlignedTick - 1_000, + aeroAlignedTick + 1_000, + AERO_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const switched = await lastSwitched(manager, safeAddress); + const newTokenId = switched.args.newTokenId; + // In kind: every withdrawn token either entered the new LP or stayed in + // the Safe as residue. The basis rides along MINUS that residue — + // `expectResidueConserved` below pins the exact split. + expect(switched.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + expect(switched.args.withdrawn0).to.be.greaterThan(0); + expect(switched.args.withdrawn1).to.be.greaterThan(0); + expect(switched.args.used0).to.be.lessThanOrEqual(switched.args.withdrawn0); + expect(switched.args.used1).to.be.lessThanOrEqual(switched.args.withdrawn1); + expect((await weth.balanceOf(safeAddress)) - safeWethBefore).to.equal( + switched.args.withdrawn0 - switched.args.used0, + ); + expect((await usdc.balanceOf(safeAddress)) - safeUsdcBefore).to.equal( + switched.args.withdrawn1 - switched.args.used1, + ); + + const uniNpm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const aeroNpm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + await expect(uniNpm.ownerOf(oldTokenId)).to.be.reverted; + expect(await aeroNpm.ownerOf(newTokenId)).to.equal(safeAddress); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId)).to.equal(0); + const basisAfterSwitch = await expectResidueConserved(manager, safeAddress, initialBasis); + expect(await manager.residualBasisUsd6Of(AERODROME, newTokenId)).to.equal(basisAfterSwitch); + expect(await manager.positionHandlerOf(AERODROME, newTokenId)).to.equal(await aeroHandler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + + // Make the replacement genuinely profitable, then prove the final + // close charges the lifecycle profit (switch residue + final value - + // original basis). Snapshot/revert keeps the reverse-switch coverage + // below independent from this economic assertion. + const profitableCloseSnapshot = await network.provider.send("evm_snapshot"); + const residueEvents = await manager.queryFilter(manager.filters.SwitchResidueSettled(safeAddress), -5); + const firstResidue = residueEvents[residueEvents.length - 1]; + const aeroBefore = await readAeroPool(); + await pushAeroSpotUp(uniPoolAddress, ethers.parseUnits("1000000", 6)); + const aeroAfter = await readAeroPool(); + expect(aeroAfter.tick).to.be.greaterThan(aeroBefore.tick + 100); + + const treasuryBeforeClose: bigint = await usdc.balanceOf(treasury.address); + const closeReceipt = await ( + await manager.connect(operator).closeLp(AERODROME, { + onBehalfOf: safeAddress, + tokenId: newTokenId, + exitBps: 10_000, + swap0: leg(0, AERO_POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: 0, + }) + ).wait(); + const parsedCloseLogs = closeReceipt!.logs.map((log: any) => { + try { + return manager.interface.parseLog(log); + } catch { + return null; + } + }); + const closed = parsedCloseLogs.find((event: any) => event?.name === "PositionClosed")!; + const collected = parsedCloseLogs.find((event: any) => event?.name === "FeesCollected")!; + const lifecycleProfit = closed.args.currentValueUsd6 + firstResidue.args.residualUsd6 - initialBasis; + const expectedPerformanceFee = (lifecycleProfit * 1_000n) / 10_000n; + expect(lifecycleProfit).to.be.greaterThan(0n); + expect(closed.args.feeUsd6).to.equal(expectedPerformanceFee); + const usdcCollectFee = + collected.args.token0.toLowerCase() === USDC_ADDRESS.toLowerCase() + ? collected.args.fee0 + : collected.args.fee1; + expect((await usdc.balanceOf(treasury.address)) - treasuryBeforeClose).to.equal( + expectedPerformanceFee + usdcCollectFee, + ); + expect(await network.provider.send("evm_revert", [profitableCloseSnapshot])).to.equal(true); + + // Reverse composition: Aerodrome withdraw followed by Uniswap V3 open. + await expect( + manager + .connect(operator) + .switchLp( + AERODROME, + UNISWAP_V3, + switchParams( + safeAddress, + newTokenId, + uniAlignedTick - 1_000, + uniAlignedTick + 1_000, + UNIV3_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const reverse = await lastSwitched(manager, safeAddress); + const reverseTokenId = reverse.args.newTokenId; + expect(reverse.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + await expect(aeroNpm.ownerOf(newTokenId)).to.be.reverted; + expect(await uniNpm.ownerOf(reverseTokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(AERODROME, newTokenId)).to.equal(0); + const basisAfterReverse = await expectResidueConserved(manager, safeAddress, basisAfterSwitch); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, reverseTokenId)).to.equal(basisAfterReverse); + expect(await manager.positionHandlerOf(UNISWAP_V3, reverseTokenId)).to.equal(await uniHandler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("switches between Uniswap V3 fee tiers (0.3% -> 0.05%) in kind carrying the basis", async function () { + const { operator, treasury, safeAddress, uniHandler, manager } = await deployStack( + [UNIV3_POOL_PARAM_3000, UNIV3_POOL_PARAM], + [AERO_POOL_PARAM], + ); + + const from = await readUniPool(FEE_TIER_3000); + const to = await readUniPool(FEE_TIER); + const fromAlignedTick = Math.floor(from.tick / UNIV3_TICK_SPACING_3000) * UNIV3_TICK_SPACING_3000; + const toAlignedTick = Math.floor(to.tick / UNIV3_TICK_SPACING) * UNIV3_TICK_SPACING; + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdcFromPool(to.poolAddress, safeAddress, input); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const opened = await openUniV3( + manager, + operator, + safeAddress, + input, + from.sqrtP, + UNIV3_POOL_PARAM_3000, + fromAlignedTick - 1_020, + fromAlignedTick + 1_020, + deadline, + ); + const oldTokenId = opened.args.tokenId; + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId); + expect(initialBasis).to.be.greaterThan(0); + + await expect( + manager + .connect(operator) + .switchLp( + UNISWAP_V3, + UNISWAP_V3, + switchParams( + safeAddress, + oldTokenId, + toAlignedTick - 1_000, + toAlignedTick + 1_000, + UNIV3_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const switched = await lastSwitched(manager, safeAddress); + const newTokenId = switched.args.newTokenId; + expect(newTokenId).to.not.equal(oldTokenId); + expect(switched.args.fromProtocol).to.equal(UNISWAP_V3); + expect(switched.args.toProtocol).to.equal(UNISWAP_V3); + expect(switched.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + + const uniNpm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + await expect(uniNpm.ownerOf(oldTokenId)).to.be.reverted; + expect(await uniNpm.ownerOf(newTokenId)).to.equal(safeAddress); + + const uniNpmPositions = new ethers.Contract( + UNISWAP_V3_NPM_ADDRESS, + [ + "function positions(uint256) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)", + ], + ethers.provider, + ); + const position = await uniNpmPositions.positions(newTokenId); + expect(position[4]).to.equal(FEE_TIER); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId)).to.equal(0); + const basisAfterSwitch = await expectResidueConserved(manager, safeAddress, initialBasis); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, newTokenId)).to.equal(basisAfterSwitch); + expect(await manager.positionHandlerOf(UNISWAP_V3, newTokenId)).to.equal(await uniHandler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("moves real positions between Uniswap V3 and Uniswap V4 in both directions in kind", async function () { + const { operator, treasury, safeAddress, uniHandler, v4Handler, manager } = await deployStack( + [UNIV3_POOL_PARAM], + [AERO_POOL_PARAM], + ); + + const { poolAddress: uniPoolAddress, sqrtP: uniSqrtP, tick: uniTick } = await readUniPool(FEE_TIER); + const uniAlignedTick = Math.floor(uniTick / UNIV3_TICK_SPACING) * UNIV3_TICK_SPACING; + const { tick: v4Tick } = await readV4Pool(); + const v4AlignedTick = Math.floor(v4Tick / UNIV4_TICK_SPACING) * UNIV4_TICK_SPACING; + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdcFromPool(uniPoolAddress, safeAddress, input); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const opened = await openUniV3( + manager, + operator, + safeAddress, + input, + uniSqrtP, + UNIV3_POOL_PARAM, + uniAlignedTick - 1_000, + uniAlignedTick + 1_000, + deadline, + ); + const oldTokenId = opened.args.tokenId; + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId); + expect(initialBasis).to.be.greaterThan(0); + + // V3 -> native V4: the withdrawn WETH is unwrapped to ETH inside the + // V4 handler's in-kind open leg. + await expect( + manager + .connect(operator) + .switchLp( + UNISWAP_V3, + UNISWAP_V4, + switchParams( + safeAddress, + oldTokenId, + v4AlignedTick - 1_000, + v4AlignedTick + 1_000, + UNIV4_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const switched = await lastSwitched(manager, safeAddress); + const v4TokenId = switched.args.newTokenId; + expect(switched.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + + const uniNpm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + const v4Pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, V4_PM_ABI, ethers.provider); + await expect(uniNpm.ownerOf(oldTokenId)).to.be.reverted; + expect(await v4Pm.ownerOf(v4TokenId)).to.equal(safeAddress); + expect(await v4Pm.getPositionLiquidity(v4TokenId)).to.be.greaterThan(0); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, oldTokenId)).to.equal(0); + const basisAfterSwitch = await expectResidueConserved(manager, safeAddress, initialBasis); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, v4TokenId)).to.equal(basisAfterSwitch); + expect(await manager.positionHandlerOf(UNISWAP_V4, v4TokenId)).to.equal(await v4Handler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + + // Back leg: native V4 -> V3 wraps the withdrawn ETH into WETH. + await expect( + manager + .connect(operator) + .switchLp( + UNISWAP_V4, + UNISWAP_V3, + switchParams( + safeAddress, + v4TokenId, + uniAlignedTick - 1_000, + uniAlignedTick + 1_000, + UNIV3_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const back = await lastSwitched(manager, safeAddress); + const backTokenId = back.args.newTokenId; + expect(back.args.fromProtocol).to.equal(UNISWAP_V4); + expect(back.args.toProtocol).to.equal(UNISWAP_V3); + expect(back.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + await expect(v4Pm.ownerOf(v4TokenId)).to.be.reverted; + expect(await uniNpm.ownerOf(backTokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, v4TokenId)).to.equal(0); + const basisAfterBack = await expectResidueConserved(manager, safeAddress, basisAfterSwitch); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, backTokenId)).to.equal(basisAfterBack); + expect(await manager.positionHandlerOf(UNISWAP_V3, backTokenId)).to.equal(await uniHandler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); + + it("moves real positions between Aerodrome and Uniswap V4 in both directions in kind", async function () { + const { operator, treasury, safeAddress, aeroHandler, v4Handler, manager } = await deployStack( + [UNIV3_POOL_PARAM], + [AERO_POOL_PARAM], + ); + + const { poolAddress: uniPoolAddress } = await readUniPool(FEE_TIER); + const { sqrtP: aeroSqrtP, tick: aeroTick } = await readAeroPool(); + const aeroAlignedTick = Math.floor(aeroTick / AERO_TICK_SPACING) * AERO_TICK_SPACING; + const { tick: v4Tick } = await readV4Pool(); + const v4AlignedTick = Math.floor(v4Tick / UNIV4_TICK_SPACING) * UNIV4_TICK_SPACING; + + const input = ethers.parseUnits("10", 6); + const usdc = await fundSafeUsdcFromPool(uniPoolAddress, safeAddress, input); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const openExpectedOut = spotUsdcToWeth(input / 2n, aeroSqrtP); + await ( + await manager.connect(operator).openLp(AERODROME, { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: aeroAlignedTick - 1_000, + tickUpper: aeroAlignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((openExpectedOut * 9_700n) / 10_000n, AERO_POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 300, + deadline, + lpPoolParam: AERO_POOL_PARAM, + stake: false, + }) + ).wait(); + + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const oldTokenId = opened.args.tokenId; + const initialBasis = await manager.residualBasisUsd6Of(AERODROME, oldTokenId); + expect(initialBasis).to.be.greaterThan(0); + + await expect( + manager + .connect(operator) + .switchLp( + AERODROME, + UNISWAP_V4, + switchParams( + safeAddress, + oldTokenId, + v4AlignedTick - 1_000, + v4AlignedTick + 1_000, + UNIV4_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const switched = await lastSwitched(manager, safeAddress); + const v4TokenId = switched.args.newTokenId; + expect(switched.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + + const aeroNpm = new ethers.Contract(AERODROME_SLIPSTREAM_NPM_ADDRESS, NPM_ABI, ethers.provider); + const v4Pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, V4_PM_ABI, ethers.provider); + await expect(aeroNpm.ownerOf(oldTokenId)).to.be.reverted; + expect(await v4Pm.ownerOf(v4TokenId)).to.equal(safeAddress); + expect(await v4Pm.getPositionLiquidity(v4TokenId)).to.be.greaterThan(0); + expect(await manager.residualBasisUsd6Of(AERODROME, oldTokenId)).to.equal(0); + const basisAfterSwitch = await expectResidueConserved(manager, safeAddress, initialBasis); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, v4TokenId)).to.equal(basisAfterSwitch); + expect(await manager.positionHandlerOf(UNISWAP_V4, v4TokenId)).to.equal(await v4Handler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + + await expect( + manager + .connect(operator) + .switchLp( + UNISWAP_V4, + AERODROME, + switchParams( + safeAddress, + v4TokenId, + aeroAlignedTick - 1_000, + aeroAlignedTick + 1_000, + AERO_POOL_PARAM, + deadline, + ), + ), + ).to.emit(manager, "PositionSwitched"); + + const back = await lastSwitched(manager, safeAddress); + const backTokenId = back.args.newTokenId; + expect(back.args.fromProtocol).to.equal(UNISWAP_V4); + expect(back.args.toProtocol).to.equal(AERODROME); + expect(back.args.carriedBasisUsd6).to.be.lessThanOrEqual(initialBasis); + await expect(v4Pm.ownerOf(v4TokenId)).to.be.reverted; + expect(await aeroNpm.ownerOf(backTokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, v4TokenId)).to.equal(0); + const basisAfterBack = await expectResidueConserved(manager, safeAddress, basisAfterSwitch); + expect(await manager.residualBasisUsd6Of(AERODROME, backTokenId)).to.equal(basisAfterBack); + expect(await manager.positionHandlerOf(AERODROME, backTokenId)).to.equal(await aeroHandler.getAddress()); + expect(await usdc.balanceOf(treasury.address)).to.equal(0); + }); +}); diff --git a/test/yield/safeYieldManagerUniV3Fork.ts b/test/yield/safeYieldManagerUniV3Fork.ts new file mode 100644 index 0000000..fca1d51 --- /dev/null +++ b/test/yield/safeYieldManagerUniV3Fork.ts @@ -0,0 +1,277 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V3_NPM_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + TWAP_REF_WETH_USDC_POOL, + TWAP_WINDOW, + TWAP_CARDINALITY, +} from "../../contractAddresses"; +import { encodeUniV3PoolParam } from "../../contractAddresses"; +import { ZERO_LEG, leg } from "../helpers/utils"; +import { deployRealSafe, enableModuleOnSafe } from "../helpers/deployRealSafe"; + +const UNISWAP_V3 = 0; +const FEE_TIER = 500; +// Uniswap V3 tick spacing for the 0.05% fee tier. +const TICK_SPACING = 10; +const FORK_BLOCK = Number(process.env.BASE_FORK_BLOCK_NUMBER ?? 49_470_000); +const POOL_PARAM = encodeUniV3PoolParam(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + +const ERC20_ABI = [ + "function balanceOf(address) view returns (uint256)", + "function transfer(address,uint256) returns (bool)", +]; +const FACTORY_ABI = ["function getPool(address,address,uint24) view returns (address)"]; +const POOL_ABI = [ + "function token0() view returns (address)", + "function token1() view returns (address)", + "function slot0() view returns (uint160,int24,uint16,uint16,uint16,uint8,bool)", +]; +const NPM_ABI = ["function ownerOf(uint256) view returns (address)"]; +const WETH_ABI = ["function deposit() payable", "function approve(address,uint256) returns (bool)"]; +const ROUTER_ABI = [ + "function exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) payable returns (uint256)", +]; + +async function pushUniV3SpotDown(wethIn: bigint) { + const trader = (await ethers.getSigners())[4]; + const weth = new ethers.Contract(WETH_ADDRESS, WETH_ABI, trader); + const router = new ethers.Contract(UNISWAP_V3_SWAP_ROUTER_ADDRESS, ROUTER_ABI, trader); + await (await weth.deposit({ value: wethIn })).wait(); + await (await weth.approve(UNISWAP_V3_SWAP_ROUTER_ADDRESS, wethIn)).wait(); + await ( + await router.exactInputSingle([WETH_ADDRESS, USDC_ADDRESS, FEE_TIER, trader.address, wethIn, 0n, 0n]) + ).wait(); +} + +describe("SafeYieldManager + Uniswap V3 - integration (Base fork)", function () { + this.timeout(300_000); + + beforeEach(async function () { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org", + blockNumber: FORK_BLOCK, + }, + }, + ], + }); + }); + + it("opens, collects, partially closes, and fully closes a real Uniswap V3 position", async function () { + const [admin, operator, treasury, pauser] = await ethers.getSigners(); + + const Registry = await ethers.getContractFactory("MockRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + await (await registry.setOperator(operator.address)).wait(); + await (await registry.setWhitelisted(WETH_ADDRESS, true)).wait(); + await (await registry.setWhitelisted(USDC_ADDRESS, true)).wait(); + + const safeAddress = await deployRealSafe(admin); + + const Handler = await ethers.getContractFactory("UniV3YieldHandler"); + const handler = await Handler.deploy( + UNISWAP_V3_NPM_ADDRESS, + USDC_ADDRESS, + UNISWAP_V3_SWAP_ROUTER_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + ); + await handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await registry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + [UNISWAP_V3], + [await handler.getAddress()], + [[POOL_PARAM]], + [0], + [0], + [ + { + token: WETH_ADDRESS, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + 1_000, + 250, + 2_000, + admin.address, + await timelock.getAddress(), + pauser.address, + ); + await manager.waitForDeployment(); + await enableModuleOnSafe(safeAddress, admin, await manager.getAddress()); + + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const poolAddress: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + expect(poolAddress).to.not.equal(ethers.ZeroAddress); + + const pool = new ethers.Contract(poolAddress, POOL_ABI, ethers.provider); + expect(await pool.token0()).to.equal(WETH_ADDRESS); + expect(await pool.token1()).to.equal(USDC_ADDRESS); + const [sqrtPriceRaw, tick] = await pool.slot0(); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + const alignedTick = Math.floor(Number(tick) / TICK_SPACING) * TICK_SPACING; + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + // The live pool is a convenient deterministic USDC holder on the + // fork. Impersonation only mutates the disposable fork state. + await network.provider.send("hardhat_setBalance", [poolAddress, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(poolAddress); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, ethers.provider); + const input = ethers.parseUnits("10", 6); + await (await (usdc.connect(poolSigner) as any).transfer(safeAddress, input)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [poolAddress]); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: false, + }; + + await expect( + manager.connect(operator).openLp(UNISWAP_V3, { + ...openParams, + swap0: leg((expectedSwapOut * 2n * 9_900n) / 10_000n, POOL_PARAM), + }), + ).to.be.revertedWith("Too little received"); + + await expect(manager.connect(operator).openLp(UNISWAP_V3, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + const npm = new ethers.Contract(UNISWAP_V3_NPM_ADDRESS, NPM_ABI, ethers.provider); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V3, tokenId); + expect(initialBasis).to.be.greaterThan(0); + + await expect( + manager.connect(operator).collectLp(UNISWAP_V3, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.emit(manager, "FeesCollected"); + + const wethToLp: bigint = opened.args.amount0ToLp; + const usdcToLp: bigint = opened.args.amount1ToLp; + // shareOfOriginalBps: fraction of the ORIGINAL position this close + // removes, driving spot-price estimates of the swap and total output. + const closeParams = (exitBps: number, shareOfOriginalBps: bigint) => { + const wethShare = (wethToLp * shareOfOriginalBps) / 10_000n; + const usdcShare = (usdcToLp * shareOfOriginalBps) / 10_000n; + const expectedOut = spotWethToUsdc(wethShare); + return { + onBehalfOf: safeAddress, + tokenId, + exitBps, + swap0: { + amountOutMin: (expectedOut * 9_700n) / 10_000n, + poolParam: POOL_PARAM, + }, + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcShare + expectedOut) * 9_500n) / 10_000n, + }; + }; + + // The external reference may disappear after configuration. Normal + // swap-close must fail closed, while the oracle-independent in-kind + // exit still burns the NFT and returns both pool assets to the Safe. + const oracleFailureSnapshot = await network.provider.send("evm_snapshot"); + await network.provider.send("hardhat_setCode", [TWAP_REF_WETH_USDC_POOL, "0x"]); + await expect( + manager.connect(operator).closeLp(UNISWAP_V3, { + ...closeParams(10_000, 10_000n), + swap0: leg(0, POOL_PARAM), + minUsdcOut: 0, + }), + ).to.be.reverted; + const weth = new ethers.Contract(WETH_ADDRESS, ERC20_ABI, ethers.provider); + const wethBeforeExit: bigint = await weth.balanceOf(safeAddress); + const usdcBeforeExit: bigint = await usdc.balanceOf(safeAddress); + await expect( + manager.connect(operator).withdrawLp(UNISWAP_V3, { + onBehalfOf: safeAddress, + tokenId, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + }), + ).to.emit(manager, "PositionWithdrawn"); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await weth.balanceOf(safeAddress)).to.be.greaterThan(wethBeforeExit); + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(usdcBeforeExit); + expect(await network.provider.send("evm_revert", [oracleFailureSnapshot])).to.equal(true); + + // Move only the execution pool's spot down. The independent 0.01% + // reference is untouched, so a caller-supplied zero cannot lower the + // close router floor to the manipulated execution price. + const manipulationSnapshot = await network.provider.send("evm_snapshot"); + const tickBeforeManipulation = Number((await pool.slot0())[1]); + await pushUniV3SpotDown(ethers.parseEther("1000")); + const tickAfterManipulation = Number((await pool.slot0())[1]); + expect(tickAfterManipulation).to.be.lessThan(tickBeforeManipulation - 300); + await expect( + manager.connect(operator).closeLp(UNISWAP_V3, { + ...closeParams(10_000, 10_000n), + swap0: leg(0, POOL_PARAM), + minUsdcOut: 0, + }), + ).to.be.revertedWith("Too little received"); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await network.provider.send("evm_revert", [manipulationSnapshot])).to.equal(true); + + await expect(manager.connect(operator).closeLp(UNISWAP_V3, closeParams(5_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await npm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, tokenId)).to.be.lessThan(initialBasis); + + await expect(manager.connect(operator).closeLp(UNISWAP_V3, closeParams(10_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, tokenId)).to.equal(0); + await expect(npm.ownerOf(tokenId)).to.be.reverted; + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); +}); diff --git a/test/yield/safeYieldManagerUniV4.ts b/test/yield/safeYieldManagerUniV4.ts new file mode 100644 index 0000000..45f5ca4 --- /dev/null +++ b/test/yield/safeYieldManagerUniV4.ts @@ -0,0 +1,1190 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { + YieldProtocol, + encodeAerodromePoolParam, + encodeUniV3PoolParam, + encodeUniV4PoolParam, +} from "../../contractAddresses"; +import { ZERO_LEG, leg } from "../helpers/utils"; + +// ───────────────────────────────────────────────────────────────────────── +// Mock-driven suite for UniV4YieldHandler behind SafeYieldManager. +// +// Uses the V4 stack from RatehopperUniV4Mocks.sol (MockPermit2 with real +// two-hop allowance enforcement, an actions-decoding MockV4PositionManager, +// a V4_SWAP MockUniversalRouter, MockStateView) plus the shared mocks from +// RatehopperMocks.sol. Covers ERC20 (WETH/USDC-shaped) and native ETH +// (currency0 == address(0)) pools through the full openLp / closeLp / +// collectLp lifecycle, every handler revert branch, the Permit2/ERC20 +// approval-reset invariant, the Safe-side collect-fee skim, and V3<->V4 / +// Aerodrome<->V4 switchLp through the untouched manager. +// ───────────────────────────────────────────────────────────────────────── + +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 TWAP_WINDOW = 1800; +const TWAP_CARDINALITY = 60; + +function timelockCall(timelock: any, manager: any, functionName: string, args: any[]) { + return timelock.execute(manager.target, manager.interface.encodeFunctionData(functionName, args)); +} + +const UNISWAP_V3 = YieldProtocol.UNISWAP_V3; +const AERODROME = YieldProtocol.AERODROME; +const UNISWAP_V4 = YieldProtocol.UNISWAP_V4; + +let FEE_TIER: string; // Uniswap V3 pool param (switch tests) +let TICK_SPACING: string; // Aerodrome pool param (switch tests) +let V4_KEY: string; // ERC20 pair PoolKey param +let V4_NATIVE_KEY: string; // native ETH pair PoolKey param +let V4_UNINIT_KEY: string; // allow-listed but never initialized in StateView +let V4_BAD_KEY: string; // never allow-listed +let V4_USDC0_KEY: string; // pair where USDC sorts as currency0 +let V4_WRONG1_KEY: string; // trades {WETH, other} — wrong currency1 for a WETH/USDC leg + +const USDC_AMOUNT = 1_000_000n; +const HALF = USDC_AMOUNT / 2n; +const WETH_OUT = 2_000_000n; // token/ETH produced by the openLp swap +const CLOSE_OUT = 600_000n; // USDC produced by a close/collect swap + +function openParams(safeAddr: string, poolParam: string, overrides: Record = {}) { + return { + onBehalfOf: safeAddr, + usdcAmount: USDC_AMOUNT, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg(WETH_OUT, poolParam), + swap1: ZERO_LEG, + slippageBps: SLIP, + deadline: DEADLINE, + lpPoolParam: poolParam, + stake: false, + ...overrides, + }; +} + +function closeParams( + safeAddr: string, + tokenId: bigint | number, + poolParam: string, + overrides: Record = {}, +) { + return { + onBehalfOf: safeAddr, + tokenId, + exitBps: 10_000, + swap0: leg(CLOSE_OUT, poolParam), + swap1: ZERO_LEG, + slippageBps: SLIP, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: DEADLINE, + minUsdcOut: 0, + ...overrides, + }; +} + +function collectParams( + safeAddr: string, + tokenId: bigint | number, + poolParam: string, + overrides: Record = {}, +) { + return { + onBehalfOf: safeAddr, + tokenId, + swapFeesToUsdc: false, + swap0: leg(CLOSE_OUT, poolParam), + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: SLIP, + deadline: DEADLINE, + ...overrides, + }; +} + +async function deployUniV4Harness() { + const [deployer, operatorEOA, treasury, stranger, pauser] = await ethers.getSigners(); + + const ERC = await ethers.getContractFactory("MockERC20"); + const usdc = await ERC.deploy("USD Coin", "USDC", 6); + await usdc.waitForDeployment(); + const usdcAddr = await usdc.getAddress(); + + // WETH must sort BELOW USDC (currency0 side) and expose deposit/withdraw + // for the V4 handler's in-kind wrap/unwrap; place MockWETH code at + // usdc - 1 directly — deterministically the next address down. + const WETHFactory = await ethers.getContractFactory("MockWETH"); + const wethImpl = await WETHFactory.deploy(); + await wethImpl.waitForDeployment(); + const wethAddr = ethers.getAddress(ethers.toBeHex(BigInt(usdcAddr) - 1n, 20)); + await network.provider.send("hardhat_setCode", [wethAddr, await ethers.provider.getCode(wethImpl)]); + const weth = await ethers.getContractAt("MockWETH", wethAddr); + + // A token that sorts ABOVE USDC, so a {USDC, tokenC} pool has USDC as + // currency0 (drives the currency0-is-USDC branches). Deployed addresses + // are nonce-derived and can land anywhere, so place the code at + // usdc + 1 directly — deterministically the next address up. + const tokenCAddr = ethers.getAddress(ethers.toBeHex(BigInt(usdcAddr) + 1n, 20)); + await network.provider.send("hardhat_setCode", [tokenCAddr, await ethers.provider.getCode(usdcAddr)]); + const tokenC = await ethers.getContractAt("MockERC20", tokenCAddr); + + FEE_TIER = encodeUniV3PoolParam(wethAddr, usdcAddr, 500); + TICK_SPACING = encodeAerodromePoolParam(wethAddr, usdcAddr, 100); + V4_KEY = encodeUniV4PoolParam(wethAddr, usdcAddr, 500, 10, ZERO); + V4_NATIVE_KEY = encodeUniV4PoolParam(ZERO, usdcAddr, 500, 10, ZERO); + V4_UNINIT_KEY = encodeUniV4PoolParam(wethAddr, usdcAddr, 3000, 60, ZERO); + V4_BAD_KEY = encodeUniV4PoolParam(wethAddr, usdcAddr, 10000, 200, ZERO); + V4_USDC0_KEY = encodeUniV4PoolParam(usdcAddr, tokenCAddr, 500, 10, ZERO); + V4_WRONG1_KEY = encodeUniV4PoolParam(wethAddr, tokenCAddr, 500, 10, ZERO); + + // Uniswap V3 side (switchLp counterparty) + const UniPool = await ethers.getContractFactory("MockUniswapV3Pool"); + const uniPool = await UniPool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await uniPool.waitForDeployment(); + const UniFactory = await ethers.getContractFactory("MockUniswapV3Factory"); + const uniFactory = await UniFactory.deploy(); + await uniFactory.waitForDeployment(); + await (await uniFactory.setPool(await uniPool.getAddress())).wait(); + const UniNPM = await ethers.getContractFactory("MockNonfungiblePositionManager"); + const uniNpm = await UniNPM.deploy(); + await uniNpm.waitForDeployment(); + const UniRouter = await ethers.getContractFactory("MockSwapRouter"); + const uniRouter = await UniRouter.deploy(); + await uniRouter.waitForDeployment(); + + // Aerodrome side (switchLp counterparty) + const CLPool = await ethers.getContractFactory("MockCLPool"); + const clPool = await CLPool.deploy(wethAddr, usdcAddr, Q96, 10n ** 18n); + await clPool.waitForDeployment(); + const CLFactory = await ethers.getContractFactory("MockCLFactory"); + const clFactory = await CLFactory.deploy(); + await clFactory.waitForDeployment(); + await (await clFactory.setPool(await clPool.getAddress())).wait(); + const CLNPM = await ethers.getContractFactory("MockCLNonfungiblePositionManager"); + const clNpm = await CLNPM.deploy(); + await clNpm.waitForDeployment(); + const CLRouter = await ethers.getContractFactory("MockSlipstreamSwapRouter"); + const clRouter = await CLRouter.deploy(); + await clRouter.waitForDeployment(); + const Voter = await ethers.getContractFactory("MockVoter"); + const voter = await Voter.deploy(); + await voter.waitForDeployment(); + + // Uniswap V4 side + const Permit2 = await ethers.getContractFactory("MockPermit2"); + const permit2 = await Permit2.deploy(); + await permit2.waitForDeployment(); + const StateView = await ethers.getContractFactory("MockStateView"); + const stateView = await StateView.deploy(); + await stateView.waitForDeployment(); + const V4PM = await ethers.getContractFactory("MockV4PositionManager"); + const v4Pm = await V4PM.deploy(await permit2.getAddress()); + await v4Pm.waitForDeployment(); + const UR = await ethers.getContractFactory("MockUniversalRouter"); + const universalRouter = await UR.deploy(await permit2.getAddress()); + await universalRouter.waitForDeployment(); + + await (await stateView.setPool(ethers.keccak256(V4_KEY), Q96, 10n ** 18n)).wait(); + await (await stateView.setPool(ethers.keccak256(V4_NATIVE_KEY), Q96, 10n ** 18n)).wait(); + await (await stateView.setPool(ethers.keccak256(V4_USDC0_KEY), Q96, 10n ** 18n)).wait(); + await (await stateView.setPool(ethers.keccak256(V4_WRONG1_KEY), Q96, 10n ** 18n)).wait(); + + 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(); + await (await reg.setWhitelisted(wethAddr, true)).wait(); + await (await reg.setWhitelisted(usdcAddr, true)).wait(); + await (await reg.setWhitelisted(tokenCAddr, true)).wait(); + + const UniHandler = await ethers.getContractFactory("UniV3YieldHandler"); + const uniHandler = await UniHandler.deploy( + await uniNpm.getAddress(), + usdcAddr, + await uniRouter.getAddress(), + await uniFactory.getAddress(), + ); + await uniHandler.waitForDeployment(); + + const AeroHandler = await ethers.getContractFactory("AerodromeYieldHandler"); + const aeroHandler = await AeroHandler.deploy( + await clNpm.getAddress(), + usdcAddr, + await clRouter.getAddress(), + await clFactory.getAddress(), + await voter.getAddress(), + ); + await aeroHandler.waitForDeployment(); + + const V4Handler = await ethers.getContractFactory("UniV4YieldHandler"); + const v4Handler = await V4Handler.deploy( + await v4Pm.getAddress(), + await universalRouter.getAddress(), + await permit2.getAddress(), + await stateView.getAddress(), + usdcAddr, + wethAddr, + ); + await v4Handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + // Price references, seeded at construction. Native ETH carries its OWN key, + // address(0), pointed at a WETH/USDC pool — WETH is substituted only for the + // tick math. Both keys are needed: swaps on a native pool read address(0), + // while the in-kind switch values its residue under WETH, because + // `withdrawLp` hands a native side back wrapped. + const [c0, c1] = + tokenCAddr.toLowerCase() < usdcAddr.toLowerCase() ? [tokenCAddr, usdcAddr] : [usdcAddr, tokenCAddr]; + const tokenCRef = await UniPool.deploy(c0, c1, Q96, 10n ** 18n); + await tokenCRef.waitForDeployment(); + const uniPoolAddr = await uniPool.getAddress(); + const tokenCRefAddr = await tokenCRef.getAddress(); + const twapSeed = (token: string, pool: string) => ({ + token, + config: { pool, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }); + + const manager = await Manager.deploy( + await reg.getAddress(), + usdcAddr, + wethAddr, + [UNISWAP_V3, AERODROME, UNISWAP_V4], + [await uniHandler.getAddress(), await aeroHandler.getAddress(), await v4Handler.getAddress()], + [[FEE_TIER], [TICK_SPACING], [V4_KEY, V4_NATIVE_KEY, V4_UNINIT_KEY, V4_USDC0_KEY, V4_WRONG1_KEY]], + [0, 0, 0], + [0, 0, 0], + [twapSeed(wethAddr, uniPoolAddr), twapSeed(ZERO, uniPoolAddr), twapSeed(tokenCAddr, tokenCRefAddr)], + treasury.address, + Number(PERF_FEE_BPS), + Number(COLLECT_FEE_BPS), + MAX_FEE_BPS, + deployer.address, // initialAdmin + await timelock.getAddress(), // timelock + pauser.address, + ); + await manager.waitForDeployment(); + + await (await usdc.mint(safeAddr, 10n ** 12n)).wait(); + for (const target of [uniRouter, clRouter, uniNpm, clNpm, universalRouter, v4Pm]) { + await (await weth.mint(await target.getAddress(), 10n ** 24n)).wait(); + await (await usdc.mint(await target.getAddress(), 10n ** 18n)).wait(); + await (await tokenC.mint(await target.getAddress(), 10n ** 24n)).wait(); + } + // Native ETH liquidity for the router (swap output), the PM (fee/principal + // takes), and MockWETH (honouring `withdraw` for minted balances). + await (await deployer.sendTransaction({ to: await universalRouter.getAddress(), value: 10n ** 18n })).wait(); + await (await deployer.sendTransaction({ to: await v4Pm.getAddress(), value: 10n ** 18n })).wait(); + await (await deployer.sendTransaction({ to: wethAddr, value: 10n ** 18n })).wait(); + await (await uniRouter.setOutput(WETH_OUT)).wait(); + await (await clRouter.setOutput(WETH_OUT)).wait(); + await (await universalRouter.setOutput(WETH_OUT)).wait(); + + return { + deployer, + operatorEOA, + treasury, + stranger, + pauser, + timelock, + weth, + usdc, + tokenC, + wethAddr, + usdcAddr, + tokenCAddr, + uniPool, + uniFactory, + uniNpm, + uniRouter, + clPool, + clFactory, + clNpm, + clRouter, + voter, + permit2, + stateView, + v4Pm, + universalRouter, + safe, + safeAddr, + reg, + uniHandler, + aeroHandler, + v4Handler, + manager, + }; +} + +describe("SafeYieldManager + UniV4YieldHandler", function () { + describe("deployment & registration", function () { + it("registers the V4 handler with separate ERC20 and native reference keys", async function () { + const { manager, v4Handler, uniPool, wethAddr } = await loadFixture(deployUniV4Harness); + + expect(await v4Handler.PROTOCOL()).to.equal(UNISWAP_V4); + expect(await manager.yieldHandlers(UNISWAP_V4)).to.equal(await v4Handler.getAddress()); + expect(await manager.protocolEnabledForOpen(UNISWAP_V4)).to.equal(true); + expect(await manager.protocolEnabledForClose(UNISWAP_V4)).to.equal(true); + expect(await manager.isPoolParamAllowed(UNISWAP_V4, V4_KEY)).to.equal(true); + expect(await manager.isPoolParamAllowed(UNISWAP_V4, V4_NATIVE_KEY)).to.equal(true); + expect(await manager.isPoolParamAllowed(UNISWAP_V4, V4_BAD_KEY)).to.equal(false); + expect((await manager.twapConfigOf(wethAddr)).pool).to.equal(await uniPool.getAddress()); + expect((await manager.twapConfigOf(ZERO)).pool).to.equal(await uniPool.getAddress()); + }); + + it("rejects registering the V4 handler under a foreign id", async function () { + const { manager, timelock, v4Handler } = await loadFixture(deployUniV4Harness); + await expect(timelockCall(timelock, manager, "setYieldHandler", [1, await v4Handler.getAddress()])) + .to.be.revertedWithCustomError(manager, "HandlerProtocolMismatch") + .withArgs(1, UNISWAP_V4); + }); + + it("rejects zero addresses in the handler constructor", async function () { + const { v4Pm, universalRouter, permit2, stateView, usdcAddr, wethAddr, v4Handler } = + await loadFixture(deployUniV4Harness); + const V4Handler = await ethers.getContractFactory("UniV4YieldHandler"); + const args = [ + await v4Pm.getAddress(), + await universalRouter.getAddress(), + await permit2.getAddress(), + await stateView.getAddress(), + usdcAddr, + wethAddr, + ]; + for (let i = 0; i < args.length; i++) { + const bad = [...args]; + bad[i] = ZERO; + await expect( + V4Handler.deploy(bad[0], bad[1], bad[2], bad[3], bad[4], bad[5]), + ).to.be.revertedWithCustomError(v4Handler, "ZeroAddress"); + } + }); + + it("rejects direct (non-delegatecall) entry", async function () { + const { v4Handler, safeAddr } = await loadFixture(deployUniV4Harness); + await expect(v4Handler.openLp(openParams(safeAddr, V4_KEY))).to.be.revertedWithCustomError( + v4Handler, + "OnlyDelegatecall", + ); + await expect(v4Handler.closeLp(closeParams(safeAddr, 1, V4_KEY), 0)).to.be.revertedWithCustomError( + v4Handler, + "OnlyDelegatecall", + ); + await expect(v4Handler.collectLp(collectParams(safeAddr, 1, V4_KEY))).to.be.revertedWithCustomError( + v4Handler, + "OnlyDelegatecall", + ); + await expect( + v4Handler.withdrawLp({ + onBehalfOf: safeAddr, + tokenId: 1, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline: DEADLINE, + }), + ).to.be.revertedWithCustomError(v4Handler, "OnlyDelegatecall"); + await expect( + v4Handler.openLpInKind({ + onBehalfOf: safeAddr, + token0: safeAddr, + token1: safeAddr, + amount0: 0, + amount1: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: V4_KEY, + deadline: DEADLINE, + }), + ).to.be.revertedWithCustomError(v4Handler, "OnlyDelegatecall"); + }); + }); + + describe("openLp (ERC20 pair)", function () { + it("opens a V4 position: swap leg, liquidity mint, basis at executed rate", async function () { + const { manager, operatorEOA, safeAddr, v4Pm, weth, usdc } = await loadFixture(deployUniV4Harness); + + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, WETH_OUT, HALF, USDC_AMOUNT); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V4, 1)).to.equal(await manager.yieldHandlers(UNISWAP_V4)); + expect(await v4Pm.ownerOf(1)).to.equal(safeAddr); + expect(await v4Pm.getPositionLiquidity(1)).to.be.gt(0n); + }); + + it("resets both Permit2 hops after the mint", async function () { + const { manager, operatorEOA, safeAddr, permit2, v4Pm, universalRouter, weth, usdc, wethAddr, usdcAddr } = + await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)); + + const pmAddr = await v4Pm.getAddress(); + const urAddr = await universalRouter.getAddress(); + const permit2Addr = await permit2.getAddress(); + for (const token of [wethAddr, usdcAddr]) { + const [pmAmount] = await permit2.allowance(safeAddr, token, pmAddr); + const [urAmount] = await permit2.allowance(safeAddr, token, urAddr); + expect(pmAmount).to.equal(0n); + expect(urAmount).to.equal(0n); + } + expect(await weth.allowance(safeAddr, permit2Addr)).to.equal(0n); + expect(await usdc.allowance(safeAddr, permit2Addr)).to.equal(0n); + }); + + it("enforces the caller's mint minimums post-hoc (V4 has only settle caps)", async function () { + const { manager, operatorEOA, safeAddr, v4Pm, v4Handler } = await loadFixture(deployUniV4Harness); + // PM consumes only half the acquired token0 — below the min. + await (await v4Pm.setMintUse(1_000_000n, (1n << 128n) - 1n)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { mintAmount0Min: WETH_OUT })), + ).to.be.revertedWithCustomError(v4Handler, "MintAmountBelowMin"); + }); + + it("rejects staking (V4 has no stakePool)", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { stake: true })), + ).to.be.revertedWithCustomError(v4Handler, "StakingNotSupported"); + }); + + it("rejects a pool param outside the allow-list", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_BAD_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "PoolParamNotAllowed"); + }); + + it("rejects an uninitialized pool (lazy V4 pools read a zero sqrt price)", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_UNINIT_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "PoolNotInitialized"); + }); + + it("rejects a pool below the liquidity floor", async function () { + const { manager, operatorEOA, deployer, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await (await manager.connect(deployer).setMinPoolLiquidity(UNISWAP_V4, 10n ** 19n)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "PoolTooThin"); + }); + + it("rejects a mint below the position-liquidity floor", async function () { + const { manager, operatorEOA, deployer, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await (await manager.connect(deployer).setMinPositionLiquidity(UNISWAP_V4, (1n << 127n) - 1n)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "PositionLiquidityTooLow"); + }); + + it("rejects a mint whose computed liquidity is zero", async function () { + const { manager, operatorEOA, safeAddr, stateView, v4Handler } = await loadFixture(deployUniV4Harness); + // Price pinned one unit above MIN_SQRT_PRICE: over the full range + // the token0 side yields zero liquidity for any realistic amount. + await (await stateView.setPool(ethers.keccak256(V4_KEY), 4_295_128_740n, 10n ** 18n)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { tickLower: -887_200, tickUpper: 887_200 })), + ).to.be.revertedWithCustomError(v4Handler, "PositionLiquidityTooLow"); + }); + + it("enforces the token1-side mint minimum too", async function () { + const { manager, operatorEOA, safeAddr, v4Pm, v4Handler } = await loadFixture(deployUniV4Harness); + await (await v4Pm.setMintUse((1n << 128n) - 1n, 100_000n)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { mintAmount1Min: 200_000n })), + ).to.be.revertedWithCustomError(v4Handler, "MintAmountBelowMin"); + }); + + it("rejects a leg pool with the right currency0 but wrong currency1", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { swap0: leg(WETH_OUT, V4_WRONG1_KEY) })), + ).to.be.revertedWithCustomError(v4Handler, "WrongTokenPair"); + }); + + it("rejects a zero-output swap", async function () { + const { manager, operatorEOA, safeAddr, universalRouter, v4Handler } = + await loadFixture(deployUniV4Harness); + await (await universalRouter.setOutput(0)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "SwapFailed"); + }); + + it("rejects a mint that does not land on the Safe", async function () { + const { manager, operatorEOA, safeAddr, stranger, v4Pm, v4Handler } = await loadFixture(deployUniV4Harness); + await (await v4Pm.setMintOwnerOverride(stranger.address)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "LpNotOnSafe"); + }); + + it("validates the swap leg: slippage bounds and route", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + const open = (overrides: Record) => + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, overrides)); + + await expect(open({ slippageBps: 0 })).to.be.revertedWithCustomError(v4Handler, "SlippageTooLow"); + await expect(open({ slippageBps: 301 })).to.be.revertedWithCustomError(v4Handler, "SlippageAboveMax"); + // Leg pool must trade the {token, USDC} pair — the native pool does not. + await expect(open({ swap0: leg(WETH_OUT, V4_NATIVE_KEY) })).to.be.revertedWithCustomError( + v4Handler, + "WrongTokenPair", + ); + // Leg pool param must itself be allow-listed. + await expect(open({ swap0: leg(WETH_OUT, V4_BAD_KEY) })).to.be.revertedWithCustomError( + v4Handler, + "PoolParamNotAllowed", + ); + }); + + it("floors the UniversalRouter minimum at the V3 reference TWAP", async function () { + const { manager, operatorEOA, safeAddr, universalRouter } = await loadFixture(deployUniV4Harness); + // A V4 swap priced off Uniswap V3 history: the reference follows the + // token, not the venue it trades on. + const halfUsdc = USDC_AMOUNT / 2n; + const floor = (halfUsdc * (10_000n - BigInt(SLIP))) / 10_000n; + await ( + await manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { swap0: leg(1n, V4_KEY) })) + ).wait(); + expect(await universalRouter.lastAmountIn()).to.equal(halfUsdc); + expect(await universalRouter.lastAmountOutMinimum()).to.equal(floor); + }); + + it("reverts one unit below the V4 effective floor and succeeds exactly at it", async function () { + const { manager, operatorEOA, safeAddr, universalRouter } = await loadFixture(deployUniV4Harness); + const floor = ((USDC_AMOUNT / 2n) * (10_000n - BigInt(SLIP))) / 10_000n; + await (await universalRouter.setEnforceMinOut(true)).wait(); + await (await universalRouter.setOutput(floor - 1n)).wait(); + + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { swap0: leg(1n, V4_KEY) })), + ).to.be.revertedWith("ur: too little received"); + + await (await universalRouter.setOutput(floor)).wait(); + await expect( + manager + .connect(operatorEOA) + .openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY, { swap0: leg(1n, V4_KEY) })), + ).to.emit(manager, "PositionOpened"); + expect(await universalRouter.lastAmountOutMinimum()).to.equal(floor); + }); + + it("maps module-call failures to their step codes", async function () { + const { manager, operatorEOA, safeAddr, safe, usdcAddr, permit2, universalRouter, v4Pm, v4Handler } = + await loadFixture(deployUniV4Harness); + const open = () => manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)); + + // Leg swap: ERC20 approve USDC -> Permit2 is the first module call. + await (await safe.setFail(usdcAddr, 1)).wait(); + await expect(open()).to.be.revertedWithCustomError(v4Handler, "ModuleCallFailed").withArgs(41); + await (await safe.setFail(usdcAddr, 0)).wait(); + + await (await safe.setFail(await permit2.getAddress(), 1)).wait(); + await expect(open()).to.be.revertedWithCustomError(v4Handler, "ModuleCallFailed").withArgs(42); + await (await safe.setFail(await permit2.getAddress(), 0)).wait(); + + await (await safe.setFail(await universalRouter.getAddress(), 1)).wait(); + await expect(open()).to.be.revertedWithCustomError(v4Handler, "ModuleCallFailed").withArgs(43); + await (await safe.setFail(await universalRouter.getAddress(), 0)).wait(); + + await (await safe.setFail(await v4Pm.getAddress(), 1)).wait(); + await expect(open()).to.be.revertedWithCustomError(v4Handler, "ModuleCallFailed").withArgs(55); + }); + + it("bubbles a module inner revert with returndata", async function () { + const { manager, operatorEOA, safeAddr, safe, universalRouter } = await loadFixture(deployUniV4Harness); + const reason = ethers.concat([ + "0x08c379a0", + ethers.AbiCoder.defaultAbiCoder().encode(["string"], ["boom"]), + ]); + await (await safe.setFailData(reason)).wait(); + await (await safe.setFail(await universalRouter.getAddress(), 2)).wait(); + await expect( + manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)), + ).to.be.revertedWith("boom"); + }); + + it("rejects an approve that returns false", async function () { + const { manager, operatorEOA, safeAddr, usdc, v4Handler, usdcAddr } = await loadFixture(deployUniV4Harness); + // The zero-reset after the leg swap returns false — must revert. + await (await usdc.setFalseApproveZero(true)).wait(); + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY))) + .to.be.revertedWithCustomError(v4Handler, "TokenApprovalFailed") + .withArgs(usdcAddr); + }); + }); + + describe("openLp (native ETH pair)", function () { + it("opens a native position: ETH swap output, value-settled mint", async function () { + const { manager, operatorEOA, safeAddr, v4Pm } = await loadFixture(deployUniV4Harness); + + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_NATIVE_KEY))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, WETH_OUT, HALF, USDC_AMOUNT); + + expect(await v4Pm.ownerOf(1)).to.equal(safeAddr); + // All acquired ETH went into the mint. + expect(await ethers.provider.getBalance(safeAddr)).to.equal(0n); + }); + + it("sweeps unconsumed mint value back to the Safe and prices basis on used amounts", async function () { + const { manager, operatorEOA, safeAddr, v4Pm } = await loadFixture(deployUniV4Harness); + // The mint consumes only 1.5M of the 2M wei acquired; 0.5M is swept back. + await (await v4Pm.setMintUse(1_500_000n, (1n << 128n) - 1n)).wait(); + + // basis = 1.5M * (500k USDC / 2M wei) + 500k = 875k + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_NATIVE_KEY))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, 1_500_000n, HALF, 875_000n); + + expect(await ethers.provider.getBalance(safeAddr)).to.equal(500_000n); + }); + }); + + describe("closeLp (ERC20 pair)", function () { + async function openedFixture() { + const ctx = await loadFixture(deployUniV4Harness); + await ctx.manager.connect(ctx.operatorEOA).openLp(UNISWAP_V4, openParams(ctx.safeAddr, V4_KEY)); + await (await ctx.universalRouter.setOutputFor(ctx.usdcAddr, CLOSE_OUT)).wait(); + return ctx; + } + + it("fully closes: fees skimmed first, burn, swap back, performance fee", async function () { + const ctx = await openedFixture(); + const { manager, operatorEOA, safeAddr, treasury, v4Pm, weth, usdc } = ctx; + await (await v4Pm.setOwed(1, 40_000n, 20_000n)).wait(); + + const usdcBefore = await usdc.balanceOf(safeAddr); + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY)); + + // collected 40k WETH / 20k USDC, 2.5% skim = 1000 / 500 + await expect(tx) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ctx.wethAddr, 40_000n, 1_000n, ctx.usdcAddr, 20_000n, 500n); + // value = 19.5k fee USDC + 500k principal USDC + 600k swap out = 1_119_500 + // perf fee = 10% of (1_119_500 - 1_000_000) = 11_950 + await expect(tx) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, 1_119_500n, 11_950n, 10_000, 0n); + + expect(await usdc.balanceOf(safeAddr)).to.equal(usdcBefore + 1_119_500n - 11_950n); + expect(await weth.balanceOf(treasury.address)).to.equal(1_000n); + expect(await usdc.balanceOf(treasury.address)).to.equal(500n + 11_950n); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(0n); + expect(await manager.positionHandlerOf(UNISWAP_V4, 1)).to.equal(ZERO); + await expect(v4Pm.ownerOf(1)).to.be.revertedWith("ERC721: invalid token"); + }); + + it("partially closes and decrements basis pro-rata", async function () { + const ctx = await openedFixture(); + const { manager, operatorEOA, safeAddr, v4Pm } = ctx; + + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY, { exitBps: 5000 })), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V4, 1n, HALF, anyValue, anyValue, 5000, 0n); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(HALF); + expect(await v4Pm.getPositionLiquidity(1)).to.be.gt(0n); + }); + + it("rejects a partial exit whose rounded liquidity is zero", async function () { + const { manager, operatorEOA, safeAddr, v4Handler, universalRouter, usdcAddr } = + await loadFixture(deployUniV4Harness); + // Full-range small mint => liquidity (~5000) far below basis (10_000), + // so exitBps=1 rounds liquidity to 0 while basisForExit is 1. + await manager.connect(operatorEOA).openLp( + UNISWAP_V4, + openParams(safeAddr, V4_KEY, { + usdcAmount: 10_000n, + tickLower: -887_200, + tickUpper: 887_200, + }), + ); + await (await universalRouter.setOutputFor(usdcAddr, CLOSE_OUT)).wait(); + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY, { exitBps: 1 })), + ).to.be.revertedWithCustomError(v4Handler, "InvalidExitBps"); + }); + + it("enforces minUsdcOut on the gross realized value", async function () { + const ctx = await openedFixture(); + const { manager, operatorEOA, safeAddr, v4Handler } = ctx; + await expect( + manager + .connect(operatorEOA) + .closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY, { minUsdcOut: 10n ** 12n })), + ).to.be.revertedWithCustomError(v4Handler, "MinUsdcOutNotMet"); + }); + + it("waives the collect fee when the treasury transfer reverts or returns false", async function () { + const ctx = await openedFixture(); + const { manager, operatorEOA, safeAddr, treasury, v4Pm, weth, usdc } = ctx; + await (await v4Pm.setOwed(1, 40_000n, 20_000n)).wait(); + await (await weth.setRevertTransferTo(treasury.address)).wait(); + await (await usdc.setFalseTransferTo(treasury.address)).wait(); + + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY)); + await expect(tx).to.emit(manager, "CollectFeeTransferFailed").withArgs(safeAddr, 1n, ctx.wethAddr, 1_000n); + await expect(tx).to.emit(manager, "CollectFeeTransferFailed").withArgs(safeAddr, 1n, ctx.usdcAddr, 500n); + await expect(tx) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ctx.wethAddr, 40_000n, 0n, ctx.usdcAddr, 20_000n, 0n); + expect(await weth.balanceOf(treasury.address)).to.equal(0n); + }); + + it("maps the fee-harvest and principal module calls to steps 60-62", async function () { + const ctx = await openedFixture(); + const { manager, operatorEOA, safeAddr, safe, v4Pm, v4Handler } = ctx; + await (await safe.setFail(await v4Pm.getAddress(), 1)).wait(); + await expect(manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY))) + .to.be.revertedWithCustomError(v4Handler, "ModuleCallFailed") + .withArgs(60); + }); + }); + + describe("closeLp (native ETH pair)", function () { + async function openedNativeFixture() { + const ctx = await loadFixture(deployUniV4Harness); + await ctx.manager.connect(ctx.operatorEOA).openLp(UNISWAP_V4, openParams(ctx.safeAddr, V4_NATIVE_KEY)); + await (await ctx.universalRouter.setOutputFor(ctx.usdcAddr, CLOSE_OUT)).wait(); + return ctx; + } + + it("fully closes a native position: ETH fees skimmed, ETH principal swapped back", async function () { + const ctx = await openedNativeFixture(); + const { manager, operatorEOA, safeAddr, treasury, v4Pm, usdc } = ctx; + await (await v4Pm.setOwed(1, 40_000n, 20_000n)).wait(); + + const treasuryEthBefore = await ethers.provider.getBalance(treasury.address); + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_NATIVE_KEY)); + + await expect(tx) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ZERO, 40_000n, 1_000n, ctx.usdcAddr, 20_000n, 500n); + await expect(tx) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, 1_119_500n, 11_950n, 10_000, 0n); + + // ETH fee skim landed on the treasury; the Safe holds no stray ETH. + expect(await ethers.provider.getBalance(treasury.address)).to.equal(treasuryEthBefore + 1_000n); + expect(await ethers.provider.getBalance(safeAddr)).to.equal(0n); + await expect(v4Pm.ownerOf(1)).to.be.revertedWith("ERC721: invalid token"); + }); + + it("waives the native fee when the treasury cannot receive ETH", async function () { + const ctx = await openedNativeFixture(); + const { manager, operatorEOA, timelock, safeAddr, v4Pm, reg } = ctx; + // MockRegistry has no receive() — the ETH skim call fails. + await (await timelockCall(timelock, manager, "setTreasury", [await reg.getAddress()])).wait(); + await (await v4Pm.setOwed(1, 40_000n, 0n)).wait(); + + const tx = manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_NATIVE_KEY)); + await expect(tx).to.emit(manager, "CollectFeeTransferFailed").withArgs(safeAddr, 1n, ZERO, 1_000n); + await expect(tx) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ZERO, 40_000n, 0n, ctx.usdcAddr, 0n, 0n); + }); + }); + + describe("collectLp", function () { + async function openedWithFees() { + const ctx = await loadFixture(deployUniV4Harness); + await ctx.manager.connect(ctx.operatorEOA).openLp(UNISWAP_V4, openParams(ctx.safeAddr, V4_KEY)); + await (await ctx.v4Pm.setOwed(1, 40_000n, 20_000n)).wait(); + return ctx; + } + + it("harvests fees to the Safe with the treasury skim, no swap", async function () { + const ctx = await openedWithFees(); + const { manager, operatorEOA, safeAddr, treasury, weth, usdc } = ctx; + const wethBefore = await weth.balanceOf(safeAddr); + const usdcBefore = await usdc.balanceOf(safeAddr); + + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY))) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ctx.wethAddr, 40_000n, 1_000n, ctx.usdcAddr, 20_000n, 500n); + + expect(await weth.balanceOf(safeAddr)).to.equal(wethBefore + 39_000n); + expect(await usdc.balanceOf(safeAddr)).to.equal(usdcBefore + 19_500n); + expect(await weth.balanceOf(treasury.address)).to.equal(1_000n); + expect(await usdc.balanceOf(treasury.address)).to.equal(500n); + }); + + it("swaps harvested fees to USDC on request", async function () { + const ctx = await openedWithFees(); + const { manager, operatorEOA, safeAddr, universalRouter, usdc, usdcAddr, weth } = ctx; + await (await universalRouter.setOutputFor(usdcAddr, CLOSE_OUT)).wait(); + const wethBefore = await weth.balanceOf(safeAddr); + const usdcBefore = await usdc.balanceOf(safeAddr); + + await manager + .connect(operatorEOA) + .collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY, { swapFeesToUsdc: true })); + + // 39k net WETH fees swapped for 600k USDC + 19.5k net USDC fees. + expect(await weth.balanceOf(safeAddr)).to.equal(wethBefore); + expect(await usdc.balanceOf(safeAddr)).to.equal(usdcBefore + CLOSE_OUT + 19_500n); + }); + + it("leaves a dynamic V4 fee delta in kind when its independent floor rounds to zero", async function () { + const ctx = await loadFixture(deployUniV4Harness); + const { manager, operatorEOA, safeAddr, universalRouter, v4Pm, weth } = ctx; + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)); + await (await v4Pm.setOwed(1, 1n, 0n)).wait(); + const callsBefore = await universalRouter.callCount(); + const wethBefore = await weth.balanceOf(safeAddr); + + await expect( + manager.connect(operatorEOA).collectLp( + UNISWAP_V4, + collectParams(safeAddr, 1, V4_KEY, { + swapFeesToUsdc: true, + swap0: leg(0, V4_KEY), + }), + ), + ).to.emit(manager, "FeesCollected"); + + expect(await universalRouter.callCount()).to.equal(callsBefore); + expect(await weth.balanceOf(safeAddr)).to.equal(wethBefore + 1n); + }); + + it("rejects an expired deadline on the swap path only", async function () { + const ctx = await openedWithFees(); + const { manager, operatorEOA, safeAddr, v4Handler } = ctx; + await expect( + manager + .connect(operatorEOA) + .collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY, { swapFeesToUsdc: true, deadline: 1 })), + ).to.be.revertedWithCustomError(v4Handler, "DeadlineExpired"); + // The no-swap path ignores the stale deadline. + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY, { deadline: 1 })), + ).to.emit(manager, "FeesCollected"); + }); + + it("handles a fee-less harvest and a skim rounded to zero", async function () { + const ctx = await loadFixture(deployUniV4Harness); + const { manager, operatorEOA, safeAddr, v4Pm, treasury, usdc } = ctx; + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)); + + // No fees at all: amounts 0, fee 0. + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY))) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ctx.wethAddr, 0n, 0n, ctx.usdcAddr, 0n, 0n); + + // 39 * 250 / 10000 = 0 — fee rounds to zero, nothing skimmed. + await (await v4Pm.setOwed(1, 0n, 39n)).wait(); + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY))) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ctx.wethAddr, 0n, 0n, ctx.usdcAddr, 39n, 0n); + expect(await usdc.balanceOf(treasury.address)).to.equal(0n); + }); + + it("rejects collect for a position the Safe does not own", async function () { + const ctx = await openedWithFees(); + const { manager, operatorEOA, safeAddr, stranger, v4Pm, v4Handler } = ctx; + await (await v4Pm.setOwner(1, stranger.address)).wait(); + await expect( + manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "LpNotOnSafe"); + }); + + it("harvests native ETH fees", async function () { + const ctx = await loadFixture(deployUniV4Harness); + const { manager, operatorEOA, safeAddr, v4Pm, treasury } = ctx; + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(ctx.safeAddr, V4_NATIVE_KEY)); + await (await v4Pm.setOwed(1, 40_000n, 0n)).wait(); + const treasuryEthBefore = await ethers.provider.getBalance(treasury.address); + + await expect(manager.connect(operatorEOA).collectLp(UNISWAP_V4, collectParams(safeAddr, 1, V4_NATIVE_KEY))) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, ZERO, 40_000n, 1_000n, ctx.usdcAddr, 0n, 0n); + + expect(await ethers.provider.getBalance(safeAddr)).to.equal(39_000n); + expect(await ethers.provider.getBalance(treasury.address)).to.equal(treasuryEthBefore + 1_000n); + }); + }); + + describe("USDC-as-currency0 pair", function () { + // {USDC, tokenC} sorts USDC first, flipping every currency0/currency1 + // USDC-side branch relative to the WETH/USDC pair. + function usdc0Open(safeAddr: string, overrides: Record = {}) { + return openParams(safeAddr, V4_USDC0_KEY, { + swap0: ZERO_LEG, + swap1: leg(WETH_OUT, V4_USDC0_KEY), + ...overrides, + }); + } + + it("runs the full lifecycle with the swap on the currency1 side", async function () { + const { manager, operatorEOA, safeAddr, universalRouter, usdcAddr, tokenC, tokenCAddr, v4Pm, usdc } = + await loadFixture(deployUniV4Harness); + + await expect(manager.connect(operatorEOA).openLp(UNISWAP_V4, usdc0Open(safeAddr))) + .to.emit(manager, "PositionOpened") + .withArgs(safeAddr, UNISWAP_V4, 1n, USDC_AMOUNT, HALF, WETH_OUT, USDC_AMOUNT); + + await (await v4Pm.setOwed(1, 20_000n, 40_000n)).wait(); + await (await universalRouter.setOutputFor(usdcAddr, CLOSE_OUT)).wait(); + + // Harvest with the tokenC (currency1) fees swapped to USDC. + const usdcBefore = await usdc.balanceOf(safeAddr); + await expect( + manager.connect(operatorEOA).collectLp( + UNISWAP_V4, + collectParams(safeAddr, 1, V4_USDC0_KEY, { + swapFeesToUsdc: true, + swap0: ZERO_LEG, + swap1: leg(CLOSE_OUT, V4_USDC0_KEY), + }), + ), + ) + .to.emit(manager, "FeesCollected") + .withArgs(safeAddr, UNISWAP_V4, 1n, usdcAddr, 20_000n, 500n, tokenCAddr, 40_000n, 1_000n); + // 19.5k net USDC fees + 39k tokenC fees swapped for 600k USDC. + expect(await usdc.balanceOf(safeAddr)).to.equal(usdcBefore + 19_500n + CLOSE_OUT); + + await expect( + manager.connect(operatorEOA).closeLp( + UNISWAP_V4, + closeParams(safeAddr, 1, V4_USDC0_KEY, { + swap0: ZERO_LEG, + swap1: leg(CLOSE_OUT, V4_USDC0_KEY), + }), + ), + ).to.emit(manager, "PositionClosed"); + expect(await tokenC.balanceOf(safeAddr)).to.equal(0n); + }); + }); + + describe("partial close with zero rounded liquidity and zero basis slice", function () { + it("skips the decrease and settles at zero value without reverting", async function () { + const { manager, operatorEOA, safeAddr, universalRouter, usdcAddr, v4Pm } = + await loadFixture(deployUniV4Harness); + // Full-range mint => liquidity (~4000) below basis (8000); exitBps=1 + // rounds BOTH the liquidity and the basis slice to zero. + await manager.connect(operatorEOA).openLp( + UNISWAP_V4, + openParams(safeAddr, V4_KEY, { + usdcAmount: 8_000n, + tickLower: -887_200, + tickUpper: 887_200, + }), + ); + await (await universalRouter.setOutputFor(usdcAddr, CLOSE_OUT)).wait(); + const liquidityBefore = await v4Pm.getPositionLiquidity(1); + + await expect( + manager.connect(operatorEOA).closeLp(UNISWAP_V4, closeParams(safeAddr, 1, V4_KEY, { exitBps: 1 })), + ) + .to.emit(manager, "PositionClosed") + .withArgs(safeAddr, UNISWAP_V4, 1n, 0n, 0n, 0n, 1, 0n); + + expect(await v4Pm.getPositionLiquidity(1)).to.equal(liquidityBefore); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(8_000n); + }); + }); + + describe("switchLp across V3, Aerodrome and V4", function () { + function switchParams( + safeAddr: string, + tokenId: bigint | number, + openPoolParam: string, + overrides: Record = {}, + ) { + return { + onBehalfOf: safeAddr, + tokenId, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + tickLower: -100, + tickUpper: 100, + mintAmount0Min: 0, + mintAmount1Min: 0, + lpPoolParam: openPoolParam, + deadline: DEADLINE, + ...overrides, + }; + } + + it("switches V3 -> V4 in kind with zero manager changes", async function () { + const { manager, operatorEOA, safeAddr, v4Handler, v4Pm } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_KEY)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V3, UNISWAP_V4, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(0n); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V4, 1)).to.equal(await v4Handler.getAddress()); + expect(await v4Pm.ownerOf(1)).to.equal(safeAddr); + }); + + it("switches V3 -> native V4, unwrapping the withdrawn WETH", async function () { + const { manager, operatorEOA, safeAddr, weth, v4Pm } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_NATIVE_KEY)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V3, UNISWAP_V4, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(USDC_AMOUNT); + expect(await v4Pm.ownerOf(1)).to.equal(safeAddr); + expect(await weth.balanceOf(safeAddr)).to.equal(0n); + }); + + it("switches V4 -> V3, wrapping the native side of the withdrawal", async function () { + const { manager, operatorEOA, safeAddr, weth, uniHandler, uniNpm } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_NATIVE_KEY)); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V4, UNISWAP_V3, switchParams(safeAddr, 1, FEE_TIER)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V4, UNISWAP_V3, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(0n); + expect(await manager.residualBasisUsd6Of(UNISWAP_V3, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(await uniHandler.getAddress()); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + expect(await weth.balanceOf(safeAddr)).to.equal(0n); + }); + + it("switches Aerodrome -> V4 in kind with zero manager changes", async function () { + const { manager, operatorEOA, safeAddr, v4Handler, v4Pm } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(AERODROME, openParams(safeAddr, TICK_SPACING)); + + await expect( + manager.connect(operatorEOA).switchLp(AERODROME, UNISWAP_V4, switchParams(safeAddr, 1, V4_KEY)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, AERODROME, UNISWAP_V4, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(0n); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(UNISWAP_V4, 1)).to.equal(await v4Handler.getAddress()); + expect(await v4Pm.ownerOf(1)).to.equal(safeAddr); + }); + + it("switches V4 -> Aerodrome, wrapping the native side of the withdrawal", async function () { + const { manager, operatorEOA, safeAddr, aeroHandler, clNpm } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_NATIVE_KEY)); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V4, AERODROME, switchParams(safeAddr, 1, TICK_SPACING)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V4, AERODROME, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, 1)).to.equal(0n); + expect(await manager.residualBasisUsd6Of(AERODROME, 1)).to.equal(USDC_AMOUNT); + expect(await manager.positionHandlerOf(AERODROME, 1)).to.equal(await aeroHandler.getAddress()); + expect(await clNpm.ownerOf(1)).to.equal(safeAddr); + }); + + it("rejects an in-kind open whose tokens do not match the destination pool", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + + // currency1 mismatch: the destination trades {WETH, tokenC}, not {WETH, USDC}. + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_WRONG1_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "WrongTokenPair"); + + // currency0 mismatch: the destination's currency0 is USDC, the withdrawal delivered WETH. + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_USDC0_KEY)), + ).to.be.revertedWithCustomError(v4Handler, "WrongTokenPair"); + }); + + it("switches an ERC20-currency0 V4 position out without wrapping", async function () { + const { manager, operatorEOA, safeAddr, uniHandler, uniNpm, weth } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V4, openParams(safeAddr, V4_KEY)); + const safeWethBefore = await weth.balanceOf(safeAddr); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V4, UNISWAP_V3, switchParams(safeAddr, 1, FEE_TIER)), + ) + .to.emit(manager, "PositionSwitched") + .withArgs(safeAddr, UNISWAP_V4, UNISWAP_V3, 1n, 1n, USDC_AMOUNT, WETH_OUT, HALF, WETH_OUT, HALF); + + expect(await manager.positionHandlerOf(UNISWAP_V3, 1)).to.equal(await uniHandler.getAddress()); + expect(await uniNpm.ownerOf(1)).to.equal(safeAddr); + // The WETH went straight from the V4 burn into the V3 mint — never parked on the Safe. + expect(await weth.balanceOf(safeAddr)).to.equal(safeWethBefore); + }); + + it("rejects a destination mint below the position-liquidity floor", async function () { + const { manager, deployer, operatorEOA, safeAddr } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + await (await manager.connect(deployer).setMinPositionLiquidity(UNISWAP_V4, 10n ** 18n)).wait(); + + await expect( + manager.connect(operatorEOA).switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_KEY)), + ).to.be.revertedWithCustomError(manager, "PositionLiquidityTooLow"); + }); + + it("enforces the destination mint minimums on each side", async function () { + const { manager, operatorEOA, safeAddr, v4Handler } = await loadFixture(deployUniV4Harness); + await manager.connect(operatorEOA).openLp(UNISWAP_V3, openParams(safeAddr, FEE_TIER)); + + await expect( + manager + .connect(operatorEOA) + .switchLp( + UNISWAP_V3, + UNISWAP_V4, + switchParams(safeAddr, 1, V4_KEY, { mintAmount0Min: WETH_OUT + 1n }), + ), + ).to.be.revertedWithCustomError(v4Handler, "MintAmountBelowMin"); + + await expect( + manager + .connect(operatorEOA) + .switchLp(UNISWAP_V3, UNISWAP_V4, switchParams(safeAddr, 1, V4_KEY, { mintAmount1Min: HALF + 1n })), + ).to.be.revertedWithCustomError(v4Handler, "MintAmountBelowMin"); + }); + }); +}); diff --git a/test/yield/safeYieldManagerUniV4Fork.ts b/test/yield/safeYieldManagerUniV4Fork.ts new file mode 100644 index 0000000..19e161e --- /dev/null +++ b/test/yield/safeYieldManagerUniV4Fork.ts @@ -0,0 +1,485 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { + PERMIT2_ADDRESS, + UNISWAP_V3_FACTORY_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + encodeUniV4PoolParam, + TWAP_REF_WETH_USDC_POOL, + TWAP_WINDOW, + TWAP_CARDINALITY, +} from "../../contractAddresses"; +import { ZERO_LEG, leg } from "../helpers/utils"; +import { deployRealSafe, enableModuleOnSafe } from "../helpers/deployRealSafe"; + +// ───────────────────────────────────────────────────────────────────────── +// Base-fork integration for UniV4YieldHandler against the REAL Uniswap V4 +// stack (PoolManager-backed PositionManager, UniversalRouter, Permit2, +// StateView). This is what validates the vendored Actions/Commands byte +// values and param encodings — the mock suite can only prove internal +// consistency. Uses the native ETH/USDC pool (currency0 == address(0)), +// the deepest V4 pool on Base. +// ───────────────────────────────────────────────────────────────────────── + +const UNISWAP_V4 = 2; +const FEE_TIER = 500; +const TICK_SPACING = 10; +const FORK_BLOCK = Number(process.env.BASE_FORK_BLOCK_NUMBER ?? 49_470_000); +const NATIVE = ethers.ZeroAddress; +const POOL_PARAM = encodeUniV4PoolParam(NATIVE, USDC_ADDRESS, FEE_TIER, TICK_SPACING, ethers.ZeroAddress); +const POOL_ID = ethers.keccak256(POOL_PARAM); + +// ERC20-currency0 pool (WETH < USDC so currency0 == WETH): exercises the +// Permit2 two-step approval + reset path the native pool skips. fee 0.3% / +// tickSpacing 60 is the deeper of the two real WETH/USDC V4 pools on Base. +const WETH_FEE_TIER = 3000; +const WETH_TICK_SPACING = 60; +const WETH_POOL_PARAM = encodeUniV4PoolParam( + WETH_ADDRESS, + USDC_ADDRESS, + WETH_FEE_TIER, + WETH_TICK_SPACING, + ethers.ZeroAddress, +); +const WETH_POOL_ID = ethers.keccak256(WETH_POOL_PARAM); + +const ERC20_ABI = [ + "function balanceOf(address) view returns (uint256)", + "function transfer(address,uint256) returns (bool)", + "function allowance(address,address) view returns (uint256)", +]; +const FACTORY_ABI = ["function getPool(address,address,uint24) view returns (address)"]; +const STATE_VIEW_ABI = [ + "function getSlot0(bytes32) view returns (uint160,int24,uint24,uint24)", + "function getLiquidity(bytes32) view returns (uint128)", +]; +const PM_ABI = [ + "function ownerOf(uint256) view returns (address)", + "function getPositionLiquidity(uint256) view returns (uint128)", +]; +const PERMIT2_ABI = ["function allowance(address,address,address) view returns (uint160,uint48,uint48)"]; +const UNIVERSAL_ROUTER_ABI = ["function execute(bytes,bytes[],uint256) payable"]; + +async function pushV4NativeSpotDown(ethIn: bigint) { + const trader = (await ethers.getSigners())[4]; + const coder = ethers.AbiCoder.defaultAbiCoder(); + const swap = coder.encode( + [ + "tuple(tuple(address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks) poolKey,bool zeroForOne,uint128 amountIn,uint128 amountOutMinimum,bytes hookData)", + ], + [[[NATIVE, USDC_ADDRESS, FEE_TIER, TICK_SPACING, ethers.ZeroAddress], true, ethIn, 0n, "0x"]], + ); + const settle = coder.encode(["address", "uint256"], [NATIVE, ethIn]); + const take = coder.encode(["address", "uint256"], [USDC_ADDRESS, 0n]); + const input = coder.encode(["bytes", "bytes[]"], ["0x060c0f", [swap, settle, take]]); + const router = new ethers.Contract(UNIVERSAL_ROUTER_ADDRESS, UNIVERSAL_ROUTER_ABI, trader); + const block = await ethers.provider.getBlock("latest"); + await (await router.execute("0x10", [input], block!.timestamp + 600, { value: ethIn })).wait(); +} + +describe("SafeYieldManager + Uniswap V4 - integration (Base fork)", function () { + this.timeout(300_000); + + beforeEach(async function () { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org", + blockNumber: FORK_BLOCK, + }, + }, + ], + }); + }); + + it("opens, collects, partially closes, and fully closes a real native ETH/USDC V4 position", async function () { + const [admin, operator, treasury, pauser] = await ethers.getSigners(); + + const Registry = await ethers.getContractFactory("MockRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + await (await registry.setOperator(operator.address)).wait(); + await (await registry.setWhitelisted(WETH_ADDRESS, true)).wait(); + await (await registry.setWhitelisted(USDC_ADDRESS, true)).wait(); + + const safeAddress = await deployRealSafe(admin); + + const Handler = await ethers.getContractFactory("UniV4YieldHandler"); + const handler = await Handler.deploy( + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + PERMIT2_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + ); + await handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await registry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + [UNISWAP_V4], + [await handler.getAddress()], + [[POOL_PARAM]], + [0], + [0], + [ + { + token: ethers.ZeroAddress, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + 1_000, + 250, + 2_000, + admin.address, + await timelock.getAddress(), + pauser.address, + ); + await manager.waitForDeployment(); + await enableModuleOnSafe(safeAddress, admin, await manager.getAddress()); + + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, STATE_VIEW_ABI, ethers.provider); + const [sqrtPriceRaw, tickRaw] = await stateView.getSlot0(POOL_ID); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + if (sqrtPriceX96 === 0n) { + throw new Error( + `Uniswap V4 pool ${POOL_ID} is not initialized at fork block ${FORK_BLOCK}; refusing to skip the integration test`, + ); + } + const alignedTick = Math.floor(Number(tickRaw) / TICK_SPACING) * TICK_SPACING; + const spotUsdcToEth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotEthToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + // The live V3 pool is a convenient deterministic USDC holder on the + // fork. Impersonation only mutates the disposable fork state. + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const v3Pool: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + await network.provider.send("hardhat_setBalance", [v3Pool, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(v3Pool); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, ethers.provider); + const input = ethers.parseUnits("10", 6); + await (await (usdc.connect(poolSigner) as any).transfer(safeAddress, input)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [v3Pool]); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToEth(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 1_000, + tickUpper: alignedTick + 1_000, + mintAmount0Min: 0, + mintAmount1Min: 0, + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: POOL_PARAM, + stake: false, + }; + + // Router-level min-out (TAKE_ALL floor) must fire on an impossible quote. + await expect( + manager.connect(operator).openLp(UNISWAP_V4, { + ...openParams, + swap0: leg((expectedSwapOut * 2n * 9_900n) / 10_000n, POOL_PARAM), + }), + ).to.be.reverted; + + await expect(manager.connect(operator).openLp(UNISWAP_V4, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + + const pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, PM_ABI, ethers.provider); + // Mint dust: ETH the liquidity computation could not consume was swept + // back to the Safe (the V4 analogue of V3's unused-desired refund). + const ethDustAfterOpen = await ethers.provider.getBalance(safeAddress); + expect(await pm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await pm.getPositionLiquidity(tokenId)).to.be.greaterThan(0); + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId); + expect(initialBasis).to.be.greaterThan(0); + + // Both Permit2 hops must be back at zero after the mint. + const permit2 = new ethers.Contract(PERMIT2_ADDRESS, PERMIT2_ABI, ethers.provider); + const [pmAllowance] = await permit2.allowance(safeAddress, USDC_ADDRESS, UNISWAP_V4_POSITION_MANAGER_ADDRESS); + const [urAllowance] = await permit2.allowance(safeAddress, USDC_ADDRESS, UNIVERSAL_ROUTER_ADDRESS); + expect(pmAllowance).to.equal(0); + expect(urAllowance).to.equal(0); + expect(await usdc.allowance(safeAddress, PERMIT2_ADDRESS)).to.equal(0); + + await expect( + manager.connect(operator).collectLp(UNISWAP_V4, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.emit(manager, "FeesCollected"); + + const ethToLp: bigint = opened.args.amount0ToLp; + const usdcToLp: bigint = opened.args.amount1ToLp; + const closeParams = (exitBps: number, shareOfOriginalBps: bigint) => { + const ethShare = (ethToLp * shareOfOriginalBps) / 10_000n; + const usdcShare = (usdcToLp * shareOfOriginalBps) / 10_000n; + const expectedOut = spotEthToUsdc(ethShare); + return { + onBehalfOf: safeAddress, + tokenId, + exitBps, + swap0: { + amountOutMin: (expectedOut * 9_700n) / 10_000n, + poolParam: POOL_PARAM, + }, + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcShare + expectedOut) * 9_500n) / 10_000n, + }; + }; + + const manipulationSnapshot = await network.provider.send("evm_snapshot"); + const tickBeforeManipulation = Number((await stateView.getSlot0(POOL_ID))[1]); + await pushV4NativeSpotDown(ethers.parseEther("1000")); + const tickAfterManipulation = Number((await stateView.getSlot0(POOL_ID))[1]); + expect(tickAfterManipulation).to.be.lessThan(tickBeforeManipulation - 300); + await expect( + manager.connect(operator).closeLp(UNISWAP_V4, { + ...closeParams(10_000, 10_000n), + swap0: leg(0, POOL_PARAM), + minUsdcOut: 0, + }), + ).to.be.reverted; + expect(await pm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await network.provider.send("evm_revert", [manipulationSnapshot])).to.equal(true); + + await expect(manager.connect(operator).closeLp(UNISWAP_V4, closeParams(5_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await pm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId)).to.be.lessThan(initialBasis); + + await expect(manager.connect(operator).closeLp(UNISWAP_V4, closeParams(10_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId)).to.equal(0); + await expect(pm.ownerOf(tokenId)).to.be.reverted; + // The closes swept their own ETH back to USDC — only the pre-existing + // open-mint dust remains on the Safe. + expect(await ethers.provider.getBalance(safeAddress)).to.equal(ethDustAfterOpen); + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); + + it("opens, collects, partially closes, and fully closes a real WETH/USDC V4 position (ERC20 currency0)", async function () { + const [admin, operator, treasury, pauser] = await ethers.getSigners(); + + const Registry = await ethers.getContractFactory("MockRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + await (await registry.setOperator(operator.address)).wait(); + await (await registry.setWhitelisted(WETH_ADDRESS, true)).wait(); + await (await registry.setWhitelisted(USDC_ADDRESS, true)).wait(); + + const safeAddress = await deployRealSafe(admin); + + const Handler = await ethers.getContractFactory("UniV4YieldHandler"); + const handler = await Handler.deploy( + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + PERMIT2_ADDRESS, + UNISWAP_V4_STATE_VIEW_ADDRESS, + USDC_ADDRESS, + WETH_ADDRESS, + ); + await handler.waitForDeployment(); + + const Timelock = await ethers.getContractFactory("MockTimelockController"); + const timelock = await Timelock.deploy(1); + await timelock.waitForDeployment(); + + const Manager = await ethers.getContractFactory("SafeYieldManager"); + const manager = await Manager.deploy( + await registry.getAddress(), + USDC_ADDRESS, + WETH_ADDRESS, + [UNISWAP_V4], + [await handler.getAddress()], + [[WETH_POOL_PARAM]], + [0], + [0], + [ + { + token: WETH_ADDRESS, + config: { pool: TWAP_REF_WETH_USDC_POOL, window: TWAP_WINDOW, minCardinality: TWAP_CARDINALITY }, + }, + ], + treasury.address, + 1_000, + 250, + 2_000, + admin.address, + await timelock.getAddress(), + pauser.address, + ); + await manager.waitForDeployment(); + await enableModuleOnSafe(safeAddress, admin, await manager.getAddress()); + + const stateView = new ethers.Contract(UNISWAP_V4_STATE_VIEW_ADDRESS, STATE_VIEW_ABI, ethers.provider); + const [sqrtPriceRaw, tickRaw] = await stateView.getSlot0(WETH_POOL_ID); + const sqrtPriceX96 = BigInt(sqrtPriceRaw); + if (sqrtPriceX96 === 0n) { + throw new Error( + `Uniswap V4 pool ${WETH_POOL_ID} is not initialized at fork block ${FORK_BLOCK}; refusing to skip the integration test`, + ); + } + const alignedTick = Math.floor(Number(tickRaw) / WETH_TICK_SPACING) * WETH_TICK_SPACING; + // currency0 == WETH, currency1 == USDC (both share the native pool's + // 18dp/6dp layout, so the same spot formulas hold). + const spotUsdcToWeth = (amount: bigint) => (amount << 192n) / (sqrtPriceX96 * sqrtPriceX96); + const spotWethToUsdc = (amount: bigint) => (amount * sqrtPriceX96 * sqrtPriceX96) >> 192n; + + // The live V3 pool is a convenient deterministic USDC holder on the fork. + const factory = new ethers.Contract(UNISWAP_V3_FACTORY_ADDRESS, FACTORY_ABI, ethers.provider); + const v3Pool: string = await factory.getPool(WETH_ADDRESS, USDC_ADDRESS, FEE_TIER); + await network.provider.send("hardhat_setBalance", [v3Pool, "0x8AC7230489E80000"]); + const poolSigner = await ethers.getImpersonatedSigner(v3Pool); + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, ethers.provider); + const weth = new ethers.Contract(WETH_ADDRESS, ERC20_ABI, ethers.provider); + const input = ethers.parseUnits("10", 6); + await (await (usdc.connect(poolSigner) as any).transfer(safeAddress, input)).wait(); + await network.provider.send("hardhat_stopImpersonatingAccount", [v3Pool]); + + const block = await ethers.provider.getBlock("latest"); + const deadline = BigInt(block!.timestamp + 3_600); + const expectedSwapOut = spotUsdcToWeth(input / 2n); + const openParams = { + onBehalfOf: safeAddress, + usdcAmount: input, + tickLower: alignedTick - 20 * WETH_TICK_SPACING, + tickUpper: alignedTick + 20 * WETH_TICK_SPACING, + mintAmount0Min: 0, + mintAmount1Min: 0, + // USDC -> WETH (currency0) leg; the USDC side (currency1) needs none. + swap0: leg((expectedSwapOut * 9_900n) / 10_000n, WETH_POOL_PARAM), + swap1: ZERO_LEG, + slippageBps: 100, + deadline, + lpPoolParam: WETH_POOL_PARAM, + stake: false, + }; + + await expect(manager.connect(operator).openLp(UNISWAP_V4, openParams)).to.emit(manager, "PositionOpened"); + const openedEvents = await manager.queryFilter(manager.filters.PositionOpened(safeAddress), -5); + const opened = openedEvents[openedEvents.length - 1]; + const tokenId = opened.args.tokenId; + + const pm = new ethers.Contract(UNISWAP_V4_POSITION_MANAGER_ADDRESS, PM_ABI, ethers.provider); + expect(await pm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await pm.getPositionLiquidity(tokenId)).to.be.greaterThan(0); + const initialBasis = await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId); + expect(initialBasis).to.be.greaterThan(0); + // WETH the liquidity could not consume stays on the Safe (ERC20 has no + // SWEEP; Permit2 only pulls what the mint settles). + const wethDustAfterOpen = await weth.balanceOf(safeAddress); + + // Both Permit2 hops must be back at zero after the mint — for the ERC20 + // currency0 (WETH) as well as the USDC swap input. This is the path the + // native pool skips entirely. + const permit2 = new ethers.Contract(PERMIT2_ADDRESS, PERMIT2_ABI, ethers.provider); + const [wethPmAllowance] = await permit2.allowance( + safeAddress, + WETH_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + ); + const [usdcPmAllowance] = await permit2.allowance( + safeAddress, + USDC_ADDRESS, + UNISWAP_V4_POSITION_MANAGER_ADDRESS, + ); + const [usdcUrAllowance] = await permit2.allowance(safeAddress, USDC_ADDRESS, UNIVERSAL_ROUTER_ADDRESS); + expect(wethPmAllowance).to.equal(0); + expect(usdcPmAllowance).to.equal(0); + expect(usdcUrAllowance).to.equal(0); + expect(await weth.allowance(safeAddress, PERMIT2_ADDRESS)).to.equal(0); + expect(await usdc.allowance(safeAddress, PERMIT2_ADDRESS)).to.equal(0); + + await expect( + manager.connect(operator).collectLp(UNISWAP_V4, { + onBehalfOf: safeAddress, + tokenId, + swapFeesToUsdc: false, + swap0: ZERO_LEG, + swap1: ZERO_LEG, + swapRewardToUsdc: false, + rewardSwap: ZERO_LEG, + slippageBps: 0, + deadline, + }), + ).to.emit(manager, "FeesCollected"); + + const wethToLp: bigint = opened.args.amount0ToLp; + const usdcToLp: bigint = opened.args.amount1ToLp; + const closeParams = (exitBps: number, shareOfOriginalBps: bigint) => { + const wethShare = (wethToLp * shareOfOriginalBps) / 10_000n; + const usdcShare = (usdcToLp * shareOfOriginalBps) / 10_000n; + const expectedOut = spotWethToUsdc(wethShare); + return { + onBehalfOf: safeAddress, + tokenId, + exitBps, + swap0: { + amountOutMin: (expectedOut * 9_700n) / 10_000n, + poolParam: WETH_POOL_PARAM, + }, + swap1: ZERO_LEG, + slippageBps: 300, + decreaseAmount0Min: 0, + decreaseAmount1Min: 0, + deadline, + minUsdcOut: ((usdcShare + expectedOut) * 9_500n) / 10_000n, + }; + }; + + await expect(manager.connect(operator).closeLp(UNISWAP_V4, closeParams(5_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await pm.ownerOf(tokenId)).to.equal(safeAddress); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId)).to.be.lessThan(initialBasis); + + await expect(manager.connect(operator).closeLp(UNISWAP_V4, closeParams(10_000, 5_000n))).to.emit( + manager, + "PositionClosed", + ); + expect(await manager.residualBasisUsd6Of(UNISWAP_V4, tokenId)).to.equal(0); + await expect(pm.ownerOf(tokenId)).to.be.reverted; + // Closes swap only the WETH they withdraw (delta-measured), so the + // open-mint WETH dust is left untouched; realized USDC lands on the Safe. + expect(await weth.balanceOf(safeAddress)).to.equal(wethDustAfterOpen); + expect(await usdc.balanceOf(safeAddress)).to.be.greaterThan(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index 94c8ad0..0edd777 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2883,6 +2883,20 @@ __metadata: languageName: node linkType: hard +"@uniswap/v4-core@npm:1.0.2": + version: 1.0.2 + resolution: "@uniswap/v4-core@npm:1.0.2" + checksum: 10c0/a0f97e6a73e6d6395191d1f95d0e8914f338dab83144092105b12dd7bebf7cab8bd8f2ceeab048061f54c0e499e83d3dcde9443062949f6f7c227c2ee4d54f2c + languageName: node + linkType: hard + +"@uniswap/v4-periphery@npm:1.0.3": + version: 1.0.3 + resolution: "@uniswap/v4-periphery@npm:1.0.3" + checksum: 10c0/5cfcb9390870901949350b3aa614aa99896d440b1fc79d4c5582690bbb9c483c0707ae6817a115d12c40cfbb46087b220f61b57c068290a3329f5eb0884fea0c + languageName: node + linkType: hard + "abbrev@npm:1": version: 1.1.1 resolution: "abbrev@npm:1.1.1" @@ -7620,6 +7634,8 @@ __metadata: "@types/mocha": "npm:>=9.1.0" "@types/node": "npm:>=18.0.0" "@uniswap/v3-periphery": "npm:^1.4.4" + "@uniswap/v4-core": "npm:1.0.2" + "@uniswap/v4-periphery": "npm:1.0.3" axios: "npm:^1.13.2" chai: "npm:^4.2.0" dotenv: "npm:^16.4.5"