// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /// ---------------------------------------------------------------------- /// SWAN Protocol — SwanVault v0.3.0 /// /// A single-owner, non-custodial vault with narrowly-delegated /// rebalancing rights for an off-chain agent. /// /// On-chain guarantees (what the code CANNOT do, regardless of who calls): /// G1 Funds can only ever leave to the OWNER's address. There is no /// code path that transfers assets anywhere else — the only external /// transfer of value besides withdrawals is a swap whose recipient /// is hard-coded to this vault itself. /// G2 The agent can only swap between tokens the owner has whitelisted, /// through the single immutable router chosen at deployment. /// G3 Agent trades are rate-limited: per-trade size cap (bps of the /// tokenIn balance) and a cooldown between trades. /// G4 Every agent trade must carry a non-zero minimum-output — /// an explicit slippage bound checked against real balance deltas. /// G5 The owner can revoke the agent and pause trading in one /// transaction. Withdrawals are NEVER pausable. /// G6 ERC20 approvals are granted only to the router, only for the /// exact trade amount, and reset to zero afterwards. /// /// v0.1.0 scope notes (documented, deliberate): /// - Allocation-band enforcement (e.g. "crypto <= 40%") requires price /// oracles and ships in v1. v0 enforces custody, whitelist, venue, /// size and rate limits — the safety-critical envelope. /// - The policy itself (targets, thresholds, autonomy) is stored as a /// keccak256 hash of the owner's signed EIP-712 policy document, so /// the vault is verifiably bound to one specific policy version. /// ---------------------------------------------------------------------- interface IERC20 { function balanceOf(address) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function approve(address spender, uint256 amount) external returns (bool); } /// @dev Uniswap V3 SwapRouter02 style single-hop swap (no deadline field). interface ISwapRouter02 { struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; } function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } contract SwanVault { // ---------------------------------------------------------------- state address public immutable owner; address public immutable router; address public agent; bool public paused; bytes32 public policyHash; uint16 public maxTradeBps; // max share of tokenIn balance per trade, in bps uint32 public cooldown; // min seconds between agent trades uint64 public lastTradeAt; mapping(address => bool) public allowedToken; uint256 private _lock; // reentrancy guard (0 = unlocked) // ---------------------------------------------------------------- events event AgentSet(address indexed agent); event AgentRevoked(); event PolicySet(bytes32 indexed policyHash); event TokenAllowed(address indexed token, bool allowed); event LimitsSet(uint16 maxTradeBps, uint32 cooldown); event PausedSet(bool paused); event DepositedETH(address indexed from, uint256 amount); event DepositedToken(address indexed token, uint256 amount); event WithdrawnETH(uint256 amount); event WithdrawnToken(address indexed token, uint256 amount); event Rebalanced( address indexed caller, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut ); // ---------------------------------------------------------------- errors error NotOwner(); error NotAgentOrOwner(); error Paused(); error Reentrancy(); error ZeroAddress(); error TokenNotAllowed(address token); error SameToken(); error CooldownActive(uint256 readyAt); error TradeTooLarge(uint256 amountIn, uint256 maxAllowed); error MinOutZero(); error InsufficientOutput(uint256 received, uint256 minOut); error TransferFailed(); error BadLimits(); // ---------------------------------------------------------------- modifiers modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier nonReentrant() { if (_lock != 0) revert Reentrancy(); _lock = 1; _; _lock = 0; } // ---------------------------------------------------------------- setup /// @param _owner the vault owner — the ONLY address that can ever withdraw /// @param _router the only venue this vault can ever trade through /// (Robinhood Chain: canonical Uniswap SwapRouter02 /// 0xCaf681a66D020601342297493863E78C959E5cb2) /// @param _agent initial agent (may be address(0) = no agent yet) /// @param _policyHash keccak256 of the owner's signed policy document /// @param _tokens initial trading whitelist, active from creation constructor( address _owner, address _router, address _agent, bytes32 _policyHash, address[] memory _tokens ) { if (_owner == address(0) || _router == address(0)) revert ZeroAddress(); owner = _owner; router = _router; agent = _agent; policyHash = _policyHash; maxTradeBps = 2_500; // 25% of a sleeve per trade cooldown = 1 hours; for (uint256 i = 0; i < _tokens.length; i++) { if (_tokens[i] == address(0)) revert ZeroAddress(); allowedToken[_tokens[i]] = true; emit TokenAllowed(_tokens[i], true); } emit AgentSet(_agent); emit PolicySet(_policyHash); emit LimitsSet(2_500, 1 hours); } receive() external payable { emit DepositedETH(msg.sender, msg.value); } // ---------------------------------------------------------------- owner: funds /// @notice Pull an approved ERC20 amount from the owner into the vault. /// (You can also simply transfer tokens to the vault address.) function depositToken(address token, uint256 amount) external onlyOwner nonReentrant { _safeTransferFrom(token, msg.sender, address(this), amount); emit DepositedToken(token, amount); } /// @notice Withdraw ERC20 to the owner. amount = 0 withdraws the full balance. /// @dev Never pausable, never restricted — G1/G5. function withdrawToken(address token, uint256 amount) external onlyOwner nonReentrant { uint256 bal = IERC20(token).balanceOf(address(this)); uint256 amt = amount == 0 ? bal : amount; _safeTransfer(token, owner, amt); emit WithdrawnToken(token, amt); } /// @notice Withdraw ETH to the owner. amount = 0 withdraws the full balance. function withdrawETH(uint256 amount) external onlyOwner nonReentrant { uint256 amt = amount == 0 ? address(this).balance : amount; (bool ok, ) = owner.call{value: amt}(""); if (!ok) revert TransferFailed(); emit WithdrawnETH(amt); } // ---------------------------------------------------------------- owner: policy & controls function setAgent(address _agent) external onlyOwner { agent = _agent; emit AgentSet(_agent); } /// @notice One-transaction kill switch: removes the agent and pauses trading. /// Withdrawals remain fully available. function revokeAgent() external onlyOwner { agent = address(0); paused = true; emit AgentRevoked(); emit PausedSet(true); } function setPolicy(bytes32 _policyHash) external onlyOwner { policyHash = _policyHash; emit PolicySet(_policyHash); } function setTokenAllowed(address token, bool allowed) external onlyOwner { if (token == address(0)) revert ZeroAddress(); allowedToken[token] = allowed; emit TokenAllowed(token, allowed); } function setLimits(uint16 _maxTradeBps, uint32 _cooldown) external onlyOwner { if (_maxTradeBps == 0 || _maxTradeBps > 10_000 || _cooldown > 7 days) revert BadLimits(); maxTradeBps = _maxTradeBps; cooldown = _cooldown; emit LimitsSet(_maxTradeBps, _cooldown); } function setPaused(bool _paused) external onlyOwner { paused = _paused; emit PausedSet(_paused); } // ---------------------------------------------------------------- agent: rebalance /// @notice Swap `amountIn` of `tokenIn` for at least `minOut` of `tokenOut` /// through the immutable router. Callable by the agent or the owner. /// @dev Every guarantee G1-G6 is enforced in this function. function rebalance( address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint256 minOut ) external nonReentrant returns (uint256 amountOut) { if (msg.sender != agent && msg.sender != owner) revert NotAgentOrOwner(); if (paused) revert Paused(); if (!allowedToken[tokenIn]) revert TokenNotAllowed(tokenIn); if (!allowedToken[tokenOut]) revert TokenNotAllowed(tokenOut); if (tokenIn == tokenOut) revert SameToken(); if (minOut == 0) revert MinOutZero(); // rate limits apply to the agent; the owner trades at will if (msg.sender == agent) { uint256 readyAt = uint256(lastTradeAt) + cooldown; if (block.timestamp < readyAt) revert CooldownActive(readyAt); uint256 maxAllowed = (IERC20(tokenIn).balanceOf(address(this)) * maxTradeBps) / 10_000; if (amountIn > maxAllowed) revert TradeTooLarge(amountIn, maxAllowed); } uint256 outBefore = IERC20(tokenOut).balanceOf(address(this)); // exact-amount approval, reset after (G6); 0-first for USDT-style tokens _safeApprove(tokenIn, router, 0); _safeApprove(tokenIn, router, amountIn); amountOut = ISwapRouter02(router).exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: tokenIn, tokenOut: tokenOut, fee: fee, recipient: address(this), // G1: proceeds can only come home amountIn: amountIn, amountOutMinimum: minOut, sqrtPriceLimitX96: 0 }) ); _safeApprove(tokenIn, router, 0); // trust nothing: verify the real balance delta (G4) uint256 received = IERC20(tokenOut).balanceOf(address(this)) - outBefore; if (received < minOut) revert InsufficientOutput(received, minOut); lastTradeAt = uint64(block.timestamp); emit Rebalanced(msg.sender, tokenIn, tokenOut, amountIn, received); } // ---------------------------------------------------------------- views function isReady() external view returns (bool) { return !paused && agent != address(0) && block.timestamp >= uint256(lastTradeAt) + cooldown; } // ---------------------------------------------------------------- safe ERC20 // Minimal safe-transfer helpers tolerating non-standard tokens that // return nothing (USDT-style). A call must succeed, and if it returns // data, that data must decode to true. function _safeTransfer(address token, address to, uint256 amount) private { (bool ok, bytes memory ret) = token.call(abi.encodeCall(IERC20.transfer, (to, amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } function _safeTransferFrom(address token, address from, address to, uint256 amount) private { (bool ok, bytes memory ret) = token.call(abi.encodeCall(IERC20.transferFrom, (from, to, amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } function _safeApprove(address token, address spender, uint256 amount) private { (bool ok, bytes memory ret) = token.call(abi.encodeCall(IERC20.approve, (spender, amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } } /// ---------------------------------------------------------------------- /// SwanVaultFactory — the singleton "front door" of the protocol. /// /// Deployed ONCE. Fully permissionless, holds no funds, has no admin, /// takes no fees, cannot be paused. Anyone calls createVault() and gets /// their own isolated SwanVault in a single transaction — owned by the /// caller, wired to the canonical router, with the standard whitelist /// active from block one. /// ---------------------------------------------------------------------- interface IWETH { function deposit() external payable; function transfer(address to, uint256 amount) external returns (bool); } contract SwanVaultFactory { address public immutable weth; address public immutable router; uint256 public totalVaults; address[] private _defaultTokens; mapping(address => address[]) private _vaultsOf; event VaultCreated( address indexed owner, address indexed vault, bytes32 policyHash, address agent ); constructor(address _router, address _weth, address[] memory defaultTokens_) { require(_router != address(0) && _weth != address(0), "zero"); router = _router; weth = _weth; for (uint256 i = 0; i < defaultTokens_.length; i++) { require(defaultTokens_[i] != address(0), "token=0"); _defaultTokens.push(defaultTokens_[i]); } } /// @notice One transaction: your own vault, fully configured. /// @param policyHash keccak256 of your signed policy document /// @param agent optional agent address (address(0) = none yet) function createVault(bytes32 policyHash, address agent) public returns (address vault) { vault = address(new SwanVault(msg.sender, router, agent, policyHash, _defaultTokens)); _vaultsOf[msg.sender].push(vault); unchecked { totalVaults++; } emit VaultCreated(msg.sender, vault, policyHash, agent); } /// @notice The whole journey in ONE transaction: create your vault and /// fund it — the ETH you attach is wrapped to WETH inside the /// new vault, ready for the agent to allocate per your policy. function createVaultWithETH(bytes32 policyHash, address agent) external payable returns (address vault) { vault = createVault(policyHash, agent); if (msg.value > 0) { IWETH(weth).deposit{value: msg.value}(); require(IWETH(weth).transfer(vault, msg.value), "fund"); } } function vaultsOf(address owner_) external view returns (address[] memory) { return _vaultsOf[owner_]; } function defaultTokens() external view returns (address[] memory) { return _defaultTokens; } }