diff --git a/.env.example b/.env.example index 7dfe750..c73d4a4 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,24 @@ +# ─── TESTNET (current default) ──────────────────────────────────────── X_LAYER_RPC_URL=https://testrpc.xlayer.tech/terigon X_LAYER_CHAIN_ID=1952 + +# ─── MAINNET (for production deployment) ────────────────────────────── +# Switch by setting these and reusing X_LAYER_RPC_URL/CHAIN_ID variables +# X_LAYER_RPC_URL=https://rpc.xlayer.tech +# X_LAYER_CHAIN_ID=196 +# Canonical mainnet infra (no need to redeploy): +# PoolManager = 0x360E68faCCca8cA495c1B759Fd9EEe466dB9FB32 +# Aave Pool = 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116 +# USDT = 0x779Ded0c9e1022225f8E0630b35a9b54bE713736 +# aUSDT = 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297 +# After mainnet deploy, fill in: +# VAULT_ADDRESS= +# HOOK_ADDRESS= +# HOOK_SALT= + +# Address that will be set as the promoter on FloatVault (optional, defaults to DEPLOYER) +PROMOTER_ADDRESS= +DEPLOYER_ADDRESS= DEPLOYER_PRIVATE_KEY= PROMOTER_PRIVATE_KEY= USDC_ADDRESS= diff --git a/agent/src/approver.ts b/agent/src/approver.ts index 2d6c154..f85432c 100644 --- a/agent/src/approver.ts +++ b/agent/src/approver.ts @@ -77,6 +77,16 @@ function decide(rec: ProposalRecord, mode: OperatingMode, ctx: { todayCount: num // ── Hard guardrails (apply in every mode except `watch`) ───────────── if (mode.mode !== 'watch') { + // Chain-scoped allowlist — mainnet bounds the AI to register-from-library actions + const chainId = process.env.X_LAYER_CHAIN_ID || '0'; + const allowedForChain = mode.chain_actions_allowed?.[chainId]; + if (allowedForChain && allowedForChain.length > 0 && !allowedForChain.includes(action)) { + return { + newStatus: 'rejected_by_rule', + reason: `action_type "${action}" not in chain_actions_allowed for chain ${chainId} (mainnet bounds AI to library register/retire/scoring)`, + }; + } + if (mode.blocked_action_types.includes(action)) { return { newStatus: 'rejected_by_rule', reason: `action_type "${action}" is in blocked_action_types` }; } diff --git a/agent/src/brain.ts b/agent/src/brain.ts index b59c1b4..52d1f0e 100644 --- a/agent/src/brain.ts +++ b/agent/src/brain.ts @@ -88,8 +88,8 @@ export function loadStrategySpecs(): StrategySpec[] { const parsed = matter(raw); const data = parsed.data; - // Skip retired strategies - if (data.status === 'retired') continue; + // Do not skip retired strategies, so they are kept in sync in the SQLite DB + // and displayed correctly on the dashboard. // Validate required fields if (typeof data.strategy_id !== 'number' || !data.contract_address) { @@ -167,6 +167,15 @@ export interface OperatingMode { blocked_action_types: string[]; require_security_audit_for: string[]; veto_window_hours: Record; + /** + * Chain-scoped allowlist. The agent reads `process.env.X_LAYER_CHAIN_ID` and + * applies the matching entry. Action types not listed are blocked on that chain. + * + * The intent: on mainnet, the AI can register/un-register strategies from a + * pre-audited library, but cannot deploy new Solidity. New strategy contracts + * require human signoff + audit before being added to the library. + */ + chain_actions_allowed: Record; sourceFile: string; } @@ -181,6 +190,13 @@ const DEFAULT_MODE: Omit = { blocked_action_types: [], require_security_audit_for: ['new_strategy'], veto_window_hours: {}, + // chain "0" or absent = no chain-scoped restriction (testnet default permissive) + chain_actions_allowed: { + // X Layer mainnet (chain 196) — only safe actions on real money + '196': ['register_strategy', 'retire', 'scoring_change', 'no_action'], + // X Layer testnet (chain 1952) — everything goes for experimentation + '1952': ['parameter_variant', 'new_strategy', 'scoring_change', 'retire', 'no_action', 'register_strategy'], + }, }; /** diff --git a/agent/src/consolidator.ts b/agent/src/consolidator.ts index 7f9c4e0..f654791 100644 --- a/agent/src/consolidator.ts +++ b/agent/src/consolidator.ts @@ -168,7 +168,7 @@ function computeStats(entries: ScoreEntry[], specs: StrategySpec[]): StrategySta stats.push({ spec: specsById.get(strategyId), strategyId, - strategyName: strategyEntries[0].strategyName, + strategyName: specsById.get(strategyId)?.name || strategyEntries[strategyEntries.length - 1].strategyName, totalObservations: strategyEntries.length, firstSeen: strategyEntries[0].timestamp, lastSeen: strategyEntries[strategyEntries.length - 1].timestamp, diff --git a/agent/src/deployer.ts b/agent/src/deployer.ts index 8e4f5f3..da1ce32 100644 --- a/agent/src/deployer.ts +++ b/agent/src/deployer.ts @@ -157,13 +157,16 @@ export async function runDeployer(): Promise { const specFilename = `mock-yield-strategy-v${strategyId}.md`; const specPath = path.join(SPECS_DIR, specFilename); const expectedApyBps = Number(bpsPerBlock) / 2; // approximation for expected apy bps based on rate + const isMainnet = CONFIG.chainId === 196; + const networkName = isMainnet ? 'x-layer-mainnet' : 'x-layer-testnet'; + const explorerUrl = xLayerTestnetChain.blockExplorers.default.url; const specContent = `--- name: agentfloat-strategy-mock-yield-v${strategyId} strategy_id: ${strategyId} contract_address: "${newStrategyAddress}" -network: x-layer-testnet -chain_id: 1952 +network: ${networkName} +chain_id: ${CONFIG.chainId} status: shadow is_shadow: true expected_apy_bps: ${expectedApyBps} @@ -179,7 +182,7 @@ Parameter variant deployed autonomously by the AgentFloat deploy loop. ## Parameters - **bpsPerBlock**: ${bpsPerBlock} (contract scale) - **Source Proposal**: [${filename}](./proposals/${filename}) -- **Tx Hash**: [${deployHash}](https://www.oklink.com/xlayer-test/tx/${deployHash}) +- **Tx Hash**: [${deployHash}](${explorerUrl}/tx/${deployHash}) `; fs.writeFileSync(specPath, specContent); console.log(`[Deployer] Wrote strategy spec to ${specPath}`); @@ -203,9 +206,9 @@ Parameter variant deployed autonomously by the AgentFloat deploy loop. - **Type:** parameter_variant - **Strategy ID:** ${strategyId} - **Strategy Name:** Mock Yield Strategy (v${strategyId}) -- **Deployed Address:** [${newStrategyAddress}](https://www.oklink.com/xlayer-test/address/${newStrategyAddress}) +- **Deployed Address:** [${newStrategyAddress}](${explorerUrl}/address/${newStrategyAddress}) - **Constructor Arg (bpsPerBlock):** ${bpsPerBlock} -- **Tx:** [${deployHash}](https://www.oklink.com/xlayer-test/tx/${deployHash}) +- **Tx:** [${deployHash}](${explorerUrl}/tx/${deployHash}) - **Time:** ${new Date().toISOString()} `; fs.appendFileSync(journalPath, journalEntry); diff --git a/agent/src/scorer.ts b/agent/src/scorer.ts index 823c60e..6fd8612 100644 --- a/agent/src/scorer.ts +++ b/agent/src/scorer.ts @@ -16,10 +16,11 @@ export interface StrategyResult { score: bigint; } -// Custom X Layer testnet chain definition +// Custom X Layer chain definition (dynamically configured via env) +const isMainnet = CONFIG.chainId === 196; export const xLayerTestnetChain = { - id: 1952, - name: 'X Layer Testnet', + id: CONFIG.chainId, + name: isMainnet ? 'X Layer Mainnet' : 'X Layer Testnet', nativeCurrency: { decimals: 18, name: 'OKB', @@ -30,7 +31,10 @@ export const xLayerTestnetChain = { public: { http: [CONFIG.rpcUrl] }, }, blockExplorers: { - default: { name: 'OKLink', url: 'https://www.oklink.com/xlayer-test' }, + default: { + name: 'OKLink', + url: isMainnet ? 'https://www.oklink.com/xlayer' : 'https://www.oklink.com/xlayer-test' + }, }, }; diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index c1cc582..f909933 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -6,6 +6,7 @@ import "../src/FloatVault.sol"; import "../src/AgentFloatHook.sol"; import "../src/strategies/IdleStrategy.sol"; import "../src/strategies/MockYieldStrategy.sol"; +import "../src/strategies/AaveStrategy.sol"; import "../test/mocks/MockUSDC.sol"; import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {PoolManager} from "v4-core/src/PoolManager.sol"; @@ -45,17 +46,35 @@ contract DeployScript is Script { IdleStrategy idle = new IdleStrategy(usdcAddr); console.log("Deployed IdleStrategy at:", address(idle)); - MockYieldStrategy yieldStrat = new MockYieldStrategy(usdcAddr, address(vault), 1000); // 10 bps per block - console.log("Deployed MockYieldStrategy at:", address(yieldStrat)); - // 5. Register strategies in FloatVault vault.registerStrategy(address(idle), false); // active (id = 1) - vault.registerStrategy(address(yieldStrat), true); // shadow (id = 2) + + if (block.chainid == 196) { + // Deploy AaveStrategy + // Aave Pool on X Layer: 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116 + // aUSDT0 token on X Layer: 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297 + AaveStrategy aaveStrat = new AaveStrategy( + usdcAddr, + 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116, + 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297 + ); + console.log("Deployed AaveStrategy at:", address(aaveStrat)); + vault.registerStrategy(address(aaveStrat), true); // shadow (id = 2) + + // Deploy MockYieldStrategy as secondary shadow + MockYieldStrategy mockStrat = new MockYieldStrategy(usdcAddr, address(vault), 200); // 2 bps per block + console.log("Deployed MockYieldStrategy at:", address(mockStrat)); + vault.registerStrategy(address(mockStrat), true); // shadow (id = 3) + } else { + MockYieldStrategy yieldStrat = new MockYieldStrategy(usdcAddr, address(vault), 1000); // 10 bps per block + console.log("Deployed MockYieldStrategy at:", address(yieldStrat)); + vault.registerStrategy(address(yieldStrat), true); // shadow (id = 2) + } console.log("Registered strategies in FloatVault"); // 6. Mine hook salt & deploy AgentFloatHook bytes32 salt = mineHookSalt( - deployer, + 0x4e59b44847b379578588920cA78FbF26c0B4956C, poolManagerAddr, address(vault) ); diff --git a/contracts/script/DeployFlapFactory.s.sol b/contracts/script/DeployFlapFactory.s.sol new file mode 100644 index 0000000..cd0d958 --- /dev/null +++ b/contracts/script/DeployFlapFactory.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../src/flap/FlapYieldTaxVaultFactory.sol"; + +contract DeployFlapFactoryScript is Script { + function run() public { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + address floatVault = vm.envOr("VAULT_ADDRESS", address(0)); + require(floatVault != address(0), "Zero vault address"); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy FlapYieldTaxVaultFactory using the deployer address as the guardian backup + FlapYieldTaxVaultFactory factory = new FlapYieldTaxVaultFactory( + floatVault, + deployer + ); + + console.log("Deployed FlapYieldTaxVaultFactory at:", address(factory)); + + vm.stopBroadcast(); + } +} diff --git a/contracts/script/DeployMainnet.s.sol b/contracts/script/DeployMainnet.s.sol new file mode 100644 index 0000000..0887e41 --- /dev/null +++ b/contracts/script/DeployMainnet.s.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import {FloatVault} from "../src/FloatVault.sol"; +import {AgentFloatHook} from "../src/AgentFloatHook.sol"; +import {IdleStrategy} from "../src/strategies/IdleStrategy.sol"; +import {AaveStrategy} from "../src/strategies/AaveStrategy.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {XLayerMainnet} from "./XLayerMainnet.sol"; + +/// @title Deploy to X Layer mainnet +/// @notice Attaches AgentFloat to the canonical Uniswap v4 PoolManager on X Layer. +/// Uses real USDT (the asset Aave reserves on X Layer) and the live Aave V3 Pool. +/// Mines a hook salt offline first -set HOOK_SALT in env before running. +/// +/// One-shot deploy: +/// forge script script/DeployMainnet.s.sol --rpc-url $X_LAYER_MAINNET_RPC \ +/// --broadcast --legacy --priority-gas-price 50000000 +/// +/// Expected cost: ~8M gas at ~0.1 gwei ≈ 0.0008 OKB ≈ $0.20. +contract DeployMainnet is Script { + function run() public { + require(block.chainid == XLayerMainnet.CHAIN_ID, "Wrong chain -this script targets X Layer mainnet (196)"); + + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address promoter = vm.envOr("PROMOTER_ADDRESS", deployer); + bytes32 hookSalt = bytes32(vm.envBytes32("HOOK_SALT")); + + console.log("=== AgentFloat - X Layer Mainnet Deploy ==="); + console.log("Deployer :", deployer); + console.log("Promoter :", promoter); + console.log("PoolManager :", XLayerMainnet.POOL_MANAGER); + console.log("USDT :", XLayerMainnet.USDT); + console.log("Aave Pool :", XLayerMainnet.AAVE_POOL); + console.log("---"); + + vm.startBroadcast(deployerPrivateKey); + + // 1. FloatVault (vault holds USDT, distributes to strategies) + FloatVault vault = new FloatVault(XLayerMainnet.USDT); + if (promoter != deployer) { + vault.setPromoter(promoter); + } + console.log("FloatVault :", address(vault)); + + // 2. IdleStrategy (baseline floor -holds USDT, no yield) + IdleStrategy idle = new IdleStrategy(XLayerMainnet.USDT); + console.log("IdleStrategy:", address(idle)); + + // 3. AaveStrategy (real Aave V3 USDT lending market) + AaveStrategy aave = new AaveStrategy( + XLayerMainnet.USDT, + XLayerMainnet.AAVE_POOL, + XLayerMainnet.AUSDT + ); + console.log("AaveStrategy:", address(aave)); + + // 4. AgentFloatHook -deployed via CREATE2 at the mined salt + // The salt must encode permission flags (afterAddLiquidity + afterRemoveLiquidity + beforeSwap) + AgentFloatHook hook = new AgentFloatHook{salt: hookSalt}( + IPoolManager(XLayerMainnet.POOL_MANAGER), + vault + ); + console.log("Hook :", address(hook)); + + // 5. Register strategies on the vault. + // Idle starts active (floor); Aave starts as shadow until it earns promotion. + vault.registerStrategy(address(idle), false); // false = NOT shadow → becomes active + vault.registerStrategy(address(aave), true); // true = shadow + console.log("Strategies registered"); + + vm.stopBroadcast(); + + console.log(""); + console.log("=== Done ==="); + console.log("Add to ~/brain/skills/agentfloat-strategies/.md"); + console.log("Update agent/.env with mainnet addresses + X_LAYER_CHAIN_ID=196"); + console.log("Restart agent; chain_actions_allowed[196] bounds AI to register/retire/scoring"); + } +} diff --git a/contracts/script/MineHookSalt.s.sol b/contracts/script/MineHookSalt.s.sol new file mode 100644 index 0000000..ef5c940 --- /dev/null +++ b/contracts/script/MineHookSalt.s.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import {AgentFloatHook} from "../src/AgentFloatHook.sol"; +import {FloatVault} from "../src/FloatVault.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {XLayerMainnet} from "./XLayerMainnet.sol"; + +/// @title Mine a CREATE2 salt for AgentFloatHook +/// @notice Run this BEFORE DeployMainnet — outputs HOOK_SALT to set in env. +/// +/// forge script script/MineHookSalt.s.sol --rpc-url $X_LAYER_MAINNET_RPC +/// +/// Outputs the salt and predicted hook address. Set HOOK_SALT in .env before running DeployMainnet. +contract MineHookSalt is Script { + uint160 constant REQUIRED_FLAGS = + uint160(Hooks.AFTER_ADD_LIQUIDITY_FLAG) + | uint160(Hooks.AFTER_REMOVE_LIQUIDITY_FLAG) + | uint160(Hooks.BEFORE_SWAP_FLAG); + uint160 constant FLAG_MASK = uint160((1 << 14) - 1); + + function run() public view { + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + + // The FloatVault address will be deterministic from the deployer's nonce on mainnet. + // For simulation here, we assume the FloatVault has been deployed at the address + // computed from the deployer's nonce at the time of the actual mainnet broadcast. + // To make this practical, deploy in two transactions OR mine after FloatVault is known. + + address mockVault = vm.envOr("VAULT_ADDRESS", address(0)); + require(mockVault != address(0), "Set VAULT_ADDRESS in env (deploy FloatVault first OR pass predicted address)"); + + bytes memory creationCode = abi.encodePacked( + type(AgentFloatHook).creationCode, + abi.encode(XLayerMainnet.POOL_MANAGER, mockVault) + ); + + for (uint256 i = 0; i < 1_000_000; i++) { + bytes32 salt = bytes32(i); + address predicted = computeCreate2Address(deployer, salt, keccak256(creationCode)); + if ((uint160(predicted) & FLAG_MASK) == REQUIRED_FLAGS) { + console.log("Found valid salt after", i, "attempts"); + console.logBytes32(salt); + console.log("Predicted hook address:", predicted); + return; + } + } + revert("No valid salt found in 1M attempts; widen search"); + } + + function computeCreate2Address(address deployer, bytes32 salt, bytes32 codeHash) internal pure returns (address) { + bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, codeHash)); + return address(uint160(uint256(hash))); + } +} diff --git a/contracts/script/XLayerMainnet.sol b/contracts/script/XLayerMainnet.sol new file mode 100644 index 0000000..7ec6d81 --- /dev/null +++ b/contracts/script/XLayerMainnet.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title X Layer Mainnet -- canonical external contract addresses +/// @notice Sourced 2026-05-27 from Aave Address Book (bgd-labs) and Uniswap v4 docs. +/// All addresses are EIP-55 checksummed. +library XLayerMainnet { + // Chain + uint256 internal constant CHAIN_ID = 196; + + // Uniswap v4 (canonical, do not redeploy) + address internal constant POOL_MANAGER = 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; + address internal constant POSITION_MANAGER = 0xcF1EAFC6928dC385A342E7C6491d371d2871458b; + + // Aave V3 (live on X Layer mainnet) + address internal constant AAVE_POOL = 0xE3F3Caefdd7180F884c01E57f65Df979Af84f116; + address internal constant AAVE_POOL_ADDRESSES_PROVIDER = 0xdFf435BCcf782f11187D3a4454d96702eD78e092; + address internal constant AAVE_DATA_PROVIDER = 0x6C505C31714f14e8af2A03633EB2Cdfb4959138F; + address internal constant AAVE_ORACLE = 0x91FC11136d5615575a0fC5981Ab5C0C54418E2C6; + + // USDT on X Layer (the canonical stable; Aave reserves this) + address internal constant USDT = 0x779Ded0c9e1022225f8E0630b35a9b54bE713736; + address internal constant AUSDT = 0xF356ae412dB5df43BD3a10746f7ad4e1C4De4297; +} diff --git a/contracts/src/AgentFloatHook.sol b/contracts/src/AgentFloatHook.sol index c176c05..793e001 100644 --- a/contracts/src/AgentFloatHook.sol +++ b/contracts/src/AgentFloatHook.sol @@ -27,6 +27,9 @@ contract AgentFloatHook is BaseHook { FloatVault public immutable vault; IERC20 public immutable usdc; + // Track user deposits to prevent capital lockup + mapping(address => uint256) public userDeposits; + // EIP-1153 Transient storage slot for tracking transiently parked USDC within the transaction lifecycle bytes32 private constant TRANSIENT_PARKED_USDC_SLOT = keccak256("agentfloat.transient.parked.usdc"); @@ -72,18 +75,24 @@ contract AgentFloatHook is BaseHook { // If current tick is out of LP range, capital is idle if (currentTick < params.tickLower || currentTick > params.tickUpper) { + address lpProvider = sender; uint256 idleAmount = 100 * 10 ** 6; // default mock amount - if (hookData.length >= 32) { + if (hookData.length >= 64) { + (lpProvider, idleAmount) = abi.decode(hookData, (address, uint256)); + } else if (hookData.length >= 32) { idleAmount = abi.decode(hookData, (uint256)); } - emit IdleLPParked(sender, key.toId(), idleAmount, currentTick); + emit IdleLPParked(lpProvider, key.toId(), idleAmount, currentTick); - if (usdc.allowance(sender, address(this)) >= idleAmount) { - usdc.safeTransferFrom(sender, address(this), idleAmount); + if (usdc.allowance(lpProvider, address(this)) >= idleAmount) { + usdc.safeTransferFrom(lpProvider, address(this), idleAmount); usdc.approve(address(vault), idleAmount); vault.park(idleAmount); + // Track deposit + userDeposits[lpProvider] += idleAmount; + // Gas Optimization: Track the parked amount in transient storage bytes32 slot = TRANSIENT_PARKED_USDC_SLOT; assembly { @@ -97,13 +106,26 @@ contract AgentFloatHook is BaseHook { } function _afterRemoveLiquidity( - address, + address sender, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta delta, BalanceDelta, bytes calldata ) internal override returns (bytes4, BalanceDelta) { + uint256 userDep = userDeposits[sender]; + if (userDep > 0) { + userDeposits[sender] = 0; + + // Check if the funds are in the vault or already recalled to the hook balance + uint256 vaultBal = vault.deposits(address(this)); + if (vaultBal >= userDep) { + vault.withdraw(userDep); + } + + // Return USDC back to the LP provider (sender) + usdc.safeTransfer(sender, userDep); + } return (BaseHook.afterRemoveLiquidity.selector, delta); } diff --git a/contracts/src/flap/FlapYieldTaxVault.sol b/contracts/src/flap/FlapYieldTaxVault.sol new file mode 100644 index 0000000..2616c5f --- /dev/null +++ b/contracts/src/flap/FlapYieldTaxVault.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./VaultBaseV2.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IFloatVault { + function usdc() external view returns (address); + function park(uint256 amount) external; + function withdraw(uint256 amount) external; + function deposits(address depositor) external view returns (uint256); +} + +interface IWOKB { + function deposit() external payable; + function withdraw(uint256 wad) external; +} + +contract FlapYieldTaxVault is VaultBaseV2 { + using SafeERC20 for IERC20; + + // Wrapped OKB Address on X Layer Mainnet + address public constant WOKB_ADDRESS = 0xe538905cf8410324e03A5A23C1c177a474D59b2b; + + address public immutable taxToken; + address public immutable quoteToken; + address public immutable creator; + IFloatVault public immutable floatVault; + IERC20 public immutable usdc; + + event TaxesParked(uint256 amount); + event TaxesWithdrawn(address indexed recipient, uint256 amount); + + constructor( + address _taxToken, + address _quoteToken, + address _creator, + address _floatVault, + address _guardian + ) VaultBaseV2(_guardian) { + require(_taxToken != address(0), "Zero tax token"); + require(_floatVault != address(0), "Zero float vault"); + taxToken = _taxToken; + quoteToken = _quoteToken; + creator = _creator; + floatVault = IFloatVault(_floatVault); + usdc = IERC20(floatVault.usdc()); + } + + // Called when the tax token sends native quote tokens (e.g. OKB) + receive() external payable override { + // If the vault is configured for WOKB, wrap native OKB to WOKB and park it + if (address(usdc) == WOKB_ADDRESS) { + uint256 amount = msg.value; + if (amount > 0) { + IWOKB(WOKB_ADDRESS).deposit{value: amount}(); + IERC20(WOKB_ADDRESS).approve(address(floatVault), amount); + floatVault.park(amount); + emit TaxesParked(amount); + } + } + } + + // Check the vault's raw underlying balance and park it in FloatVault to start earning yield + function parkTaxes() public { + if (quoteToken == address(usdc)) { + uint256 rawBalance = usdc.balanceOf(address(this)); + if (rawBalance > 0) { + usdc.approve(address(floatVault), rawBalance); + floatVault.park(rawBalance); + emit TaxesParked(rawBalance); + } + } + } + + // Accept ERC20 taxes and deposit them to FloatVault + function depositTaxERC20(uint256 amount) external { + require(quoteToken != address(0), "Native quote token"); + require(msg.sender == taxToken || msg.sender == creator, "Not authorized"); + + IERC20(quoteToken).safeTransferFrom(msg.sender, address(this), amount); + + if (quoteToken == address(usdc)) { + IERC20(quoteToken).approve(address(floatVault), amount); + floatVault.park(amount); + emit TaxesParked(amount); + } + } + + // Withdraw taxes along with any accrued yield + function withdrawTaxes(uint256 amount) external { + require(msg.sender == creator || msg.sender == guardian, "Not creator or guardian"); + + if (quoteToken == address(usdc)) { + // First park any raw USDC/USDT0 to ensure all yield is counted + parkTaxes(); + + // Check deposits in FloatVault + uint256 deposited = floatVault.deposits(address(this)); + require(deposited >= amount, "Insufficient deposited balance"); + + // Withdraw from FloatVault to this contract + floatVault.withdraw(amount); + + // Transfer to creator/guardian + usdc.safeTransfer(msg.sender, amount); + } else if (quoteToken == address(0)) { + // Native OKB withdrawal + if (address(usdc) == WOKB_ADDRESS) { + // Withdraw WOKB from FloatVault + floatVault.withdraw(amount); + // Unwrap WOKB to native OKB + IWOKB(WOKB_ADDRESS).withdraw(amount); + // Send native OKB to creator/guardian + payable(msg.sender).transfer(amount); + } else { + require(address(this).balance >= amount, "Insufficient native balance"); + payable(msg.sender).transfer(amount); + } + } else { + // Standard ERC20 + IERC20(quoteToken).safeTransfer(msg.sender, amount); + } + + emit TaxesWithdrawn(msg.sender, amount); + } + + function withdrawAll() external { + require(msg.sender == creator || msg.sender == guardian, "Not creator or guardian"); + + if (quoteToken == address(usdc)) { + parkTaxes(); + + uint256 deposited = floatVault.deposits(address(this)); + if (deposited > 0) { + floatVault.withdraw(deposited); + } + uint256 balance = usdc.balanceOf(address(this)); + if (balance > 0) { + usdc.safeTransfer(msg.sender, balance); + emit TaxesWithdrawn(msg.sender, balance); + } + } else if (quoteToken == address(0)) { + if (address(usdc) == WOKB_ADDRESS) { + uint256 deposited = floatVault.deposits(address(this)); + if (deposited > 0) { + floatVault.withdraw(deposited); + IWOKB(WOKB_ADDRESS).withdraw(deposited); + } + } + uint256 nativeBalance = address(this).balance; + if (nativeBalance > 0) { + payable(msg.sender).transfer(nativeBalance); + emit TaxesWithdrawn(msg.sender, nativeBalance); + } + } else { + uint256 balance = IERC20(quoteToken).balanceOf(address(this)); + if (balance > 0) { + IERC20(quoteToken).safeTransfer(msg.sender, balance); + emit TaxesWithdrawn(msg.sender, balance); + } + } + } + + // Get current total value (including yield from FloatVault) + function totalValue() public view returns (uint256) { + if (quoteToken == address(usdc)) { + return floatVault.deposits(address(this)) + usdc.balanceOf(address(this)); + } else if (quoteToken == address(0)) { + if (address(usdc) == WOKB_ADDRESS) { + return floatVault.deposits(address(this)) + address(this).balance; + } else { + return address(this).balance; + } + } else { + return IERC20(quoteToken).balanceOf(address(this)); + } + } + + function description() external view override returns (string memory) { + return "AgentFloat Yield-generating Flap Tax Vault. Routes accumulated taxes to FloatVault to earn optimized on-chain yield."; + } + + function vaultUISchema() external view override returns (VaultUISchema memory) { + VaultMethodSchema[] memory methods = new VaultMethodSchema[](4); + + // 1. depositTaxERC20 + FieldDescriptor[] memory depositInputs = new FieldDescriptor[](1); + depositInputs[0] = FieldDescriptor("amount", "uint256", "Amount of quote token tax to deposit"); + FieldDescriptor[] memory depositOutputs = new FieldDescriptor[](0); + ApproveAction[] memory depositApprovals = new ApproveAction[](1); + depositApprovals[0] = ApproveAction("quoteToken", "amount"); + methods[0] = VaultMethodSchema( + "depositTaxERC20", + "Deposits quote token tax into the vault, routing it to FloatVault if it is USDC/USDT0 to generate yield.", + depositInputs, + depositOutputs, + depositApprovals, + false, + false, + true + ); + + // 2. withdrawTaxes + FieldDescriptor[] memory withdrawInputs = new FieldDescriptor[](1); + withdrawInputs[0] = FieldDescriptor("amount", "uint256", "Amount to withdraw"); + FieldDescriptor[] memory withdrawOutputs = new FieldDescriptor[](0); + ApproveAction[] memory withdrawApprovals = new ApproveAction[](0); + methods[1] = VaultMethodSchema( + "withdrawTaxes", + "Withdraws tax tokens along with accrued yield to the creator or guardian.", + withdrawInputs, + withdrawOutputs, + withdrawApprovals, + false, + false, + true + ); + + // 3. withdrawAll + FieldDescriptor[] memory withdrawAllInputs = new FieldDescriptor[](0); + FieldDescriptor[] memory withdrawAllOutputs = new FieldDescriptor[](0); + ApproveAction[] memory withdrawAllApprovals = new ApproveAction[](0); + methods[2] = VaultMethodSchema( + "withdrawAll", + "Withdraws all tax tokens along with accrued yield to the creator or guardian.", + withdrawAllInputs, + withdrawAllOutputs, + withdrawAllApprovals, + false, + false, + true + ); + + // 4. totalValue + FieldDescriptor[] memory valueInputs = new FieldDescriptor[](0); + FieldDescriptor[] memory valueOutputs = new FieldDescriptor[](1); + valueOutputs[0] = FieldDescriptor("value", "uint256", "Total value of taxes currently held in the vault"); + ApproveAction[] memory valueApprovals = new ApproveAction[](0); + methods[3] = VaultMethodSchema( + "totalValue", + "Returns the total value of taxes currently held (including yield).", + valueInputs, + valueOutputs, + valueApprovals, + false, + false, + false + ); + + return VaultUISchema("AgentFloatYieldVault", "Yield-generating Flap Tax Vault", methods); + } +} diff --git a/contracts/src/flap/FlapYieldTaxVaultFactory.sol b/contracts/src/flap/FlapYieldTaxVaultFactory.sol new file mode 100644 index 0000000..0e3912f --- /dev/null +++ b/contracts/src/flap/FlapYieldTaxVaultFactory.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./FlapYieldTaxVault.sol"; + +contract FlapYieldTaxVaultFactory { + address public immutable floatVault; + address public immutable guardian; + + event VaultCreated(address indexed vault, address indexed taxToken, address indexed creator); + + constructor(address _floatVault, address _guardian) { + require(_floatVault != address(0), "Zero float vault"); + require(_guardian != address(0), "Zero guardian"); + floatVault = _floatVault; + guardian = _guardian; + } + + function newVault( + address taxToken, + address quoteToken, + address creator, + bytes calldata /* vaultData */ + ) external returns (address vault) { + FlapYieldTaxVault newContract = new FlapYieldTaxVault( + taxToken, + quoteToken, + creator, + floatVault, + guardian + ); + vault = address(newContract); + emit VaultCreated(vault, taxToken, creator); + } +} diff --git a/contracts/src/flap/IVaultSchemasV1.sol b/contracts/src/flap/IVaultSchemasV1.sol new file mode 100644 index 0000000..4107502 --- /dev/null +++ b/contracts/src/flap/IVaultSchemasV1.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IVaultSchemasV1 { + struct ApproveAction { + string tokenType; // e.g., "taxToken" + string amountFieldName; // e.g., "amount" + } + + struct FieldDescriptor { + string name; // e.g., "recipient", "bps", "amount" + string fieldType; // e.g., "address", "uint256" + string description; // e.g., "Address to send funds", "Percentage to send" + } + + struct VaultMethodSchema { + string name; + string description; + FieldDescriptor[] inputs; + FieldDescriptor[] outputs; + ApproveAction[] approvals; + bool isInputArray; + bool isOutputArray; + bool isWriteMethod; + } + + struct VaultUISchema { + string vaultType; + string description; + VaultMethodSchema[] methods; + } +} diff --git a/contracts/src/flap/VaultBaseV2.sol b/contracts/src/flap/VaultBaseV2.sol new file mode 100644 index 0000000..61eb3a7 --- /dev/null +++ b/contracts/src/flap/VaultBaseV2.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./IVaultSchemasV1.sol"; + +abstract contract VaultBaseV2 is IVaultSchemasV1 { + address public immutable guardian; + + constructor(address _guardian) { + require(_guardian != address(0), "Zero guardian address"); + guardian = _guardian; + } + + function description() external view virtual returns (string memory); + function vaultUISchema() external view virtual returns (VaultUISchema memory); + + receive() external payable virtual {} +} diff --git a/contracts/src/strategies/AaveStrategy.sol b/contracts/src/strategies/AaveStrategy.sol new file mode 100644 index 0000000..83742ba --- /dev/null +++ b/contracts/src/strategies/AaveStrategy.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "../interfaces/IStrategy.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IAavePool { + struct ReserveConfigurationMap { + uint256 data; + } + + struct ReserveData { + ReserveConfigurationMap configuration; + uint128 liquidityIndex; + uint128 currentLiquidityRate; + uint128 variableBorrowIndex; + uint128 currentVariableBorrowRate; + uint128 currentStableBorrowRate; + uint40 lastUpdateTimestamp; + uint16 id; + address aTokenAddress; + address stableDebtTokenAddress; + address variableDebtTokenAddress; + address interestRateStrategyAddress; + address accruedToTreasury; + uint128 unbacked; + uint128 isolationModeTotalDebt; + } + + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; + function withdraw(address asset, uint256 amount, address to) external returns (uint256); + function getReserveData(address asset) external view returns (ReserveData memory); +} + +contract AaveStrategy is IStrategy { + using SafeERC20 for IERC20; + + IERC20 public immutable underlying; + IAavePool public immutable pool; + IERC20 public immutable aToken; + string public constant STRATEGY_NAME = "Aave V3 Yield Strategy"; + + uint256 public storedBalance; + uint256 public lastLiquidityIndex; + + constructor(address _underlying, address _pool, address _aToken) { + require(_underlying != address(0), "Zero address underlying"); + require(_pool != address(0), "Zero address pool"); + require(_aToken != address(0), "Zero address aToken"); + underlying = IERC20(_underlying); + pool = IAavePool(_pool); + aToken = IERC20(_aToken); + lastLiquidityIndex = getAaveLiquidityIndex(); + } + + function getAaveLiquidityIndex() public view returns (uint256) { + IAavePool.ReserveData memory data = pool.getReserveData(address(underlying)); + return uint256(data.liquidityIndex); + } + + function deposit(uint256 amount) external override { + // Update simulated balance before receiving new funds + storedBalance = currentValue(); + lastLiquidityIndex = getAaveLiquidityIndex(); + + underlying.safeTransferFrom(msg.sender, address(this), amount); + + // Approve and supply to Aave Pool + underlying.approve(address(pool), 0); + underlying.approve(address(pool), amount); + pool.supply(address(underlying), amount, address(this), 0); + + storedBalance += amount; + } + + function withdraw(uint256 amount) external override returns (uint256 actualOut) { + storedBalance = currentValue(); + lastLiquidityIndex = getAaveLiquidityIndex(); + + if (amount > storedBalance) { + amount = storedBalance; + } + + storedBalance -= amount; + + // Perform actual withdrawal from Aave Pool + // The pool burns our aTokens and transfers underlying to msg.sender + actualOut = pool.withdraw(address(underlying), amount, msg.sender); + return actualOut; + } + + function currentValue() public view override returns (uint256) { + uint256 realBalance = aToken.balanceOf(address(this)); + if (realBalance > 0) { + return realBalance; + } + + // If in shadow mode (no real aTokens held), calculate virtual balance based on liquidity index growth + if (storedBalance == 0) { + return 0; + } + + uint256 currentIndex = getAaveLiquidityIndex(); + if (currentIndex <= lastLiquidityIndex || lastLiquidityIndex == 0) { + return storedBalance; + } + + // liquidityIndex is expressed in Ray (27 decimals), so scale properly + return (storedBalance * currentIndex) / lastLiquidityIndex; + } + + function asset() external view override returns (address) { + return address(underlying); + } + + function name() external view override returns (string memory) { + return STRATEGY_NAME; + } +} diff --git a/contracts/test/FlapYieldTaxVault.t.sol b/contracts/test/FlapYieldTaxVault.t.sol new file mode 100644 index 0000000..d9de886 --- /dev/null +++ b/contracts/test/FlapYieldTaxVault.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {FlapYieldTaxVault} from "../src/flap/FlapYieldTaxVault.sol"; +import {FlapYieldTaxVaultFactory} from "../src/flap/FlapYieldTaxVaultFactory.sol"; +import {FloatVault} from "../src/FloatVault.sol"; +import {IdleStrategy} from "../src/strategies/IdleStrategy.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract FlapYieldTaxVaultTest is Test { + MockUSDC public mockUsdc; + FloatVault public floatVault; + IdleStrategy public idle; + FlapYieldTaxVaultFactory public factory; + + address public taxToken = address(0x1111); + address public creator = address(0x2222); + address public guardian = address(0x3333); + + function setUp() public { + mockUsdc = new MockUSDC(); + floatVault = new FloatVault(address(mockUsdc)); + + idle = new IdleStrategy(address(mockUsdc)); + floatVault.registerStrategy(address(idle), false); // Register idle as active (ID 1) + + factory = new FlapYieldTaxVaultFactory(address(floatVault), guardian); + } + + function test_CreateVault() public { + address quoteToken = address(mockUsdc); + + address vaultAddr = factory.newVault(taxToken, quoteToken, creator, ""); + FlapYieldTaxVault vault = FlapYieldTaxVault(payable(vaultAddr)); + + assertEq(vault.taxToken(), taxToken); + assertEq(vault.quoteToken(), quoteToken); + assertEq(vault.creator(), creator); + assertEq(address(vault.floatVault()), address(floatVault)); + assertEq(vault.guardian(), guardian); + } + + function test_DepositAndParkTaxes() public { + address quoteToken = address(mockUsdc); + address vaultAddr = factory.newVault(taxToken, quoteToken, creator, ""); + FlapYieldTaxVault vault = FlapYieldTaxVault(payable(vaultAddr)); + + uint256 taxAmount = 1000 * 10 ** 6; // 1000 USDC + mockUsdc.mint(address(this), taxAmount); + + // 1. Direct transfer of taxes to the vault (simulating Flap contract fee liquidation) + mockUsdc.transfer(address(vault), taxAmount); + + assertEq(mockUsdc.balanceOf(address(vault)), taxAmount); + assertEq(floatVault.deposits(address(vault)), 0); + + // 2. Call parkTaxes to sweep and deposit to FloatVault + vault.parkTaxes(); + + // 3. Verify that the taxes have been swept to FloatVault + assertEq(mockUsdc.balanceOf(address(vault)), 0); + assertEq(floatVault.deposits(address(vault)), taxAmount); + assertEq(mockUsdc.balanceOf(address(idle)), taxAmount); + } + + function test_WithdrawTaxes() public { + address quoteToken = address(mockUsdc); + address vaultAddr = factory.newVault(taxToken, quoteToken, creator, ""); + FlapYieldTaxVault vault = FlapYieldTaxVault(payable(vaultAddr)); + + uint256 taxAmount = 1000 * 10 ** 6; + mockUsdc.mint(address(this), taxAmount); + mockUsdc.transfer(address(vault), taxAmount); + + // Sweeps automatically on withdraw + vm.prank(creator); + vault.withdrawTaxes(taxAmount); + + // Verify balance was transferred to creator + assertEq(mockUsdc.balanceOf(creator), taxAmount); + assertEq(floatVault.deposits(address(vault)), 0); + } + + function test_Revert_UnauthorizedWithdraw() public { + address quoteToken = address(mockUsdc); + address vaultAddr = factory.newVault(taxToken, quoteToken, creator, ""); + FlapYieldTaxVault vault = FlapYieldTaxVault(payable(vaultAddr)); + + uint256 taxAmount = 1000 * 10 ** 6; + mockUsdc.mint(address(this), taxAmount); + mockUsdc.transfer(address(vault), taxAmount); + + // Expect revert if unauthorized EOA calls withdraw + vm.expectRevert(); + vm.prank(address(0x9999)); + vault.withdrawTaxes(taxAmount); + } +}