// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import {Test} from "forge-std/Test.sol"; import {RobotWallet} from "../src/RobotWallet.sol"; contract MockUSD { mapping(address => uint256) public balanceOf; function mint(address to, uint256 amount) external { balanceOf[to] += amount; } function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; } } contract RobotWalletTest is Test { MockUSD usd; RobotWallet wallet; address owner = makeAddr("owner"); address robot = makeAddr("robot"); address charger = makeAddr("charger"); uint256 constant USD = 1e6; function setUp() public { vm.warp(1_790_000_000); usd = new MockUSD(); wallet = new RobotWallet(address(usd), owner, robot, 25 * USD); usd.mint(address(wallet), 100 * USD); } function test_robotPaysWithinLimit() public { vm.prank(robot); wallet.pay(charger, 10 * USD, "Charging"); assertEq(usd.balanceOf(charger), 10 * USD); assertEq(wallet.spentToday(), 10 * USD); assertEq(wallet.remainingToday(), 15 * USD); } function test_limitIsCumulativeAcrossPayments() public { vm.startPrank(robot); wallet.pay(charger, 20 * USD, ""); wallet.pay(charger, 5 * USD, ""); vm.expectRevert(abi.encodeWithSelector(RobotWallet.OverDailyLimit.selector, 0)); wallet.pay(charger, 1, ""); assertEq(usd.balanceOf(charger), 25 * USD); } function test_singlePaymentOverLimitReverts() public { vm.prank(robot); vm.expectRevert(abi.encodeWithSelector(RobotWallet.OverDailyLimit.selector, 25 * USD)); wallet.pay(charger, 25 * USD + 1, ""); } function test_limitResetsNextUtcDay() public { vm.startPrank(robot); wallet.pay(charger, 25 * USD, ""); vm.warp(block.timestamp + 1 days); assertEq(wallet.spentToday(), 0); wallet.pay(charger, 25 * USD, ""); assertEq(usd.balanceOf(charger), 50 * USD); } function test_onlyRobotCanPay() public { vm.expectRevert(RobotWallet.NotRobot.selector); vm.prank(owner); wallet.pay(charger, 1 * USD, ""); vm.expectRevert(RobotWallet.NotRobot.selector); wallet.pay(charger, 1 * USD, ""); } function test_pauseStopsSpending() public { vm.prank(owner); wallet.setPaused(true); assertEq(wallet.remainingToday(), 0); vm.prank(robot); vm.expectRevert(RobotWallet.Paused.selector); wallet.pay(charger, 1 * USD, ""); } function test_loweringLimitBelowSpentBlocksFurtherSpending() public { vm.prank(robot); wallet.pay(charger, 10 * USD, ""); vm.prank(owner); wallet.setDailyLimit(5 * USD); assertEq(wallet.remainingToday(), 0); vm.prank(robot); vm.expectRevert(abi.encodeWithSelector(RobotWallet.OverDailyLimit.selector, 0)); wallet.pay(charger, 1, ""); } function test_remainingIsCappedByBalance() public { vm.prank(owner); wallet.setDailyLimit(1000 * USD); assertEq(wallet.remainingToday(), 100 * USD); vm.prank(robot); vm.expectRevert(RobotWallet.TransferFailed.selector); wallet.pay(charger, 101 * USD, ""); } function test_ownerWithdrawsTokenAndEth() public { vm.deal(address(wallet), 1 ether); vm.startPrank(owner); wallet.withdraw(address(usd), owner, 100 * USD); wallet.withdraw(address(0), owner, 1 ether); assertEq(usd.balanceOf(owner), 100 * USD); assertEq(owner.balance, 1 ether); } function test_onlyOwnerAdministers() public { vm.startPrank(robot); vm.expectRevert(RobotWallet.NotOwner.selector); wallet.setDailyLimit(1e12); vm.expectRevert(RobotWallet.NotOwner.selector); wallet.setRobot(robot); vm.expectRevert(RobotWallet.NotOwner.selector); wallet.setPaused(false); vm.expectRevert(RobotWallet.NotOwner.selector); wallet.withdraw(address(usd), robot, 1); vm.expectRevert(RobotWallet.NotOwner.selector); wallet.transferOwnership(robot); } function test_replacingRobotKeyLocksOutTheOldOne() public { address newRobot = makeAddr("newRobot"); vm.prank(owner); wallet.setRobot(newRobot); vm.prank(robot); vm.expectRevert(RobotWallet.NotRobot.selector); wallet.pay(charger, 1 * USD, ""); vm.prank(newRobot); wallet.pay(charger, 1 * USD, ""); } function test_rejectsZeroAddressesAndAmounts() public { vm.expectRevert(RobotWallet.ZeroAddress.selector); new RobotWallet(address(usd), address(0), robot, 1); vm.startPrank(robot); vm.expectRevert(RobotWallet.ZeroAddress.selector); wallet.pay(address(0), 1, ""); vm.expectRevert(RobotWallet.ZeroAmount.selector); wallet.pay(charger, 0, ""); } function testFuzz_neverSpendsMoreThanLimitInADay(uint96 limit, uint96[8] memory amounts) public { vm.prank(owner); wallet.setDailyLimit(limit); usd.mint(address(wallet), type(uint128).max); uint256 total; vm.startPrank(robot); for (uint256 i; i < amounts.length; i++) { try wallet.pay(charger, amounts[i], "") { total += amounts[i]; } catch {} } assertLe(total, limit); assertEq(usd.balanceOf(charger), total); } } /// Runs against real USDG on a Robinhood Chain fork: /// forge test --match-contract Fork --fork-url https://rpc.mainnet.chain.robinhood.com contract RobotWalletForkTest is Test { address constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168; function test_paysRealUsdg() public { if (block.chainid != 4663) return; address robot = makeAddr("robot"); address charger = makeAddr("charger"); RobotWallet wallet = new RobotWallet(USDG, address(this), robot, 25e6); deal(USDG, address(wallet), 30e6); vm.prank(robot); wallet.pay(charger, 10e6, "Charging"); (, bytes memory ret) = USDG.staticcall(abi.encodeWithSignature("balanceOf(address)", charger)); assertEq(abi.decode(ret, (uint256)), 10e6); assertEq(wallet.remainingToday(), 15e6); wallet.withdraw(USDG, address(this), 20e6); assertEq(wallet.remainingToday(), 0); } }