From 73caec55dfa6d4cc4c00be2ba2545dee0e2195b9 Mon Sep 17 00:00:00 2001
From: JA VI <127710324+JaDi03@users.noreply.github.com>
Date: Wed, 14 Oct 2026 02:05:55 -0600
Subject: [PATCH] feat: the agent moves the owner's USDC on Base and Ethereum
Sepolia into Gateway within the owner's limits
---
agent/prompt.py | 4 +-
agent/tools/definitions.py | 12 +-
agent/tools/toolbox.py | 35 ++++
agent/wiring.py | 6 +-
contracts/script/DeployFunding.s.sol | 31 ++++
contracts/src/ShopFunding.sol | 140 ++++++++++++++
contracts/src/ShopFundingFactory.sol | 54 ++++++
contracts/test/GatewayFork.t.sol | 47 +++++
contracts/test/ShopFunding.t.sol | 175 ++++++++++++++++++
dashboard/abi.js | 12 ++
dashboard/app.js | 34 ++++
dashboard/config.js | 7 +
dashboard/index.html | 7 +
dashboard/test/unified.test.mjs | 35 ++++
dashboard/unified.js | 87 ++++++++-
docs/architecture.md | 4 +-
docs/assets/connect_erpnext/api_access.png | Bin 0 -> 38657 bytes
.../connect_erpnext/create_contract.png | Bin 0 -> 39001 bytes
docs/assets/connect_erpnext/own_user.png | Bin 0 -> 53897 bytes
docs/assets/connect_erpnext/roles.png | Bin 0 -> 54453 bytes
docs/connect.md | 15 +-
docs/contracts.md | 22 +++
services/circle/client.py | 10 +
services/shops/activity.py | 6 +-
services/shops/agent.py | 8 +-
services/unified_balance/client.py | 14 ++
services/unified_balance/funding.py | 118 ++++++++++++
services/unified_balance/kit/unified.mjs | 10 +-
services/unified_balance/owner.py | 16 ++
tests/test_unified_balance.py | 77 +++++++-
30 files changed, 970 insertions(+), 16 deletions(-)
create mode 100644 contracts/script/DeployFunding.s.sol
create mode 100644 contracts/src/ShopFunding.sol
create mode 100644 contracts/src/ShopFundingFactory.sol
create mode 100644 contracts/test/GatewayFork.t.sol
create mode 100644 contracts/test/ShopFunding.t.sol
create mode 100644 docs/assets/connect_erpnext/api_access.png
create mode 100644 docs/assets/connect_erpnext/create_contract.png
create mode 100644 docs/assets/connect_erpnext/own_user.png
create mode 100644 docs/assets/connect_erpnext/roles.png
create mode 100644 services/unified_balance/funding.py
diff --git a/agent/prompt.py b/agent/prompt.py
index 2787347..b5a323c 100644
--- a/agent/prompt.py
+++ b/agent/prompt.py
@@ -49,7 +49,9 @@
options and your recommendation. When the owner's wallet on Arc holds less than what you would
pay, cash_position shows the USDC they keep on other chains: you may bring what is needed to
their wallet with bring_funds, choosing which chains it comes from (estimate_bring_funds shows
-the fees), and then pay.
+the fees), and then pay. USDC still in their own wallets there goes into their Unified Balance
+first with deposit_to_gateway; it can be spent only once that chain is final, so move it ahead
+of the due date and set an alarm to bring it.
Talking to the owner. One message, one topic: only what it is about, in one or two short
sentences, plain words. After a payment say what you paid (amount, supplier, invoice) and
diff --git a/agent/tools/definitions.py b/agent/tools/definitions.py
index fbca993..0a91c33 100644
--- a/agent/tools/definitions.py
+++ b/agent/tools/definitions.py
@@ -57,7 +57,8 @@ def _text(description: str) -> dict[str, Any]:
_tool("cash_position",
"The shop contract's limits, how much it still lets the shop pay this week, what you "
"chose to pay now in this session, the payments scheduled or due soon, and the USDC "
- "the owner keeps on other chains (their Unified Balance) with where you may spend it."),
+ "the owner keeps on other chains: in their Unified Balance (with where you may spend "
+ "it) and in their own wallets (with how much you may deposit into it)."),
_tool("pay_now",
"Pay this invoice now, for real: the payment goes out right away from the owner's "
"wallet through the shop contract (simulated first, then sent with your Circle "
@@ -74,6 +75,15 @@ def _text(description: str) -> dict[str, Any]:
_tool("agent_wallet",
"Your own wallet: its address, its balance for Arc network fees (paid in USDC), "
"whether the shop contract has it as its agent, and whether the contract is paused."),
+ _tool("deposit_to_gateway",
+ "Move the owner's USDC from their own wallet on another chain into their Unified "
+ "Balance (Circle Gateway), for real, through the owner's funding contract there: it "
+ "only ever deposits for the owner, within the limits the owner set. cash_position "
+ "says how much you may move on each chain. On Base Sepolia and Ethereum Sepolia the "
+ "deposit is spendable only once the chain is final (15 minutes or more): set an "
+ "alarm, then bring_funds. Returns the transaction, or the real error.",
+ chain=_text("The chain as cash_position lists it, e.g. Base_Sepolia."),
+ amount=_text("USDC to move, e.g. 120 or 30.5."), reason=REASON),
_tool("estimate_bring_funds",
"What it would cost to bring USDC from the owner's Unified Balance on other chains to "
"the owner's wallet on Arc, with the split you give: the fees and what would arrive. "
diff --git a/agent/tools/toolbox.py b/agent/tools/toolbox.py
index 67f3c76..8c6f3f0 100644
--- a/agent/tools/toolbox.py
+++ b/agent/tools/toolbox.py
@@ -221,8 +221,23 @@ def _cash_position(self) -> dict[str, Any]:
"today": self.today.isoformat(),
**(self._funds_once() or UNKNOWN_FUNDS),
"usdc_on_other_chains": self._other_chains(),
+ "usdc_in_owner_wallets_on_other_chains": self._owner_wallets(),
}
+ def _owner_wallets(self) -> list[dict[str, str]] | str:
+ """The owner's own wallets on other chains, and how much the agent may deposit."""
+ if self.owner_funds is None:
+ return "not connected for this shop"
+ try:
+ wallets = self.owner_funds.in_wallets()
+ except (ChainError, CircleError, PaymentSetupError, UnifiedBalanceError) as error:
+ return f"unknown: {error}"
+ rows = [{"chain": w.chain, "in_wallet": fmt(w.in_wallet),
+ "you_may_deposit_now": fmt(w.can_move) if w.funding
+ else "no: the owner sets up this chain in the dashboard"}
+ for w in wallets]
+ return rows or "no other chain is set up"
+
def _other_chains(self) -> list[dict[str, str]] | str:
"""The owner's Unified Balance, chain by chain, and where the agent may spend it."""
if self.owner_funds is None:
@@ -291,6 +306,26 @@ def _agent_wallet(self) -> dict[str, Any]:
except (ChainError, CircleError) as error:
raise ToolError(f"could not read the agent's wallet: {error}") from None
+ def _deposit_to_gateway(self, chain: str, amount: str, reason: str) -> dict[str, Any]:
+ if self.owner_funds is None:
+ raise ToolError("the owner's wallets on other chains are not connected")
+ value = _decimal(amount)
+ if value is None or value <= 0:
+ raise ToolError("amount is USDC above 0, e.g. 120 or 30.5")
+ if not self.can_pay:
+ raise ToolError("payments are paused by the owner; nothing was moved")
+ if len(self.funds_moved) >= MAX_FUND_MOVES:
+ raise ToolError(f"at most {MAX_FUND_MOVES} fund moves per session; look again later")
+ try:
+ done = self.owner_funds.deposit(chain.strip(), value)
+ except (ChainError, CircleError, PaymentSetupError, UnifiedBalanceError,
+ ValueError) as error:
+ raise ToolError(f"nothing was deposited: {error}") from None
+ moved = {"kind": "deposit", "chain": chain.strip(), "amount": fmt(value),
+ "state": done["state"], "tx": done["tx"], "reason": reason.strip()}
+ self.funds_moved.append(moved)
+ return moved
+
def _estimate_bring_funds(self, allocations: str) -> dict[str, Any]:
split = self._allocations(allocations)
try:
diff --git a/agent/wiring.py b/agent/wiring.py
index 6daacec..a18e88f 100644
--- a/agent/wiring.py
+++ b/agent/wiring.py
@@ -3,6 +3,7 @@
from collections.abc import Callable
from datetime import timezone
from decimal import Decimal
+from functools import cache
from agent.agent import CHAT_EFFORT, ClaudeBrain
from agent.guardrails.controls.base import fmt
@@ -11,6 +12,7 @@
from services.payments.encoding import USDC_DECIMALS
from services.settings import Settings
from services.unified_balance import OwnerFunds, UnifiedBalance
+from services.unified_balance.funding import OwnerWallets
def room_of(payer: Payer | None) -> Callable[[], Decimal | None] | None:
@@ -56,7 +58,9 @@ def owner_funds_of(settings: Settings, payer: Payer | None) -> OwnerFunds | None
"""The owner's Unified Balance on other chains, when the agent pays with its Circle wallet."""
if payer is None or not (settings.circle_api_key and settings.circle_entity_secret):
return None
- return OwnerFunds(UnifiedBalance(settings.circle_api_key, settings.circle_entity_secret), payer)
+ kit = UnifiedBalance(settings.circle_api_key, settings.circle_entity_secret)
+ facts = cache(kit.chains) # one Node run for the chains' facts, then reused
+ return OwnerFunds(kit, payer, OwnerWallets(payer.circle, facts))
def brain_setup(
diff --git a/contracts/script/DeployFunding.s.sol b/contracts/script/DeployFunding.s.sol
new file mode 100644
index 0000000..81bcf23
--- /dev/null
+++ b/contracts/script/DeployFunding.s.sol
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {Script, console} from "forge-std/Script.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {IGatewayDeposits} from "../src/ShopFunding.sol";
+import {ShopFundingFactory} from "../src/ShopFundingFactory.sol";
+
+/// @notice Deploys the funding factory on a source chain of the owner's Unified Balance. Owners
+/// then create their own ShopFunding from their wallets; the deployer controls nothing.
+/// USDC and Circle's GatewayWallet addresses come from Circle's Unified Balance Kit.
+///
+/// forge script script/DeployFunding.s.sol --rpc-url https://sepolia.base.org \
+/// --broadcast --account
+contract DeployFunding is Script {
+ address constant GATEWAY_WALLET_TESTNET = 0x0077777d7EBA4688BDeF3E311b846F25870A19B9;
+
+ function usdcFor(uint256 chainId) internal pure returns (address) {
+ if (chainId == 84532) return 0x036CbD53842c5426634e7929541eC2318f3dCF7e; // Base Sepolia
+ if (chainId == 11155111) return 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238; // Ethereum Sepolia
+ revert("no USDC known for this chain");
+ }
+
+ function run() external returns (ShopFundingFactory factory) {
+ address usdc = usdcFor(block.chainid);
+ vm.startBroadcast();
+ factory = new ShopFundingFactory(IERC20(usdc), IGatewayDeposits(GATEWAY_WALLET_TESTNET));
+ vm.stopBroadcast();
+ console.log("ShopFundingFactory:", address(factory));
+ }
+}
diff --git a/contracts/src/ShopFunding.sol b/contracts/src/ShopFunding.sol
new file mode 100644
index 0000000..9ad90cc
--- /dev/null
+++ b/contracts/src/ShopFunding.sol
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+
+/// @dev The deposit entry of Circle Gateway's GatewayWallet (circlefin/evm-gateway-contracts,
+/// src/modules/wallet/Deposits.sol): takes `value` of `token` from the caller and credits it
+/// to `depositor`'s Gateway balance.
+interface IGatewayDeposits {
+ function depositFor(address token, address depositor, uint256 value) external;
+}
+
+/// @title ShopFunding
+/// @notice Lets a payables agent move the shop owner's USDC on this chain into the owner's own
+/// Circle Gateway balance, within limits only the owner can change. From there the agent,
+/// as the owner's delegate, brings it to the owner's wallet on Arc to pay invoices.
+/// @dev The money never stays here: one call takes it from the owner's wallet (the owner
+/// approves this contract, and revoking that approval stops it) and deposits it for the
+/// owner in the same transaction. There is no other destination. The agent is held to a
+/// per-move and a weekly cap; the owner is not. Ownership is fixed.
+contract ShopFunding is Ownable, Pausable, ReentrancyGuard {
+ using SafeERC20 for IERC20;
+
+ IERC20 public immutable usdc;
+ IGatewayDeposits public immutable gateway;
+ uint256 public immutable startedAt;
+
+ address public agent;
+ uint256 public maxPerMove;
+ uint256 public weeklyCap;
+
+ mapping(uint256 week => uint256 amount) public agentMovedInWeek;
+
+ event Funded(uint256 amount, address indexed by);
+ event LimitsChanged(uint256 maxPerMove, uint256 weeklyCap);
+ event AgentChanged(address indexed agent);
+
+ error NotAuthorized();
+ error OverMoveLimit(uint256 amount, uint256 maxPerMove);
+ error OverWeeklyCap(uint256 amount, uint256 remaining);
+ error InvalidLimits();
+ error InvalidAddress();
+ error InvalidAmount();
+ error OwnershipFixed();
+
+ constructor(
+ IERC20 usdc_,
+ IGatewayDeposits gateway_,
+ address owner_,
+ address agent_,
+ uint256 maxPerMove_,
+ uint256 weeklyCap_
+ ) Ownable(owner_) {
+ if (address(usdc_) == address(0) || address(gateway_) == address(0)) {
+ revert InvalidAddress();
+ }
+ usdc = usdc_;
+ gateway = gateway_;
+ startedAt = block.timestamp;
+ _setAgent(agent_);
+ _setLimits(maxPerMove_, weeklyCap_);
+ }
+
+ /// @notice Move `amount` of the owner's USDC into the owner's Gateway balance.
+ function fund(uint256 amount) external nonReentrant {
+ if (amount == 0) revert InvalidAmount();
+ if (msg.sender == agent) {
+ _checkAgentMove(amount);
+ } else if (msg.sender != owner()) {
+ revert NotAuthorized();
+ }
+ address owner_ = owner();
+ emit Funded(amount, msg.sender);
+ usdc.safeTransferFrom(owner_, address(this), amount);
+ usdc.forceApprove(address(gateway), amount);
+ gateway.depositFor(address(usdc), owner_, amount);
+ }
+
+ /// @notice Stop the agent. The owner or the agent itself can pull this brake.
+ function pause() external {
+ if (msg.sender != owner() && msg.sender != agent) revert NotAuthorized();
+ if (!paused()) _pause();
+ }
+
+ /// @notice Only the owner can let the agent move funds again.
+ function unpause() external onlyOwner {
+ _unpause();
+ }
+
+ function setLimits(uint256 maxPerMove_, uint256 weeklyCap_) external onlyOwner {
+ _setLimits(maxPerMove_, weeklyCap_);
+ }
+
+ function setAgent(address agent_) external onlyOwner {
+ _setAgent(agent_);
+ }
+
+ function currentWeek() public view returns (uint256) {
+ return (block.timestamp - startedAt) / 1 weeks;
+ }
+
+ /// @notice How much the agent may still move this week.
+ function remainingThisWeek() public view returns (uint256) {
+ uint256 moved = agentMovedInWeek[currentWeek()];
+ return moved >= weeklyCap ? 0 : weeklyCap - moved;
+ }
+
+ function transferOwnership(address) public view override onlyOwner {
+ revert OwnershipFixed();
+ }
+
+ function renounceOwnership() public view override onlyOwner {
+ revert OwnershipFixed();
+ }
+
+ function _checkAgentMove(uint256 amount) private {
+ _requireNotPaused();
+ if (amount > maxPerMove) revert OverMoveLimit(amount, maxPerMove);
+ uint256 remaining = remainingThisWeek();
+ if (amount > remaining) revert OverWeeklyCap(amount, remaining);
+ agentMovedInWeek[currentWeek()] += amount;
+ }
+
+ function _setLimits(uint256 maxPerMove_, uint256 weeklyCap_) private {
+ if (maxPerMove_ == 0 || maxPerMove_ > weeklyCap_) revert InvalidLimits();
+ maxPerMove = maxPerMove_;
+ weeklyCap = weeklyCap_;
+ emit LimitsChanged(maxPerMove_, weeklyCap_);
+ }
+
+ function _setAgent(address agent_) private {
+ if (agent_ == owner()) revert InvalidAddress();
+ agent = agent_;
+ emit AgentChanged(agent_);
+ }
+}
diff --git a/contracts/src/ShopFundingFactory.sol b/contracts/src/ShopFundingFactory.sol
new file mode 100644
index 0000000..ff4f43f
--- /dev/null
+++ b/contracts/src/ShopFundingFactory.sol
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";
+import {IGatewayDeposits, ShopFunding} from "./ShopFunding.sol";
+
+/// @title ShopFundingFactory
+/// @notice Each shop owner creates their own ShopFunding on this chain, with their own wallet.
+/// The caller becomes the owner; the factory keeps no control over what it creates and
+/// only lists them so the agent and the dashboard can find them. Each one is created
+/// with CREATE2 under the owner's address, so its address is known before it exists:
+/// the dashboard can create it and approve it in one batch the owner confirms once.
+contract ShopFundingFactory {
+ IERC20 public immutable usdc;
+ IGatewayDeposits public immutable gateway;
+
+ mapping(address owner => address[] fundings) private _fundingsOf;
+
+ event FundingCreated(
+ address indexed owner, address indexed funding, address agent, uint256 maxPerMove, uint256 weeklyCap
+ );
+
+ constructor(IERC20 usdc_, IGatewayDeposits gateway_) {
+ usdc = usdc_;
+ gateway = gateway_;
+ }
+
+ function create(address agent, uint256 maxPerMove, uint256 weeklyCap) external returns (ShopFunding funding) {
+ funding = new ShopFunding{salt: _salt(msg.sender)}(usdc, gateway, msg.sender, agent, maxPerMove, weeklyCap);
+ _fundingsOf[msg.sender].push(address(funding));
+ emit FundingCreated(msg.sender, address(funding), agent, maxPerMove, weeklyCap);
+ }
+
+ function fundingsOf(address owner) external view returns (address[] memory) {
+ return _fundingsOf[owner];
+ }
+
+ /// @notice The address `create` gives the owner's ShopFunding with these settings.
+ function predict(address owner, address agent, uint256 maxPerMove, uint256 weeklyCap)
+ external
+ view
+ returns (address)
+ {
+ bytes memory init = abi.encodePacked(
+ type(ShopFunding).creationCode, abi.encode(usdc, gateway, owner, agent, maxPerMove, weeklyCap)
+ );
+ return Create2.computeAddress(_salt(owner), keccak256(init));
+ }
+
+ function _salt(address owner) private pure returns (bytes32) {
+ return bytes32(uint256(uint160(owner)));
+ }
+}
diff --git a/contracts/test/GatewayFork.t.sol b/contracts/test/GatewayFork.t.sol
new file mode 100644
index 0000000..385058f
--- /dev/null
+++ b/contracts/test/GatewayFork.t.sol
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {Test} from "forge-std/Test.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {IGatewayDeposits, ShopFunding} from "../src/ShopFunding.sol";
+import {ShopFundingFactory} from "../src/ShopFundingFactory.sol";
+
+/// @dev The balance view of Circle's GatewayWallet (src/modules/wallet/Balances.sol).
+interface IGatewayBalances {
+ function availableBalance(address token, address depositor) external view returns (uint256);
+}
+
+/// @notice Runs against a fork of Base Sepolia with Circle's real GatewayWallet and USDC (addresses
+/// from Circle's Unified Balance Kit). Skipped unless BASE_SEPOLIA_RPC_URL is set.
+contract GatewayForkTest is Test {
+ IERC20 constant USDC = IERC20(0x036CbD53842c5426634e7929541eC2318f3dCF7e);
+ address constant GATEWAY_WALLET = 0x0077777d7EBA4688BDeF3E311b846F25870A19B9;
+
+ address owner = makeAddr("fork-owner");
+ address agent = makeAddr("fork-agent");
+
+ function test_the_agent_deposits_the_owner_usdc_into_the_owner_gateway_balance() public {
+ string memory rpc = vm.envOr("BASE_SEPOLIA_RPC_URL", string(""));
+ if (bytes(rpc).length == 0) {
+ vm.skip(true);
+ }
+ vm.createSelectFork(rpc);
+ vm.prank(GATEWAY_WALLET); // it holds every deposit: a source of test USDC for the owner
+ assertTrue(USDC.transfer(owner, 100e6));
+
+ ShopFundingFactory factory = new ShopFundingFactory(USDC, IGatewayDeposits(GATEWAY_WALLET));
+ vm.startPrank(owner);
+ ShopFunding funding = factory.create(agent, 50e6, 80e6);
+ USDC.approve(address(funding), 100e6);
+ vm.stopPrank();
+
+ uint256 before = IGatewayBalances(GATEWAY_WALLET).availableBalance(address(USDC), owner);
+ vm.prank(agent);
+ funding.fund(40_250_000);
+
+ assertEq(IGatewayBalances(GATEWAY_WALLET).availableBalance(address(USDC), owner), before + 40_250_000);
+ assertEq(IGatewayBalances(GATEWAY_WALLET).availableBalance(address(USDC), agent), 0);
+ assertEq(USDC.balanceOf(owner), 100e6 - 40_250_000);
+ assertEq(USDC.balanceOf(address(funding)), 0);
+ }
+}
diff --git a/contracts/test/ShopFunding.t.sol b/contracts/test/ShopFunding.t.sol
new file mode 100644
index 0000000..41cc2e8
--- /dev/null
+++ b/contracts/test/ShopFunding.t.sol
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {Test} from "forge-std/Test.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
+import {IGatewayDeposits, ShopFunding} from "../src/ShopFunding.sol";
+import {ShopFundingFactory} from "../src/ShopFundingFactory.sol";
+import {MockUSDC} from "./MockUSDC.sol";
+
+/// @dev Stands in for Circle's GatewayWallet: takes the tokens from the caller and credits the
+/// depositor, as depositFor does.
+contract MockGateway is IGatewayDeposits {
+ mapping(address depositor => uint256 amount) public credited;
+
+ function depositFor(address token, address depositor, uint256 value) external {
+ MockUSDC(token).transferFrom(msg.sender, address(this), value);
+ credited[depositor] += value;
+ }
+}
+
+contract ShopFundingTest is Test {
+ uint256 constant USDC = 1e6;
+ uint256 constant MAX_PER_MOVE = 200 * USDC;
+ uint256 constant WEEKLY_CAP = 500 * USDC;
+
+ MockUSDC usdc;
+ MockGateway gateway;
+ ShopFundingFactory factory;
+ ShopFunding funding;
+
+ address owner = makeAddr("owner");
+ address agent = makeAddr("agent");
+ address stranger = makeAddr("stranger");
+
+ function setUp() public {
+ usdc = new MockUSDC();
+ gateway = new MockGateway();
+ factory = new ShopFundingFactory(IERC20(address(usdc)), gateway);
+ vm.prank(owner);
+ funding = factory.create(agent, MAX_PER_MOVE, WEEKLY_CAP);
+ usdc.mint(owner, 10_000 * USDC);
+ vm.prank(owner);
+ usdc.approve(address(funding), 5_000 * USDC);
+ }
+
+ function agentFunds(uint256 amount) internal {
+ vm.prank(agent);
+ funding.fund(amount);
+ }
+
+ function test_the_caller_of_the_factory_owns_its_funding() public view {
+ assertEq(funding.owner(), owner);
+ assertEq(funding.agent(), agent);
+ assertEq(factory.fundingsOf(owner)[0], address(funding));
+ }
+
+ function test_its_address_is_known_before_it_is_created() public {
+ address next = factory.predict(stranger, agent, MAX_PER_MOVE, WEEKLY_CAP);
+ assertEq(next.code.length, 0);
+ vm.prank(stranger);
+ ShopFunding created = factory.create(agent, MAX_PER_MOVE, WEEKLY_CAP);
+ assertEq(address(created), next);
+ assertEq(created.owner(), stranger);
+ assertEq(factory.predict(owner, agent, MAX_PER_MOVE, WEEKLY_CAP), address(funding));
+ }
+
+ function test_the_same_owner_and_settings_cannot_create_twice() public {
+ vm.prank(owner);
+ vm.expectRevert();
+ factory.create(agent, MAX_PER_MOVE, WEEKLY_CAP);
+ }
+
+ function test_the_agent_moves_the_owner_usdc_into_the_owner_gateway_balance() public {
+ vm.expectEmit(address(funding));
+ emit ShopFunding.Funded(150 * USDC, agent);
+ agentFunds(150 * USDC);
+ assertEq(gateway.credited(owner), 150 * USDC);
+ assertEq(gateway.credited(agent), 0);
+ assertEq(usdc.balanceOf(owner), 9_850 * USDC);
+ assertEq(usdc.balanceOf(address(funding)), 0);
+ assertEq(funding.remainingThisWeek(), 350 * USDC);
+ }
+
+ function test_the_agent_is_held_to_the_per_move_limit() public {
+ vm.prank(agent);
+ vm.expectRevert(abi.encodeWithSelector(ShopFunding.OverMoveLimit.selector, 201 * USDC, MAX_PER_MOVE));
+ funding.fund(201 * USDC);
+ }
+
+ function test_the_agent_is_held_to_the_weekly_cap_until_the_week_renews() public {
+ agentFunds(200 * USDC);
+ agentFunds(200 * USDC);
+ vm.prank(agent);
+ vm.expectRevert(abi.encodeWithSelector(ShopFunding.OverWeeklyCap.selector, 150 * USDC, 100 * USDC));
+ funding.fund(150 * USDC);
+ vm.warp(block.timestamp + 1 weeks);
+ agentFunds(150 * USDC);
+ assertEq(gateway.credited(owner), 550 * USDC);
+ }
+
+ function test_the_owner_is_not_held_to_the_limits() public {
+ vm.prank(owner);
+ funding.fund(900 * USDC);
+ assertEq(gateway.credited(owner), 900 * USDC);
+ assertEq(funding.remainingThisWeek(), WEEKLY_CAP);
+ }
+
+ function test_nobody_else_can_move_the_owner_funds() public {
+ vm.prank(stranger);
+ vm.expectRevert(ShopFunding.NotAuthorized.selector);
+ funding.fund(1 * USDC);
+ }
+
+ function test_revoking_the_approval_stops_the_agent() public {
+ vm.prank(owner);
+ usdc.approve(address(funding), 0);
+ vm.prank(agent);
+ vm.expectRevert();
+ funding.fund(1 * USDC);
+ }
+
+ function test_a_pause_stops_the_agent_and_only_the_owner_resumes() public {
+ vm.prank(agent);
+ funding.pause();
+ vm.prank(agent);
+ vm.expectRevert(Pausable.EnforcedPause.selector);
+ funding.fund(1 * USDC);
+ vm.prank(agent);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, agent));
+ funding.unpause();
+ vm.prank(owner);
+ funding.unpause();
+ agentFunds(1 * USDC);
+ }
+
+ function test_only_the_owner_changes_limits_and_agent() public {
+ vm.prank(agent);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, agent));
+ funding.setLimits(1_000 * USDC, 2_000 * USDC);
+ vm.startPrank(owner);
+ funding.setLimits(50 * USDC, 100 * USDC);
+ funding.setAgent(stranger);
+ vm.stopPrank();
+ vm.prank(agent);
+ vm.expectRevert(ShopFunding.NotAuthorized.selector);
+ funding.fund(1 * USDC);
+ }
+
+ function test_ownership_is_fixed() public {
+ vm.prank(owner);
+ vm.expectRevert(ShopFunding.OwnershipFixed.selector);
+ funding.transferOwnership(stranger);
+ }
+
+ function test_zero_and_bad_limits_are_refused() public {
+ vm.prank(agent);
+ vm.expectRevert(ShopFunding.InvalidAmount.selector);
+ funding.fund(0);
+ vm.prank(owner);
+ vm.expectRevert(ShopFunding.InvalidLimits.selector);
+ funding.setLimits(600 * USDC, 500 * USDC);
+ }
+
+ function testFuzz_the_agent_never_moves_more_than_the_week_allows(uint256 a, uint256 b) public {
+ a = bound(a, 1, MAX_PER_MOVE);
+ b = bound(b, 1, MAX_PER_MOVE);
+ agentFunds(a);
+ vm.prank(agent);
+ if (a + b > WEEKLY_CAP) vm.expectRevert();
+ funding.fund(b);
+ assertLe(gateway.credited(owner), WEEKLY_CAP);
+ }
+}
diff --git a/dashboard/abi.js b/dashboard/abi.js
index 38231ca..fcc5786 100644
--- a/dashboard/abi.js
+++ b/dashboard/abi.js
@@ -41,6 +41,18 @@ export const SHOP_ABI = [
"error ReentrancyGuardReentrantCall()",
];
+// contracts/src/ShopFundingFactory.sol: one ShopFunding per owner on each source chain.
+export const FUNDING_FACTORY_ABI = [
+ "function create(address agent, uint256 maxPerMove, uint256 weeklyCap) returns (address)",
+ "function fundingsOf(address owner) view returns (address[])",
+ "function predict(address owner, address agent, uint256 maxPerMove, uint256 weeklyCap) view returns (address)",
+ "error InvalidLimits()",
+ "error InvalidAddress()",
+];
+
+// Circle Gateway's GatewayWallet (circlefin/evm-gateway-contracts, modules/wallet/Delegation.sol).
+export const GATEWAY_WALLET_ABI = ["function addDelegate(address token, address delegate)"];
+
export const USDC_ABI = [
"function balanceOf(address) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
diff --git a/dashboard/app.js b/dashboard/app.js
index 495beba..4fe4222 100644
--- a/dashboard/app.js
+++ b/dashboard/app.js
@@ -206,6 +206,19 @@ function renderUnified(isOwner, service) {
select.replaceChildren(...view.rows.map((r) => new Option(r.name, r.chain)));
if (view.rows.some((r) => r.chain === picked)) select.value = picked;
$("unified-form").hidden = !isOwner || view.rows.length === 0;
+ const wallets = unified.walletsView(service?.last_run);
+ $("funding-wallets").replaceChildren(
+ ...wallets.map((w) => {
+ const item = document.createElement("li");
+ item.textContent = w.setUp
+ ? `${w.name}: ${formatUsdc(w.inWallet)} USDC in your wallet · the agent may move ${formatUsdc(w.agentMayMove)} now`
+ : `${w.name}: ${formatUsdc(w.inWallet)} USDC in your wallet · not set up for the agent`;
+ return item;
+ }),
+ );
+ const chains = unified.fundingChains();
+ $("funding-chain").replaceChildren(...chains.map((c) => new Option(c.replaceAll("_", " "), c)));
+ $("funding-form").hidden = !isOwner || chains.length === 0;
}
async function unifiedAct(button, label, run) {
@@ -1066,6 +1079,27 @@ function wireActions() {
unifiedAct(button, "Deposit", (provider) => unified.deposit(provider, chain, amount)).then(() => form.reset());
});
+ onSubmit("funding-form", (data, button, form) => {
+ if (!session.agent) {
+ notify("Set the agent wallet in Settings first.", "error");
+ return;
+ }
+ let maxPerMove;
+ let weeklyCap;
+ try {
+ maxPerMove = parseUsdc(data.get("maxPerMove"));
+ weeklyCap = parseUsdc(data.get("weeklyCap"));
+ if (maxPerMove > weeklyCap) throw new Error("The per-move limit cannot be above the weekly limit.");
+ } catch (error) {
+ notify(describeError(error), "error");
+ return;
+ }
+ const chain = data.get("chain");
+ unifiedAct(button, "Let the agent move USDC (one confirmation if your wallet batches, else three)", (provider) =>
+ unified.setUpChain(provider, chain, { agent: session.agent, maxPerMove, weeklyCap }),
+ ).then(() => form.reset());
+ });
+
$("unified-authorize").addEventListener("click", (event) => {
if (!session.agent) {
notify("Set the agent wallet in Settings first.", "error");
diff --git a/dashboard/config.js b/dashboard/config.js
index 780858d..d0e3496 100644
--- a/dashboard/config.js
+++ b/dashboard/config.js
@@ -40,6 +40,13 @@ export function rpcUrl(origin = window.location.origin) {
export const FAUCET_URL = "https://faucet.circle.com";
+// ShopFundingFactory on each source chain of the owner's Unified Balance (contracts/script/
+// DeployFunding.s.sol). A chain without one is not offered for the agent to move funds from.
+export const FUNDING_FACTORIES = { Base_Sepolia: "0x69e31Af302Ee222204AA78099D39a2956F5C95d6", Ethereum_Sepolia: "0x69e31Af302Ee222204AA78099D39a2956F5C95d6" };
+
+// Circle Gateway's GatewayWallet, the same address on every EVM testnet (Circle's Gateway docs).
+export const GATEWAY_WALLET_TESTNET = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
+
// Wallets the agent is waiting to pay, written by the agent on this site.
export const APPROVALS_PATH = "/pending.json";
diff --git a/dashboard/index.html b/dashboard/index.html
index ab7939e..15884e0 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -303,6 +303,13 @@ USDC on other chains
+
+