// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); } /// @title WardenVault /// @notice Funds live here, owned by a human. An autonomous agent may only ask the vault /// to move them, and every request must clear four walls: /// I LIMIT — per-tx / per-hour / per-day ETH caps, per-day caps per token /// II FILTER — only allowlisted (target, selector) pairs, no infinite approvals /// III HOLD — moves at or above a threshold wait out a delay the owner can veto /// IV HALT — an owner-only kill switch that freezes the agent entirely contract WardenVault { // ───────────────────────────────── types struct Policy { uint128 maxPerTx; // max ETH per single move (0 = no ETH may move) uint128 maxPerHour; // max ETH per clock hour uint128 maxPerDay; // max ETH per UTC day uint128 holdThreshold; // ETH value from which a move is held (0 = never hold) uint64 holdDelay; // seconds a held move waits } struct Held { address target; uint96 eta; uint256 value; bytes data; uint8 state; // 0 pending, 1 executed, 2 cancelled } // ───────────────────────────────── selectors watched by the FILTER wall bytes4 internal constant SEL_TRANSFER = 0xa9059cbb; // transfer(address,uint256) bytes4 internal constant SEL_TRANSFER_FROM = 0x23b872dd; // transferFrom(address,address,uint256) bytes4 internal constant SEL_APPROVE = 0x095ea7b3; // approve(address,uint256) bytes4 internal constant SEL_INCREASE_ALLOWANCE = 0x39509351;// increaseAllowance(address,uint256) bytes4 internal constant SEL_SET_APPROVAL_ALL = 0xa22cb465; // setApprovalForAll(address,bool) bytes4 public constant PLAIN_ETH = 0x00000000; // selector used for a bare ETH send // ───────────────────────────────── state address public owner; address public pendingOwner; address public agent; bool public halted; Policy public policy; mapping(address => mapping(bytes4 => bool)) public allowed; mapping(address => uint256) public tokenDayCap; // 0 = token transfers blocked mapping(address => mapping(uint256 => uint256)) public tokenSpent; // token => day => spent uint256 public hourIndex; uint256 public hourSpent; uint256 public dayIndex; uint256 public daySpent; Held[] internal _held; uint256 private _lock = 1; // ───────────────────────────────── errors (each one names the wall that stopped the move) error Halted(); // IV error NotAgent(); error NotOwner(); error NotAllowlisted(address target, bytes4 selector); // II error InfiniteApproval(); // II error OverTxCap(uint256 value, uint256 cap); // I error OverHourCap(uint256 wouldBe, uint256 cap); // I error OverDayCap(uint256 wouldBe, uint256 cap); // I error OverTokenCap(address token, uint256 wouldBe, uint256 cap); // I error StillHeld(uint256 id, uint256 eta); // III error NotPending(uint256 id); error CallFailed(bytes reason); error Reentrancy(); error ZeroAddress(); // ───────────────────────────────── events event Executed(address indexed target, uint256 value, bytes4 selector); event HeldMove(uint256 indexed id, address indexed target, uint256 value, uint256 eta); event HeldExecuted(uint256 indexed id); event HeldCancelled(uint256 indexed id); event HaltSet(bool halted); event PolicySet(Policy policy); event AllowSet(address indexed target, bytes4 indexed selector, bool ok); event TokenCapSet(address indexed token, uint256 cap); event AgentSet(address indexed agent); event OwnershipTransferStarted(address indexed from, address indexed to); event OwnershipTransferred(address indexed from, address indexed to); event Deposited(address indexed from, uint256 value); event Withdrawn(address indexed to, address indexed token, uint256 amount); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier nonReentrant() { if (_lock != 1) revert Reentrancy(); _lock = 2; _; _lock = 1; } constructor( address owner_, address agent_, Policy memory policy_, address[] memory targets, bytes4[] memory selectors ) { if (owner_ == address(0)) revert ZeroAddress(); require(targets.length == selectors.length, "len"); owner = owner_; agent = agent_; policy = policy_; for (uint256 i; i < targets.length; ++i) { allowed[targets[i]][selectors[i]] = true; emit AllowSet(targets[i], selectors[i], true); } emit OwnershipTransferred(address(0), owner_); emit AgentSet(agent_); emit PolicySet(policy_); } receive() external payable { emit Deposited(msg.sender, msg.value); } // ═════════════════════════════════ agent entry points /// @notice The only way the agent can move anything. /// @return id 0 when executed now, otherwise (heldId + 1) when the move is held. function execute(address target, uint256 value, bytes calldata data) external nonReentrant returns (uint256 id) { if (msg.sender != agent) revert NotAgent(); if (halted) revert Halted(); bytes4 sel = _filter(target, data); _limitPerTx(value); Policy memory p = policy; if (p.holdThreshold != 0 && value >= p.holdThreshold) { uint256 eta = block.timestamp + p.holdDelay; _held.push(Held(target, uint96(eta), value, data, 0)); id = _held.length; emit HeldMove(id - 1, target, value, eta); return id; } _limitWindows(value); _limitToken(target, sel, data); _call(target, value, data); emit Executed(target, value, sel); } /// @notice Release a held move once its delay is over. Every wall is checked again. function executeHeld(uint256 id) external nonReentrant { if (msg.sender != agent && msg.sender != owner) revert NotAgent(); if (halted) revert Halted(); Held storage h = _held[id]; if (h.state != 0) revert NotPending(id); if (block.timestamp < h.eta) revert StillHeld(id, h.eta); address target = h.target; uint256 value = h.value; bytes memory data = h.data; bytes4 sel = _filter(target, data); _limitPerTx(value); _limitWindows(value); _limitToken(target, sel, data); h.state = 1; _call(target, value, data); emit HeldExecuted(id); } // ═════════════════════════════════ owner controls function halt() external onlyOwner { halted = true; emit HaltSet(true); } function resume() external onlyOwner { halted = false; emit HaltSet(false); } function cancelHeld(uint256 id) external onlyOwner { Held storage h = _held[id]; if (h.state != 0) revert NotPending(id); h.state = 2; emit HeldCancelled(id); } function setPolicy(Policy calldata p) external onlyOwner { policy = p; emit PolicySet(p); } function setAllowed(address[] calldata targets, bytes4[] calldata selectors, bool ok) external onlyOwner { require(targets.length == selectors.length, "len"); for (uint256 i; i < targets.length; ++i) { allowed[targets[i]][selectors[i]] = ok; emit AllowSet(targets[i], selectors[i], ok); } } function setTokenCap(address token, uint256 cap) external onlyOwner { tokenDayCap[token] = cap; emit TokenCapSet(token, cap); } function setAgent(address agent_) external onlyOwner { agent = agent_; emit AgentSet(agent_); } function transferOwnership(address to) external onlyOwner { pendingOwner = to; emit OwnershipTransferStarted(owner, to); } function acceptOwnership() external { if (msg.sender != pendingOwner) revert NotOwner(); emit OwnershipTransferred(owner, msg.sender); owner = msg.sender; pendingOwner = address(0); } function withdraw(address payable to, uint256 amount) external onlyOwner nonReentrant { (bool ok, bytes memory r) = to.call{value: amount}(""); if (!ok) revert CallFailed(r); emit Withdrawn(to, address(0), amount); } function withdrawToken(address token, address to, uint256 amount) external onlyOwner nonReentrant { (bool ok, bytes memory r) = token.call(abi.encodeWithSelector(SEL_TRANSFER, to, amount)); if (!ok || (r.length != 0 && !abi.decode(r, (bool)))) revert CallFailed(r); emit Withdrawn(to, token, amount); } /// @notice The owner is never fenced in: arbitrary call from the vault. function ownerCall(address target, uint256 value, bytes calldata data) external onlyOwner nonReentrant returns (bytes memory) { (bool ok, bytes memory r) = target.call{value: value}(data); if (!ok) revert CallFailed(r); return r; } // ═════════════════════════════════ views function heldCount() external view returns (uint256) { return _held.length; } function getHeld(uint256 id) external view returns (address target, uint256 value, bytes memory data, uint256 eta, uint8 state) { Held storage h = _held[id]; return (h.target, h.value, h.data, h.eta, h.state); } /// @notice One call that gives a dashboard everything it needs. function status() external view returns ( address owner_, address agent_, bool halted_, uint256 balance_, Policy memory policy_, uint256 spentThisHour, uint256 spentToday, uint256 heldCount_ ) { owner_ = owner; agent_ = agent; halted_ = halted; balance_ = address(this).balance; policy_ = policy; spentThisHour = hourIndex == block.timestamp / 1 hours ? hourSpent : 0; spentToday = dayIndex == block.timestamp / 1 days ? daySpent : 0; heldCount_ = _held.length; } // ═════════════════════════════════ walls function _filter(address target, bytes memory data) internal view returns (bytes4 sel) { sel = data.length >= 4 ? bytes4(_word(data, 0)) : PLAIN_ETH; if (!allowed[target][sel]) revert NotAllowlisted(target, sel); if (sel == SEL_APPROVE || sel == SEL_INCREASE_ALLOWANCE) { if (data.length >= 68 && _uword(data, 36) == type(uint256).max) revert InfiniteApproval(); } else if (sel == SEL_SET_APPROVAL_ALL) { if (data.length >= 68 && _uword(data, 36) != 0) revert InfiniteApproval(); } } function _limitPerTx(uint256 value) internal view { if (value == 0) return; uint256 cap = policy.maxPerTx; if (value > cap) revert OverTxCap(value, cap); } function _limitWindows(uint256 value) internal { if (value == 0) return; uint256 h = block.timestamp / 1 hours; uint256 d = block.timestamp / 1 days; if (h != hourIndex) { hourIndex = h; hourSpent = 0; } if (d != dayIndex) { dayIndex = d; daySpent = 0; } uint256 nh = hourSpent + value; uint256 nd = daySpent + value; if (nh > policy.maxPerHour) revert OverHourCap(nh, policy.maxPerHour); if (nd > policy.maxPerDay) revert OverDayCap(nd, policy.maxPerDay); hourSpent = nh; daySpent = nd; } function _limitToken(address token, bytes4 sel, bytes memory data) internal { uint256 amount; if (sel == SEL_TRANSFER && data.length >= 68) { amount = _uword(data, 36); } else if (sel == SEL_TRANSFER_FROM && data.length >= 100) { amount = _uword(data, 68); } else { return; } uint256 d = block.timestamp / 1 days; uint256 wouldBe = tokenSpent[token][d] + amount; uint256 cap = tokenDayCap[token]; if (wouldBe > cap) revert OverTokenCap(token, wouldBe, cap); tokenSpent[token][d] = wouldBe; } function _word(bytes memory d, uint256 off) internal pure returns (bytes32 w) { assembly { w := mload(add(add(d, 32), off)) } } function _uword(bytes memory d, uint256 off) internal pure returns (uint256) { return uint256(_word(d, off)); } function _call(address target, uint256 value, bytes memory data) internal { (bool ok, bytes memory r) = target.call{value: value}(data); if (!ok) revert CallFailed(r); } }