// SPDX-License-Identifier: MIT pragma solidity 0.8.24; interface IERC20 { function balanceOf(address account) external view returns (uint256); } /// @title RobotWallet /// @notice Holds a robot's money. The robot's key can pay anyone, but only up to a /// daily limit the owner sets. The owner can change the limit, pause spending, /// replace the robot's key, and withdraw. /// @dev One wallet per robot, deployed by its owner. `token` is the stablecoin the /// limit is denominated in (USDG on Robinhood Chain). Days are UTC days. contract RobotWallet { address public immutable token; address public owner; address public robot; uint256 public dailyLimit; bool public paused; /// @dev The UTC day `spent` refers to. Spending resets when the day changes. uint64 private spentDay; uint256 private spent; event Paid(address indexed to, uint256 amount, string memo, uint256 spentToday); event LimitChanged(uint256 dailyLimit); event RobotChanged(address indexed robot); event PausedChanged(bool paused); event Withdrawn(address indexed asset, address indexed to, uint256 amount); event OwnerChanged(address indexed owner); error NotOwner(); error NotRobot(); error Paused(); error ZeroAddress(); error ZeroAmount(); error OverDailyLimit(uint256 remaining); error TransferFailed(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } constructor(address token_, address owner_, address robot_, uint256 dailyLimit_) { if (token_ == address(0) || owner_ == address(0) || robot_ == address(0)) revert ZeroAddress(); token = token_; owner = owner_; robot = robot_; dailyLimit = dailyLimit_; emit OwnerChanged(owner_); emit RobotChanged(robot_); emit LimitChanged(dailyLimit_); } receive() external payable {} // ------------------------------------------------------------------ robot /// @notice Pay `amount` of the token to `to`. Only the robot's key can call this. function pay(address to, uint256 amount, string calldata memo) external { if (msg.sender != robot) revert NotRobot(); if (paused) revert Paused(); if (to == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); uint256 already = spentToday(); if (amount > dailyLimit || already > dailyLimit - amount) { revert OverDailyLimit(dailyLimit > already ? dailyLimit - already : 0); } spentDay = today(); spent = already + amount; _send(token, to, amount); emit Paid(to, amount, memo, spent); } // ------------------------------------------------------------------ views /// @notice How much the robot has spent in the current UTC day. function spentToday() public view returns (uint256) { return spentDay == today() ? spent : 0; } /// @notice How much the robot can still spend today: the limit, capped by the balance. function remainingToday() external view returns (uint256) { if (paused) return 0; uint256 already = spentToday(); uint256 left = dailyLimit > already ? dailyLimit - already : 0; uint256 balance = IERC20(token).balanceOf(address(this)); return left < balance ? left : balance; } function today() private view returns (uint64) { return uint64(block.timestamp / 1 days); } // ------------------------------------------------------------------ owner function setDailyLimit(uint256 dailyLimit_) external onlyOwner { dailyLimit = dailyLimit_; emit LimitChanged(dailyLimit_); } /// @notice Replace the robot's key, e.g. after the old one leaks. function setRobot(address robot_) external onlyOwner { if (robot_ == address(0)) revert ZeroAddress(); robot = robot_; emit RobotChanged(robot_); } function setPaused(bool paused_) external onlyOwner { paused = paused_; emit PausedChanged(paused_); } /// @notice Withdraw any token, or ETH when `asset` is the zero address. function withdraw(address asset, address to, uint256 amount) external onlyOwner { if (to == address(0)) revert ZeroAddress(); if (asset == address(0)) { (bool ok,) = to.call{value: amount}(""); if (!ok) revert TransferFailed(); } else { _send(asset, to, amount); } emit Withdrawn(asset, to, amount); } function transferOwnership(address owner_) external onlyOwner { if (owner_ == address(0)) revert ZeroAddress(); owner = owner_; emit OwnerChanged(owner_); } /// @dev ERC-20 transfer that also accepts tokens returning nothing. function _send(address asset, address to, uint256 amount) private { (bool ok, bytes memory ret) = asset.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool))) || asset.code.length == 0) revert TransferFailed(); } }