// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol'; import {Math} from '@openzeppelin/contracts/utils/math/Math.sol'; /// @notice Bounded ERC20 transfers and swaps. No administrator or upgrade path. /// @dev An independent security audit has not been performed. contract PactMainnetVault is ReentrancyGuard { using SafeERC20 for IERC20; enum Action { Transfer, Swap } struct Grant { address owner; address agent; address recipient; uint256 budget; uint256 remaining; uint256 perCall; uint256 minRate; // output base units per input base unit, scaled by 1e18 uint64 expires; uint16 maxCalls; uint16 calls; Action action; bool closed; } IERC20 public immutable asset; IERC20 public immutable output; address public immutable router; bytes32 public immutable routerCodeHash; uint256 public nextId = 1; mapping(uint256 => Grant) public grants; mapping(address => uint256[]) private ownerIds; error InvalidPolicy(); error NotOwner(); error NotAgent(); error Inactive(); error Expired(); error OverBudget(); error PerCallLimit(); error StaleAction(); error InsufficientOutput(); error UnsupportedToken(); error InvalidRoute(); error SwapFailed(); event Created(uint256 indexed id, address indexed owner, address indexed agent, address recipient, uint256 budget, Action action, uint64 expires); event Executed(uint256 indexed id, uint16 indexed sequence, uint256 amount, uint256 outputAmount, address recipient, uint256 remaining); event Closed(uint256 indexed id, uint256 refunded, uint8 reason); constructor(address asset_, address output_, address router_) { require(asset_.code.length > 0 && output_.code.length > 0 && router_.code.length > 0, 'contracts required'); require(asset_ != output_ && asset_ != router_ && output_ != router_, 'distinct contracts'); asset = IERC20(asset_); output = IERC20(output_); router = router_; routerCodeHash = router_.codehash; } function create(address agent, address recipient, uint256 budget, uint256 perCall, uint64 expires, uint16 maxCalls, Action action, uint256 minRate) external nonReentrant returns (uint256 id) { if (agent == address(0) || recipient == address(0) || recipient == address(this) || recipient == router || budget == 0 || perCall == 0 || perCall > budget || expires <= block.timestamp || expires > block.timestamp + 7 days || maxCalls == 0 || maxCalls > 100 || (action == Action.Swap && minRate == 0) || (action == Action.Transfer && minRate != 0)) revert InvalidPolicy(); uint256 beforeBalance = asset.balanceOf(address(this)); asset.safeTransferFrom(msg.sender, address(this), budget); if (asset.balanceOf(address(this)) != beforeBalance + budget) revert UnsupportedToken(); id = nextId++; Grant storage g = grants[id]; g.owner = msg.sender; g.agent = agent; g.recipient = recipient; g.budget = budget; g.remaining = budget; g.perCall = perCall; g.minRate = minRate; g.expires = expires; g.maxCalls = maxCalls; g.action = action; ownerIds[msg.sender].push(id); emit Created(id, msg.sender, agent, recipient, budget, action, expires); } function execute(uint256 id, uint16 expectedCalls, uint256 amount, bytes calldata route, uint64 validUntil) external nonReentrant { Grant storage g = grants[id]; if (g.closed || g.owner == address(0)) revert Inactive(); if (msg.sender != g.agent) revert NotAgent(); if (block.timestamp >= g.expires || block.timestamp > validUntil) revert Expired(); if (g.calls != expectedCalls || g.calls >= g.maxCalls) revert StaleAction(); if (amount == 0 || amount > g.remaining) revert OverBudget(); if (amount > g.perCall) revert PerCallLimit(); g.remaining -= amount; g.calls++; uint256 beforeInput = asset.balanceOf(address(this)); uint256 received; if (g.action == Action.Transfer) { if (route.length != 0) revert InvalidRoute(); uint256 beforeRecipient = asset.balanceOf(g.recipient); asset.safeTransfer(g.recipient, amount); received = asset.balanceOf(g.recipient) - beforeRecipient; if (received != amount) revert UnsupportedToken(); } else { if (route.length < 4 || router.codehash != routerCodeHash) revert InvalidRoute(); uint256 beforeOutput = output.balanceOf(g.recipient); asset.forceApprove(router, amount); (bool ok,) = router.call(route); if (!ok) revert SwapFailed(); asset.forceApprove(router, 0); received = output.balanceOf(g.recipient) - beforeOutput; uint256 minimum = Math.mulDiv(amount, g.minRate, 1e18, Math.Rounding.Ceil); if (received < minimum) revert InsufficientOutput(); } // Partial fills, fee-on-transfer and unexpected changes to other budgets fail atomically. if (asset.balanceOf(address(this)) != beforeInput - amount) revert UnsupportedToken(); emit Executed(id, expectedCalls, amount, received, g.recipient, g.remaining); if (g.calls == g.maxCalls || g.remaining == 0) _close(id, 0); } function revoke(uint256 id) external nonReentrant { if (msg.sender != grants[id].owner) revert NotOwner(); _close(id, 1); } function reclaimExpired(uint256 id) external nonReentrant { Grant storage g = grants[id]; if (g.owner == address(0) || block.timestamp < g.expires) revert InvalidPolicy(); _close(id, 2); } function ownerGrantCount(address owner) external view returns (uint256) { return ownerIds[owner].length; } function ownerGrantIds(address owner, uint256 offset, uint256 limit) external view returns (uint256[] memory ids) { if (limit > 100) limit = 100; uint256 count = ownerIds[owner].length; if (offset >= count) return new uint256[](0); uint256 size = Math.min(limit, count - offset); ids = new uint256[](size); for (uint256 i; i < size; i++) ids[i] = ownerIds[owner][offset + i]; } function _close(uint256 id, uint8 reason) private { Grant storage g = grants[id]; if (g.closed || g.owner == address(0)) revert Inactive(); uint256 refund = g.remaining; g.remaining = 0; g.closed = true; if (refund > 0) asset.safeTransfer(g.owner, refund); emit Closed(id, refund, reason); } }