Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 34 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

---

Expand All @@ -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.

---

Expand All @@ -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):
Expand All @@ -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.)

---

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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/
Expand Down
22 changes: 18 additions & 4 deletions agent/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
}
Expand Down
10 changes: 9 additions & 1 deletion agent/src/consolidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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} |`,
);
Expand Down
3 changes: 2 additions & 1 deletion agent/src/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions contracts/script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions contracts/script/DeployMainnet.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
39 changes: 27 additions & 12 deletions contracts/src/AgentFloatHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
9 changes: 5 additions & 4 deletions contracts/src/FloatVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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");

Expand Down Expand Up @@ -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");
Expand Down
Loading