diff --git a/.gitignore b/.gitignore index 5ec3132..f2b3d80 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ brain/ # Twitter banner export artefacts (saved from x.com) web/x banner* *_files/ + +# Large demo video binaries (kept out of git) +*.mp4 diff --git a/README.md b/README.md index a1eab03..682dd6b 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,26 @@ --- -## What happens when you swap +## Why AgentFloat? -``` -1. LP adds liquidity to a v4 pool with AgentFloatHook attached -2. Price moves out of the LP's range → that capital is now idle -3. afterAddLiquidity → the hook parks the idle USDT into the vault → vault supplies it to Aave V3 (earning yield) -4. A swap comes in → beforeSwap → the hook recalls capital just-in-time so the pool can settle -5. In the background: an AI scores yield strategies and proposes better ones; an on-chain - consecutiveWins counter promotes a winner only after it proves itself — no AI can move - real capital on its own -``` +### The Problem: Idle Capital & Collapsed Depth +In concentrated liquidity pools (like Uniswap v4), Liquidity Providers (LPs) choose specific price ranges to deploy their assets. When the market price moves outside this range: +1. **Capital sits idle:** It stops earning trading fees. +2. **Depth collapses:** When a large position goes out of range, the pool's liquidity depth drops, and the effective spread widens. +3. **Arbitrageurs exploit the pool:** Arbitrage bots spot these widened spreads instantly and exploit the price differences before LPs can manually rebalance their positions. + +### The Solution: JIT (Just-In-Time) Recall Hook +AgentFloat solves this using a Uniswap v4 Hook that monitors and routes capital dynamically: +* **Idle Sweeps:** When a position goes out of range, the hook automatically sweeps the idle capital into the `FloatVault` to generate yield in active strategies like Aave V3. +* **Just-In-Time Recall:** The moment a swap starts, the hook's `beforeSwap` callback instantly pulls the capital back into the pool. +* **Spread Protection:** By recalling the capital in the exact transaction of the trade, the pool's full depth is restored. The spread stays tight, traders get perfect execution, and LPs capture yield while waiting. -The hook is the product. The AI is the optimization layer. The chain is the final authority on what touches money. +### The Guardrail: "The AI Thinks, the Chain Decides" +To optimize yield, an off-chain AI continuously simulates and proposes alternative strategies. But the AI has zero permission to move real capital: +* **On-Chain Gatekeeper:** A strategy must win consecutive performance checks against the active strategy directly on-chain. +* **Trustless Promotion:** Once a strategy proves itself, anyone can call `promote()` on-chain to migrate the pool's capital. The AI cannot bypass this rule. + +--- --- @@ -31,7 +38,7 @@ The hook is the product. The AI is the optimization layer. The chain is the fina 5. **Chain-scoped operating modes**: on testnet the AI can deploy new Solidity; on mainnet it's bounded to register/retire/scoring actions against a pre-audited library. 6. **Composable downstream**: a `FlapYieldTaxVaultFactory` lets any Flap-graduated token pipe its tax revenue into AgentFloat to earn Aave yield instead of sitting idle. -12/12 Forge tests passing. Real Aave V3 integration. ~$0.09 to deploy the entire system to X Layer mainnet. +13/13 Forge tests passing (incl. a strategy-drain regression test). Real Aave V3 integration. ~$0.09 to deploy the entire system to X Layer mainnet. --- @@ -42,7 +49,7 @@ The hook is the product. The AI is the optimization layer. The chain is the fina | **AgentFloatHook** | [`0x5Ba6671e8219C34edA373BF95895306929174580`](https://www.oklink.com/xlayer/address/0x5Ba6671e8219C34edA373BF95895306929174580) | 5,101 b · permission bits `0x580` | | **FloatVault** | [`0xbF06de108735332D1EDb81C7A77A750DD428a6f4`](https://www.oklink.com/xlayer/address/0xbF06de108735332D1EDb81C7A77A750DD428a6f4) | 5,115 b | | **FlapYieldTaxVaultFactory** | [`0x87D665B83557365ADf320a439B8a2DFD03c024F8`](https://www.oklink.com/xlayer/address/0x87D665B83557365ADf320a439B8a2DFD03c024F8) | 10,050 b | -| **AaveStrategy** (real Aave V3 USDT) | [`0x4C109f12d2FA55037439b73CE4E9Ee2C1e1656E1`](https://www.oklink.com/xlayer/address/0x4C109f12d2FA55037439b73CE4E9Ee2C1e1656E1) | 2,429 b | +| **AaveStrategy** (real Aave V3 USDT, `onlyVault`-guarded) | [`0xB433487F82572FF201A2455BF7a06325a7B8bFEa`](https://www.oklink.com/xlayer/address/0xB433487F82572FF201A2455BF7a06325a7B8bFEa) | 2,5xx b | | **IdleStrategy** (baseline) | [`0xf292e500459393F5CfaF8fbccFe1426bC3495EEb`](https://www.oklink.com/xlayer/address/0xf292e500459393F5CfaF8fbccFe1426bC3495EEb) | 939 b | Attached to canonical X Layer mainnet infrastructure (no PoolManager or Aave redeploy needed): @@ -56,7 +63,7 @@ Attached to canonical X Layer mainnet infrastructure (no PoolManager or Aave red Total deploy cost: **0.000372 OKB (~$0.09)** at 0.02 gwei. -**Live yield, verifiable now:** the AaveStrategy `0x4C109…` holds a real interest-bearing **aUSDT** position supplied to Aave V3 — capital that flowed through the vault and is earning lending yield block-by-block. Check `aUSDT.balanceOf(0x4C109f12d2FA55037439b73CE4E9Ee2C1e1656E1)` on chain 196. +**Live yield, verifiable now:** the AaveStrategy `0xB433…` holds a real interest-bearing **aUSDT** position supplied to Aave V3 — capital that flowed through the vault and is earning lending yield block-by-block. Check `aUSDT.balanceOf(0xB433487F82572FF201A2455BF7a06325a7B8bFEa)` on chain 196. (The prior strategy `0x4C109…` was superseded by this `onlyVault`-hardened build; funds were migrated trustlessly via the on-chain `promote()` path.) --- @@ -173,19 +180,20 @@ Other guardrails: `max_proposals_per_day`, `max_strategies_registered`, `pinned_ --- -## Test results — 12/12 passing +## Test results — 13/13 passing ``` -Ran 3 test suites: 12 tests passed, 0 failed, 0 skipped +Ran 3 test suites: 13 tests passed, 0 failed, 0 skipped -[PASS] test_Integration_Workflow() (gas: 693560) — full end-to-end path +[PASS] test_Integration_Workflow() (gas: 700264) — full end-to-end path [PASS] test_DecentralizedPromotion_Success() — trustless promote() works [PASS] test_DecentralizedPromotion_WinsResetOnLoss() — counter resets correctly -[PASS] test_ParkAndWithdraw() (gas: 267390) +[PASS] test_ParkAndWithdraw() (gas: 274304) +[PASS] test_Revert_DirectStrategyWithdraw() — strategy funds are onlyVault-gated [PASS] test_PostScore() (gas: 147575) -[PASS] test_Promote() (gas: 403565) -[PASS] test_RegisterStrategy() (gas: 181344) -[PASS] test_Revert_NonPromoterPromote() (gas: 187358) +[PASS] test_Promote() (gas: 410532) +[PASS] test_RegisterStrategy() (gas: 181338) +[PASS] test_Revert_NonPromoterPromote() (gas: 192474) [PASS] FlapYieldTaxVault — ERC20 path [PASS] FlapYieldTaxVault — native OKB → WOKB path [PASS] FlapYieldTaxVault — withdraw with accrued yield @@ -208,7 +216,7 @@ Ran 3 test suites: 12 tests passed, 0 failed, 0 skipped ```bash cd contracts ./setup.sh # installs Foundry deps + builds + runs tests -forge test # 12/12 should pass +forge test # 13/13 should pass ``` ### Mainnet deploy (~$0.09 total) @@ -261,6 +269,9 @@ Built against the Uniswap `v4-security-foundations` threat model. - **`validateHookAddress(this)` enforced in `BaseHook` constructor** — deployed hook address must encode correct permission bits in its low 14 bits; misdeployed hooks revert at construction. Our mainnet hook at `0x5Ba6671e8219C34edA373BF95895306929174580` encodes `0x580` (afterAddLiquidity + afterRemoveLiquidity + beforeSwap). - **`onlyPoolManager` modifier on every external callback** via the canonical `BaseHook` internal-callback pattern (we inlined the canonical source because the installed v4-periphery submodule doesn't ship `src/utils/BaseHook.sol`). +- **`onlyVault` on every strategy entry point** — `deposit()` and `withdraw()` on `AaveStrategy`/`IdleStrategy`/`MockYieldStrategy` revert unless called by their bound vault, so pooled funds can never be drained by a direct external call to a strategy. Covered by `test_Revert_DirectStrategyWithdraw`. +- **`ReentrancyGuard` on the vault** — `park()`, `withdraw()`, and `promote()` are `nonReentrant`. +- **`forceApprove` (zero-then-set)** everywhere the hook grants allowances, for USDT-style tokens that reject non-zero→non-zero `approve`. - **Trustless promotion gate** — `consecutiveWins` mapping lives on-chain, anyone can call `promote()` once threshold is met; promotion is not promoter-gated, scoring is. - **EIP-1153 transient storage** for tracking parked USDT within the swap transaction lifecycle, reducing storage gas on JIT recall. - **Owner-gated strategy registration + score posting** via OpenZeppelin `Ownable`. @@ -296,7 +307,7 @@ agentfloat-hook/ │ │ ├── MineHookSalt.s.sol standalone salt miner │ │ ├── DeployFlapFactory.s.sol Flap factory deploy │ │ └── XLayerMainnet.sol canonical external address constants -│ └── test/ 12 tests, all passing +│ └── test/ 13 tests, all passing │ ├── agent/ Off-chain agent (TypeScript) │ └── src/ diff --git a/agent/src/api.ts b/agent/src/api.ts index f34b8ee..ea9f3eb 100644 --- a/agent/src/api.ts +++ b/agent/src/api.ts @@ -116,11 +116,25 @@ function buildState() { testsTotal: 8, }, contracts: { - vault: process.env.VAULT_ADDRESS || '0x4d33FD7B077c1a23221252c3FFEe4261c8a67c5f', - hook: process.env.HOOK_ADDRESS || '0x3A00B5A2F15bE68AfE5415290ca4D3022e3B3b5F', + vault: + process.env.VAULT_ADDRESS || + (process.env.X_LAYER_CHAIN_ID === '196' + ? '0xbF06de108735332D1EDb81C7A77A750DD428a6f4' + : '0x4d33FD7B077c1a23221252c3FFEe4261c8a67c5f'), + hook: + process.env.HOOK_ADDRESS || + (process.env.X_LAYER_CHAIN_ID === '196' + ? '0x5Ba6671e8219C34edA373BF95895306929174580' + : '0x3A00B5A2F15bE68AfE5415290ca4D3022e3B3b5F'), poolManager: - process.env.POOL_MANAGER_ADDRESS || '0x1BB8824110DF8ED603eBb203C19cC2Ba8FdA8fbe', - explorerBase: 'https://www.oklink.com/xlayer-test', + process.env.POOL_MANAGER_ADDRESS || + (process.env.X_LAYER_CHAIN_ID === '196' + ? '0x360e68faccca8ca495c1b759fd9eee466db9fb32' + : '0x1BB8824110DF8ED603eBb203C19cC2Ba8FdA8fbe'), + explorerBase: + process.env.X_LAYER_CHAIN_ID === '196' + ? 'https://www.oklink.com/xlayer' + : 'https://www.oklink.com/xlayer-test', }, }; } diff --git a/agent/src/consolidator.ts b/agent/src/consolidator.ts index f654791..79eda6f 100644 --- a/agent/src/consolidator.ts +++ b/agent/src/consolidator.ts @@ -12,9 +12,17 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import * as dotenv from 'dotenv'; import { loadStrategySpecs, type StrategySpec } from './brain'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +dotenv.config(); + const BRAIN_PATH = process.env.BRAIN_PATH || path.join(os.homedir(), 'brain'); +const EXPLORER_BASE = process.env.X_LAYER_CHAIN_ID === '196' + ? 'https://www.oklink.com/xlayer' + : 'https://www.oklink.com/xlayer-test'; // ─── Types ──────────────────────────────────────────────────────────────── @@ -431,7 +439,7 @@ function renderHistory(opts: { lines.push('| Date | From | To | Δ Score (μbps) | Consecutive | Tx |'); lines.push('|------|------|----|----|----|----|'); for (const p of promotions) { - const txDisplay = p.txHash ? `[\`${p.txHash.slice(0, 10)}…\`](https://www.oklink.com/xlayer-test/tx/${p.txHash})` : '—'; + const txDisplay = p.txHash ? `[\`${p.txHash.slice(0, 10)}…\`](${EXPLORER_BASE}/tx/${p.txHash})` : '—'; lines.push( `| ${p.date} | [${p.fromStrategyId}] ${p.fromStrategyName} | [${p.toStrategyId}] ${p.toStrategyName} | ${p.scoreDelta} | ${p.consecutiveEpochs} | ${txDisplay} |`, ); diff --git a/agent/src/watcher.ts b/agent/src/watcher.ts index c4998b1..232f11d 100644 --- a/agent/src/watcher.ts +++ b/agent/src/watcher.ts @@ -10,7 +10,8 @@ import { syncFromBrain } from './store'; import { runDeployer } from './deployer'; export async function startWatcher() { - console.log(`[Watcher] Connecting to X Layer testnet at ${CONFIG.rpcUrl}...`); + const isMainnet = CONFIG.chainId === 196; + console.log(`[Watcher] Connecting to X Layer ${isMainnet ? 'mainnet' : 'testnet'} at ${CONFIG.rpcUrl}...`); const publicClient = createPublicClient({ chain: xLayerTestnetChain, diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index f909933..1289178 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -43,7 +43,7 @@ contract DeployScript is Script { console.log("Deployed FloatVault at:", address(vault)); // 4. Deploy Strategies - IdleStrategy idle = new IdleStrategy(usdcAddr); + IdleStrategy idle = new IdleStrategy(usdcAddr, address(vault)); console.log("Deployed IdleStrategy at:", address(idle)); // 5. Register strategies in FloatVault @@ -54,9 +54,10 @@ contract DeployScript is Script { // Aave Pool on X Layer: 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116 // aUSDT0 token on X Layer: 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297 AaveStrategy aaveStrat = new AaveStrategy( - usdcAddr, - 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116, - 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297 + usdcAddr, + 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116, + 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297, + address(vault) ); console.log("Deployed AaveStrategy at:", address(aaveStrat)); vault.registerStrategy(address(aaveStrat), true); // shadow (id = 2) diff --git a/contracts/script/DeployMainnet.s.sol b/contracts/script/DeployMainnet.s.sol index 0887e41..60a9551 100644 --- a/contracts/script/DeployMainnet.s.sol +++ b/contracts/script/DeployMainnet.s.sol @@ -47,14 +47,15 @@ contract DeployMainnet is Script { console.log("FloatVault :", address(vault)); // 2. IdleStrategy (baseline floor -holds USDT, no yield) - IdleStrategy idle = new IdleStrategy(XLayerMainnet.USDT); + IdleStrategy idle = new IdleStrategy(XLayerMainnet.USDT, address(vault)); console.log("IdleStrategy:", address(idle)); // 3. AaveStrategy (real Aave V3 USDT lending market) AaveStrategy aave = new AaveStrategy( XLayerMainnet.USDT, XLayerMainnet.AAVE_POOL, - XLayerMainnet.AUSDT + XLayerMainnet.AUSDT, + address(vault) ); console.log("AaveStrategy:", address(aave)); diff --git a/contracts/src/AgentFloatHook.sol b/contracts/src/AgentFloatHook.sol index 793e001..e9d121f 100644 --- a/contracts/src/AgentFloatHook.sol +++ b/contracts/src/AgentFloatHook.sol @@ -87,7 +87,8 @@ contract AgentFloatHook is BaseHook { if (usdc.allowance(lpProvider, address(this)) >= idleAmount) { usdc.safeTransferFrom(lpProvider, address(this), idleAmount); - usdc.approve(address(vault), idleAmount); + // Zero-then-set to stay compatible with USDT-style tokens + usdc.forceApprove(address(vault), idleAmount); vault.park(idleAmount); // Track deposit @@ -111,20 +112,34 @@ contract AgentFloatHook is BaseHook { ModifyLiquidityParams calldata, BalanceDelta delta, BalanceDelta, - bytes calldata + bytes calldata hookData ) internal override returns (bytes4, BalanceDelta) { - uint256 userDep = userDeposits[sender]; + // Resolve the LP identity symmetrically with _afterAddLiquidity: hookData may + // carry the true LP (when liquidity is routed), otherwise default to sender. + address lpProvider = sender; + if (hookData.length >= 64) { + (lpProvider,) = abi.decode(hookData, (address, uint256)); + } + + uint256 userDep = userDeposits[lpProvider]; if (userDep > 0) { - userDeposits[sender] = 0; - - // Check if the funds are in the vault or already recalled to the hook balance + userDeposits[lpProvider] = 0; + + // Pull whatever is still parked under this hook back from the vault. + // Funds may already have been JIT-recalled into the hook's own balance, + // in which case vault.deposits is short and we top up from that balance. uint256 vaultBal = vault.deposits(address(this)); - if (vaultBal >= userDep) { - vault.withdraw(userDep); + uint256 toWithdraw = vaultBal >= userDep ? userDep : vaultBal; + if (toWithdraw > 0) { + vault.withdraw(toWithdraw); + } + + // Refund the LP, capped at the USDC the hook actually holds (no overdraw). + uint256 hookBal = usdc.balanceOf(address(this)); + uint256 refund = userDep <= hookBal ? userDep : hookBal; + if (refund > 0) { + usdc.safeTransfer(lpProvider, refund); } - - // Return USDC back to the LP provider (sender) - usdc.safeTransfer(sender, userDep); } return (BaseHook.afterRemoveLiquidity.selector, delta); } @@ -165,7 +180,7 @@ contract AgentFloatHook is BaseHook { uint256 persistentDeposits = vault.deposits(address(this)); if (persistentDeposits >= recallAmount) { vault.withdraw(recallAmount); - usdc.approve(address(poolManager), recallAmount); + usdc.forceApprove(address(poolManager), recallAmount); } } diff --git a/contracts/src/FloatVault.sol b/contracts/src/FloatVault.sol index c42b79f..17a8e48 100644 --- a/contracts/src/FloatVault.sol +++ b/contracts/src/FloatVault.sol @@ -4,9 +4,10 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "./interfaces/IStrategy.sol"; -contract FloatVault is Ownable { +contract FloatVault is Ownable, ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public immutable usdc; @@ -66,7 +67,7 @@ contract FloatVault is Ownable { } // Agent parks idle USDC into the vault - function park(uint256 amount) external { + function park(uint256 amount) external nonReentrant { require(amount > 0, "Amount must be > 0"); usdc.safeTransferFrom(msg.sender, address(this), amount); deposits[msg.sender] += amount; @@ -90,7 +91,7 @@ contract FloatVault is Ownable { } // Agent withdraws USDC from the vault instantly - function withdraw(uint256 amount) external { + function withdraw(uint256 amount) external nonReentrant { require(amount > 0, "Amount must be > 0"); require(deposits[msg.sender] >= amount, "Insufficient deposit"); @@ -167,7 +168,7 @@ contract FloatVault is Ownable { } // Promote shadow strategy to active - function promote(uint256 strategyId) external { + function promote(uint256 strategyId) external nonReentrant { require(strategyId <= strategyCount && strategyId != 0, "Invalid strategy ID"); StrategyEntry storage newActive = strategies[strategyId]; require(newActive.isShadow, "Strategy must be shadow"); diff --git a/contracts/src/strategies/AaveStrategy.sol b/contracts/src/strategies/AaveStrategy.sol index 83742ba..5846be1 100644 --- a/contracts/src/strategies/AaveStrategy.sol +++ b/contracts/src/strategies/AaveStrategy.sol @@ -39,18 +39,26 @@ contract AaveStrategy is IStrategy { IERC20 public immutable underlying; IAavePool public immutable pool; IERC20 public immutable aToken; + address public immutable vault; string public constant STRATEGY_NAME = "Aave V3 Yield Strategy"; uint256 public storedBalance; uint256 public lastLiquidityIndex; - constructor(address _underlying, address _pool, address _aToken) { + modifier onlyVault() { + require(msg.sender == vault, "Only vault"); + _; + } + + constructor(address _underlying, address _pool, address _aToken, address _vault) { require(_underlying != address(0), "Zero address underlying"); require(_pool != address(0), "Zero address pool"); require(_aToken != address(0), "Zero address aToken"); + require(_vault != address(0), "Zero address vault"); underlying = IERC20(_underlying); pool = IAavePool(_pool); aToken = IERC20(_aToken); + vault = _vault; lastLiquidityIndex = getAaveLiquidityIndex(); } @@ -59,7 +67,7 @@ contract AaveStrategy is IStrategy { return uint256(data.liquidityIndex); } - function deposit(uint256 amount) external override { + function deposit(uint256 amount) external override onlyVault { // Update simulated balance before receiving new funds storedBalance = currentValue(); lastLiquidityIndex = getAaveLiquidityIndex(); @@ -74,7 +82,7 @@ contract AaveStrategy is IStrategy { storedBalance += amount; } - function withdraw(uint256 amount) external override returns (uint256 actualOut) { + function withdraw(uint256 amount) external override onlyVault returns (uint256 actualOut) { storedBalance = currentValue(); lastLiquidityIndex = getAaveLiquidityIndex(); diff --git a/contracts/src/strategies/IdleStrategy.sol b/contracts/src/strategies/IdleStrategy.sol index e207bc3..e9d2a73 100644 --- a/contracts/src/strategies/IdleStrategy.sol +++ b/contracts/src/strategies/IdleStrategy.sol @@ -9,18 +9,26 @@ contract IdleStrategy is IStrategy { using SafeERC20 for IERC20; IERC20 public immutable underlying; + address public immutable vault; string public constant STRATEGY_NAME = "Idle USDC Strategy"; - constructor(address _underlying) { + modifier onlyVault() { + require(msg.sender == vault, "Only vault"); + _; + } + + constructor(address _underlying, address _vault) { require(_underlying != address(0), "Zero address"); + require(_vault != address(0), "Zero address vault"); underlying = IERC20(_underlying); + vault = _vault; } - function deposit(uint256 amount) external override { + function deposit(uint256 amount) external override onlyVault { underlying.safeTransferFrom(msg.sender, address(this), amount); } - function withdraw(uint256 amount) external override returns (uint256 actualOut) { + function withdraw(uint256 amount) external override onlyVault returns (uint256 actualOut) { underlying.safeTransfer(msg.sender, amount); return amount; } diff --git a/contracts/src/strategies/MockYieldStrategy.sol b/contracts/src/strategies/MockYieldStrategy.sol index e378b85..d7f6b82 100644 --- a/contracts/src/strategies/MockYieldStrategy.sol +++ b/contracts/src/strategies/MockYieldStrategy.sol @@ -20,6 +20,11 @@ contract MockYieldStrategy is IStrategy { uint256 public bpsPerBlock; // 1 bps = 100 (using 6 decimals for precision, e.g. 1000 = 0.1% per block) uint256 public storedBalance; + modifier onlyVault() { + require(msg.sender == vault, "Only vault"); + _; + } + constructor(address _underlying, address _vault, uint256 _bpsPerBlock) { require(_underlying != address(0), "Zero address underlying"); require(_vault != address(0), "Zero address vault"); @@ -46,7 +51,7 @@ contract MockYieldStrategy is IStrategy { return baseAmount + yield; } - function deposit(uint256 amount) external override { + function deposit(uint256 amount) external override onlyVault { storedBalance = _accrueYield(); lastUpdateBlock = block.number; @@ -54,7 +59,7 @@ contract MockYieldStrategy is IStrategy { storedBalance += amount; } - function withdraw(uint256 amount) external override returns (uint256 actualOut) { + function withdraw(uint256 amount) external override onlyVault returns (uint256 actualOut) { storedBalance = _accrueYield(); lastUpdateBlock = block.number; diff --git a/contracts/test/AgentFloatHook.t.sol b/contracts/test/AgentFloatHook.t.sol index be5a799..d8d11ef 100644 --- a/contracts/test/AgentFloatHook.t.sol +++ b/contracts/test/AgentFloatHook.t.sol @@ -34,7 +34,7 @@ contract AgentFloatHookTest is Test, Deployers { vault = new FloatVault(address(mockUsdc)); // 3. Deploy IdleStrategy and register it in the Vault - idleStrategy = new IdleStrategy(address(mockUsdc)); + idleStrategy = new IdleStrategy(address(mockUsdc), address(vault)); vault.registerStrategy(address(idleStrategy), false); // Active strategy // 4. Determine target address with correct permission flags encoded in low 14 bits diff --git a/contracts/test/FlapYieldTaxVault.t.sol b/contracts/test/FlapYieldTaxVault.t.sol index d9de886..946dec9 100644 --- a/contracts/test/FlapYieldTaxVault.t.sol +++ b/contracts/test/FlapYieldTaxVault.t.sol @@ -23,7 +23,7 @@ contract FlapYieldTaxVaultTest is Test { mockUsdc = new MockUSDC(); floatVault = new FloatVault(address(mockUsdc)); - idle = new IdleStrategy(address(mockUsdc)); + idle = new IdleStrategy(address(mockUsdc), address(floatVault)); floatVault.registerStrategy(address(idle), false); // Register idle as active (ID 1) factory = new FlapYieldTaxVaultFactory(address(floatVault), guardian); diff --git a/contracts/test/FloatVault.t.sol b/contracts/test/FloatVault.t.sol index 9f5ef2b..1c0367c 100644 --- a/contracts/test/FloatVault.t.sol +++ b/contracts/test/FloatVault.t.sol @@ -23,7 +23,7 @@ contract FloatVaultTest is Test { usdc = new MockUSDC(); vault = new FloatVault(address(usdc)); - idleStrategy = new IdleStrategy(address(usdc)); + idleStrategy = new IdleStrategy(address(usdc), address(vault)); yieldStrategy = new MockYieldStrategy(address(usdc), address(vault), 1000); // 1000 = 0.1% per block vault.setPromoter(promoter); @@ -77,6 +77,32 @@ contract FloatVaultTest is Test { vm.stopPrank(); } + function test_Revert_DirectStrategyWithdraw() public { + // Fund the active strategy via the vault + vm.startPrank(owner); + vault.registerStrategy(address(idleStrategy), false); + vm.stopPrank(); + + vm.startPrank(user); + usdc.approve(address(vault), 500 * 10**6); + vault.park(500 * 10**6); + vm.stopPrank(); + + // An attacker must NOT be able to drain the strategy directly, bypassing the vault. + address attacker = address(0xBAD); + vm.prank(attacker); + vm.expectRevert(bytes("Only vault")); + idleStrategy.withdraw(500 * 10**6); + + // Direct deposit is also gated. + vm.prank(attacker); + vm.expectRevert(bytes("Only vault")); + idleStrategy.deposit(1); + + // Funds remain intact in the strategy. + assertEq(usdc.balanceOf(address(idleStrategy)), 500 * 10**6); + } + function test_PostScore() public { vm.startPrank(owner); vault.registerStrategy(address(idleStrategy), false); diff --git a/docs/pitch-deck.md b/docs/pitch-deck.md index 6ddaf35..ec8fc54 100644 --- a/docs/pitch-deck.md +++ b/docs/pitch-deck.md @@ -319,9 +319,11 @@ section::after { --- -## Concentrated liquidity can go quiet. +## Out-of-range capital collapses pool depth. -When a v4 LP position drifts out of range, that capital stops earning trading fees. AgentFloat treats the idle interval as yield inventory, then recalls it when swaps need liquidity again. +When a concentrated position drifts out of range, depth collapses and the pool's effective spread widens—allowing arbitrageurs to exploit it before rebalancers can act. + +AgentFloat sweeps this idle capital to earn yield, but recalls it just-in-time when swaps occur, instantly restoring depth and tight spreads.