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

+ +
diff --git a/dashboard/test/unified.test.mjs b/dashboard/test/unified.test.mjs index d5db69d..3a013cd 100644 --- a/dashboard/test/unified.test.mjs +++ b/dashboard/test/unified.test.mjs @@ -31,3 +31,38 @@ test("not connected or unreadable is said plainly, with no rows", () => { assert.equal(failed.state, "error"); assert.equal(failed.text, "Could not read it: the Unified Balance Kit did not run"); }); + +test("only chains with a funding factory are offered", async () => { + const { fundingChains } = await import("../unified.js"); + assert.deepEqual(fundingChains({ Base_Sepolia: "0xabc", Ethereum_Sepolia: "" }), ["Base_Sepolia"]); +}); + +test("the owner's wallets say whether each chain is set up and what the agent may move", async () => { + const { walletsView } = await import("../unified.js"); + const rows = walletsView({ unified_balance: { wallets: [ + { chain: "Base_Sepolia", in_wallet: "400", funding: "0xf1", approved: "1000", agent_may_move: "150" }, + { chain: "Ethereum_Sepolia", in_wallet: "80", funding: null, approved: "0", agent_may_move: "0" }, + ] } }); + assert.deepEqual(rows.map((r) => [r.name, r.inWallet, r.setUp, r.agentMayMove]), [ + ["Base Sepolia", 400000000n, true, 150000000n], + ["Ethereum Sepolia", 80000000n, false, 0n], + ]); + assert.deepEqual(walletsView({ unified_balance: { wallets: { error: "down" } } }), []); +}); + +test("setting up a chain is create, approve and authorize, skipping what is done", async () => { + const { setupCalls } = await import("../unified.js"); + const base = { + factory: "0x" + "fa".repeat(20), funding: "0x" + "f1".repeat(20), usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + gateway: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", agent: "0x" + "a9".repeat(20), + maxPerMove: 200000000n, weeklyCap: 500000000n, + }; + const all = setupCalls({ ...base, create: true, delegate: true }); + assert.deepEqual(all.map((c) => [c.to, c.data.slice(0, 10)]), [ + [base.factory, "0x5165da30"], // create(address,uint256,uint256) + [base.usdc, "0x095ea7b3"], // approve(address,uint256) + [base.gateway, "0xe909ebfa"], // addDelegate(address,address) + ]); + assert.ok(all[1].data.includes(base.funding.slice(2))); // approves the owner's own funding contract + assert.deepEqual(setupCalls({ ...base, create: false, delegate: false }).map((c) => c.to), [base.usdc]); +}); diff --git a/dashboard/unified.js b/dashboard/unified.js index 7408014..ad2e7ba 100644 --- a/dashboard/unified.js +++ b/dashboard/unified.js @@ -3,7 +3,10 @@ // authorizes the agent on a chain, with their own browser wallet, through Circle's Unified // Balance Kit. The kit is loaded only when the owner does one of these. -import { formatUsdc, parseUsdc } from "./units.js"; +import { ethers } from "./vendor/ethers-6.17.0.min.js"; +import { FUNDING_FACTORY_ABI, GATEWAY_WALLET_ABI, USDC_ABI } from "./abi.js"; +import { FUNDING_FACTORIES, GATEWAY_WALLET_TESTNET } from "./config.js"; +import { AUTHORIZE_ALL, formatUsdc, parseUsdc } from "./units.js"; const KIT = "./vendor/unified-balance-kit-1.8.0/index.js"; let loaded = null; @@ -59,6 +62,88 @@ export async function deposit(provider, chain, amount) { return new UnifiedBalanceKit().deposit({ from: { adapter, chain }, amount, token: "USDC" }); } +/** The chains where the owner can let the agent move funds: those with a funding factory. */ +export function fundingChains(factories = FUNDING_FACTORIES) { + return Object.entries(factories).filter(([, address]) => address).map(([chain]) => chain); +} + +/** The owner's wallets on other chains, from the agent's last check. */ +export function walletsView(lastRun) { + const wallets = lastRun?.unified_balance?.wallets; + if (!Array.isArray(wallets)) return []; + return wallets.map((w) => ({ + chain: w.chain, + name: w.chain.replaceAll("_", " "), + inWallet: units(w.in_wallet), + setUp: Boolean(w.funding), + agentMayMove: units(w.agent_may_move), + })); +} + +const BATCH_POLL_MS = 2000; +const BATCH_WAIT_MS = 180000; + +/** The calls that let the agent move funds on a chain, in order: create the owner's ShopFunding + * (skipped when it exists), approve it, and authorize the agent in Gateway (skipped when it is). */ +export function setupCalls({ factory, funding, create, usdc, gateway, agent, maxPerMove, weeklyCap, delegate }) { + const calls = []; + if (create) { + const data = new ethers.Interface(FUNDING_FACTORY_ABI).encodeFunctionData("create", [agent, maxPerMove, weeklyCap]); + calls.push({ to: factory, data, value: "0x0" }); + } + calls.push({ to: usdc, data: new ethers.Interface(USDC_ABI).encodeFunctionData("approve", [funding, AUTHORIZE_ALL]), value: "0x0" }); + if (delegate) { + calls.push({ to: gateway, data: new ethers.Interface(GATEWAY_WALLET_ABI).encodeFunctionData("addDelegate", [usdc, agent]), value: "0x0" }); + } + return calls; +} + +/** Wait for a batch sent with wallet_sendCalls (EIP-5792): 200 is confirmed, 400 and up failed. */ +async function waitForCalls(provider, id) { + for (let waited = 0; waited < BATCH_WAIT_MS; waited += BATCH_POLL_MS) { + const status = await provider.request({ method: "wallet_getCallsStatus", params: [id] }); + if (status.status === 200) return status; + if (status.status >= 400) throw new Error(`The wallet could not complete it (status ${status.status}).`); + await new Promise((resolve) => setTimeout(resolve, BATCH_POLL_MS)); + } + throw new Error("Still waiting for the chain; check your wallet's activity, then look again."); +} + +/** Let the agent move the owner's USDC on this chain into the owner's Unified Balance, within + * limits: create the owner's ShopFunding there, approve it, and authorize the agent in Gateway. + * One confirmation when the wallet batches calls (EIP-5792, MetaMask's smart account through + * EIP-7702); otherwise one per step. */ +export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap }, factories = FUNDING_FACTORIES) { + const { UnifiedBalanceKit, resolveChainIdentifier } = await kit(); + const definition = resolveChainIdentifier(chain); + const adapter = await adapterOn(provider, chain); // the wallet is now on that chain + const browser = new ethers.BrowserProvider(provider); + const signer = await browser.getSigner(); + const owner = await signer.getAddress(); + const factory = new ethers.Contract(factories[chain], FUNDING_FACTORY_ABI, browser); + const existing = (await factory.fundingsOf(owner)).at(-1); + const funding = existing ?? (await factory.predict(owner, agent, maxPerMove, weeklyCap)); + const delegate = await new UnifiedBalanceKit().getDelegateStatus({ from: { adapter, chain }, delegateAddress: agent }); + const calls = setupCalls({ + factory: factories[chain], funding, create: !existing, usdc: definition.usdcAddress, + gateway: GATEWAY_WALLET_TESTNET, agent, maxPerMove, weeklyCap, delegate: delegate === "none", + }); + const chainId = ethers.toQuantity(definition.chainId); + const capabilities = await provider.request({ method: "wallet_getCapabilities", params: [owner, [chainId]] }) + .catch(() => ({})); + const atomic = capabilities?.[chainId]?.atomic?.status; + if (atomic === "supported" || atomic === "ready") { + const sent = await provider.request({ + method: "wallet_sendCalls", + params: [{ version: "2.0.0", from: owner, chainId, atomicRequired: true, calls }], + }); + await waitForCalls(provider, sent.id ?? sent.batchId); + } else { + for (const call of calls) await (await signer.sendTransaction({ to: call.to, data: call.data })).wait(); + } + return funding; +} + /** Let the agent's wallet spend the owner's Unified Balance on this chain (only to the owner). */ export async function authorizeAgent(provider, chain, agent) { const { UnifiedBalanceKit } = await kit(); diff --git a/docs/architecture.md b/docs/architecture.md index 2b153fd..aa653fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ The contracts live in `contracts/`. |---|---| | `agent/agent.py`, `prompt.py`, `think.py`, `plan.py` | The brain. When a reflex wakes it, the model (today Claude Haiku 5.5, at high effort for its rounds and medium when it answers the owner) reads a short briefing and calls tools until every open invoice has its decision: pay now, pay on a date, hold, or ask the owner, each with a reason, plus alarms and notes for later and a summary for the owner. It pays during the session and reads what happened (paid with its transaction, refused by the contract, or Circle's own error), then acts on it: looks up why, holds, or tells the owner what only they can fix. Capped per session (turns, tokens, 10 payments) and per day (USD 1 of model use by default, estimated from the tokens and the public prices; past it the agent is not woken until the next day, and the owner is told once). A payment it sent stands even if the session is cut off; its other choices count once the session finishes. The rules still decide what may be paid; the agent decides what is paid and when, and pays it. On by default, and nothing is paid without it; `EMBCO_AUTONOMY=observe` decides and explains without paying | | `services/telegram` | The agent on Telegram, a process of its own (`embco telegram`). A chat is linked with a one-time code from the dashboard (hashed, 10 minutes, once); any other chat can do nothing. `/stop` turns the whole agent off and pauses the contract from the agent's wallet, `/pause` pauses payments while the agent keeps watching and answering, `/status` tells its state: none of them go through the model, and only the dashboard turns it on again. Any other text is a message to the agent, whose answers come back to the chat. The agent's questions about invoices arrive once: with two buttons (yes, pay it / no, hold it) when only the owner's yes is missing, where a press from the linked chat, while the question is still open, is only a message to the agent; as a reminder to use the dashboard, followed by the agent's question, when the owner's wallet is needed (approve a wallet, raise a limit, add money, authorize or resume payments). What the agent writes to the owner is one topic, short and without full hashes: a longer text goes back to the model to be rewritten. The bot token never reaches a log or an error | -| `agent/tools` | What the agent can call. To read: the open invoices (with the last state of each payment), a search over them (supplier, due date, what the checks say), one invoice in full, a supplier's profile, an item's price history, the cash position (with the owner's USDC on other chains), the state of a payment, and its own wallet (balance for network fees, whether the contract has it as its agent, whether the contract is paused). To act: pay now (sent at once, with the result back to the agent), estimate and bring funds from the owner's Unified Balance to their wallet on Arc (the split by chain is the agent's choice), schedule (on that day the agent is woken to pay), hold, ask the owner, set an alarm, keep a note, finish. To talk: reply to the owner's message, notify the owner of something to act on (at most 3 per session), and email a supplier from the shop's ERPNext, only after the owner agreed | +| `agent/tools` | What the agent can call. To read: the open invoices (with the last state of each payment), a search over them (supplier, due date, what the checks say), one invoice in full, a supplier's profile, an item's price history, the cash position (with the owner's USDC on other chains), the state of a payment, and its own wallet (balance for network fees, whether the contract has it as its agent, whether the contract is paused). To act: pay now (sent at once, with the result back to the agent), deposit the owner's USDC on another chain into their Unified Balance through their ShopFunding there, estimate and bring funds from the Unified Balance to their wallet on Arc (which chains and how much are the agent's choice), schedule (on that day the agent is woken to pay), hold, ask the owner, set an alarm, keep a note, finish. To talk: reply to the owner's message, notify the owner of something to act on (at most 3 per session), and email a supplier from the shop's ERPNext, only after the owner agreed | | `agent/guardrails/gate.py` | Refuses a payment the rules do not allow (HOLD, ASK, already paid, over this week's room in the contract) before anything is sent, and the refusal goes back to the agent | | `agent/reflexes/wake.py` | When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a limit change, an alarm it set, the daily round. What the agent did itself is not a reason: an invoice it paid does not wake it when it changes, an alarm about it does not ring, its own payments are not a change of money, and a hold it chose that only lost reasons since still holds. No reason, no model call | | `services/erp` | Domain models, `LedgerAdapter` protocol, `erpnext/` adapter (client, mappers, adapter). Reads, plus one write through `PaymentWriter`: a payment final on chain becomes a submitted Payment Entry (on testnet, a draft marked TESTNET, so test USDC never closes a real invoice), drafted by ERPNext from the invoice and checked against what was paid (party, amount, invoice) before it is written, with the transaction hash and payee wallet in its custom fields. The same transaction is never written twice | @@ -52,7 +52,7 @@ The contracts live in `contracts/`. | `services/payments` | Pays one invoice when the agent calls pay now, and gives back what happened, refusals and Circle errors word for word: re-reads the invoice and supplier from the ERP (same amount, USD, valid wallet), skips what the contract already marks paid or what does not fit under its weekly cap, lists for the dashboard any wallet the owner has not approved in the contract yet, including invoices asked about only because their wallet is new (approving the wallet there then counts as the owner's answer, saved in the journal like any other; a wallet approved before the question answers nothing) (`EMBCO_APPROVALS_FILE`: wallet, invoice and amount only, no supplier names), simulates the exact `Memo.memo` -> `ShopPayables.pay` call through the Arc node and names the contract's error if it would fail, sends the same bytes through Circle with an idempotency key per invoice and attempt, follows it to a final state, records every step, then writes it into the ERP. At most 3 failed transactions per invoice. Amounts become exact 6-decimal integers or are refused | | `services/unified_balance` | The owner's Unified Balance (Circle Gateway) through Circle's Unified Balance Kit 1.8.0 and its Circle Wallets adapter, in a small Node script (`kit/`, pnpm, Node 22) the agent runs once per call, with Circle's credentials only in its environment. At start it gives the agent's Circle wallet the same address on each source chain (`deriveWalletByAddress`); then it reads the owner's balance per chain and whether the agent is a delegate there, estimates fees, and spends with the split the model chose, always minted to the contract owner's wallet on Arc through Circle's Forwarding Service. The dashboard gets the same per-chain view after each check | | `dashboard/` | Static owner page (ethers 6.17.0 vendored, no build). Connects MetaMask, adds Arc Testnet, creates the shop from the factory, and lets the owner set the allowance and limits, approve suppliers (with one click for the wallets the agent is waiting to pay, from the agent's file), deposit USDC on another chain into their Unified Balance and authorize the agent there (Circle's Unified Balance Kit 1.8.0 with its viem adapter, bundled once into `vendor/`), pause or resume the agent, and read suppliers and payments from the contract's events. Money is parsed and shown as exact 6-decimal integers. Chain reads go through the site's own `/rpc` proxy, which adds the node credential on the server, so the page never holds one. Served with a strict Content Security Policy | -| `contracts/` (Solidity) | `ShopPayables`, one per shop, created by its owner from `ShopPayablesFactory`. The money stays in the owner's wallet; the agent can only pay approved suppliers, once per invoice, within a per-payment and a weekly cap; owner or agent can pause, only the owner resumes or changes limits. Unit and fuzz tests, plus a fork test against Arc Testnet's real USDC | +| `contracts/` (Solidity) | `ShopFunding`, one per owner on each source chain (Base Sepolia, Ethereum Sepolia), created from `ShopFundingFactory`: the agent may only move the owner's USDC there into the owner's own Gateway balance, within a per-move and a weekly cap. `ShopPayables`, one per shop, created by its owner from `ShopPayablesFactory`. The money stays in the owner's wallet; the agent can only pay approved suppliers, once per invoice, within a per-payment and a weekly cap; owner or agent can pause, only the owner resumes or changes limits. Unit and fuzz tests, plus a fork test against Arc Testnet's real USDC | ## ERP independence diff --git a/docs/assets/connect_erpnext/api_access.png b/docs/assets/connect_erpnext/api_access.png new file mode 100644 index 0000000..58cc55b Binary files /dev/null and b/docs/assets/connect_erpnext/api_access.png differ diff --git a/docs/assets/connect_erpnext/create_contract.png b/docs/assets/connect_erpnext/create_contract.png new file mode 100644 index 0000000..7c6b85f Binary files /dev/null and b/docs/assets/connect_erpnext/create_contract.png differ diff --git a/docs/assets/connect_erpnext/own_user.png b/docs/assets/connect_erpnext/own_user.png new file mode 100644 index 0000000..83fe06f Binary files /dev/null and b/docs/assets/connect_erpnext/own_user.png differ diff --git a/docs/assets/connect_erpnext/roles.png b/docs/assets/connect_erpnext/roles.png new file mode 100644 index 0000000..6e4ba72 Binary files /dev/null and b/docs/assets/connect_erpnext/roles.png differ diff --git a/docs/connect.md b/docs/connect.md index 0f092a2..27debb7 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -103,12 +103,15 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next expect. - **When the agent pays**, it records a Payment Entry in ERPNext for that invoice. The transaction hash is in **Reference No**, and the wallet paid is in the remarks. -- **USDC on other chains (optional):** in **Settings > USDC on other chains**, deposit USDC you - hold on another chain (Base Sepolia, Arbitrum Sepolia and the other testnets listed there) into - your Unified Balance (Circle Gateway), then **Authorize the agent on this chain**. When an - invoice needs more than your wallet on Arc holds, the agent can bring it to your wallet on Arc, - choosing which chains it comes from. It never sends it anywhere else, and your contract's limits - still apply. Each chain asks for its own gas token (for example test ETH on Base Sepolia). +- **USDC on other chains (optional):** in **Settings > USDC on other chains**, pick a chain (Base + Sepolia or Ethereum Sepolia), set how much the agent may move at once and per week, and confirm + in your wallet: your funding contract on that chain, its approval, and the agent's authorization + in your Unified Balance (Circle Gateway). A wallet that batches calls (MetaMask with its smart + account, EIP-5792) asks once; otherwise once per step. Your USDC stays in your wallet there. + When an invoice needs more than your wallet on Arc holds, the agent can move it into your + Unified Balance and bring it to your wallet on Arc, choosing which chains it comes from. It + never sends it anywhere else, and your limits still apply. Each chain asks for its own gas token + (test ETH on Base Sepolia and Ethereum Sepolia), for you once and for the agent's wallet. - **Telegram (optional):** in the dashboard, **Connect Telegram** gives a code to send to the embco bot. Then `/status` says how the agent is, `/pause` pauses payments while the agent keeps watching, and `/stop` turns the agent off and pauses the shop contract. Only the dashboard diff --git a/docs/contracts.md b/docs/contracts.md index 7d07393..be7e04c 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -50,3 +50,25 @@ succeeds, and revoking the USDC allowance stops every payment. | `Paused` / `Unpaused` | The agent or owner pauses; only the owner resumes | The dashboard reads these events from the chain to show suppliers and payments. + +## ShopFunding: the owner's USDC on other chains + +| Chain | `ShopFundingFactory` | +|---|---| +| Base Sepolia | [`0x69e31Af302Ee222204AA78099D39a2956F5C95d6`](https://sepolia.basescan.org/address/0x69e31Af302Ee222204AA78099D39a2956F5C95d6) | +| Ethereum Sepolia | [`0x69e31Af302Ee222204AA78099D39a2956F5C95d6`](https://sepolia.etherscan.io/address/0x69e31Af302Ee222204AA78099D39a2956F5C95d6) | + +`ShopFunding` is the same idea on a source chain of the owner's Unified Balance (Circle +Gateway). Each owner creates one from `ShopFundingFactory` on that chain and approves it, the way +they approve the shop contract on Arc. + +| Who | Can do | Cannot do | +|---|---|---| +| Agent | `fund(amount)`: take that much of the owner's USDC and deposit it into the owner's own Gateway balance, within a per-move and a weekly cap; pause | Send it anywhere else, change a limit, resume after a pause | +| Owner | Everything the agent can, with no limit; set the limits and the agent; resume | Transfer ownership | + +The money never stays in the contract: `fund` takes it from the owner's wallet and calls +Gateway's `depositFor(usdc, owner, amount)` in the same transaction, so the deposit always belongs +to the owner. From there the agent, which the owner authorizes as a delegate in Gateway, brings it +to the owner's wallet on Arc. Unit and fuzz tests, plus a fork test against Base Sepolia's real +GatewayWallet and USDC. diff --git a/services/circle/client.py b/services/circle/client.py index 620ecaf..5eafa4d 100644 --- a/services/circle/client.py +++ b/services/circle/client.py @@ -146,6 +146,16 @@ def get_wallet(self, wallet_id: str) -> CircleWallet: return CircleWallet(id=w["id"], address=w["address"], blockchain=w["blockchain"], wallet_set_id=w["walletSetId"]) + def wallet_on(self, address: str, blockchain: str) -> CircleWallet: + """The wallet with this address on this chain (one address, one wallet per chain).""" + wallets = self._request( + "GET", f"/v1/w3s/wallets?address={address}&blockchain={blockchain}")["wallets"] + if not wallets: + raise CircleError(f"no Circle wallet {address} on {blockchain}") + w = wallets[0] + return CircleWallet(id=w["id"], address=w["address"], blockchain=w["blockchain"], + wallet_set_id=w["walletSetId"]) + def execute_contract( self, wallet_id: str, diff --git a/services/shops/activity.py b/services/shops/activity.py index 5f9842e..545977e 100644 --- a/services/shops/activity.py +++ b/services/shops/activity.py @@ -163,7 +163,11 @@ def session_events(session) -> list[dict[str, Any]]: def _brought(moved: Mapping[str, Any]) -> str: - """A move from the owner's Unified Balance, in the owner's words.""" + """A move of the owner's funds, in the owner's words: a deposit into their Unified Balance, + or a spend from it to their wallet on Arc.""" + if moved.get("kind") == "deposit": + return (f"Moved {moved['amount']} USDC from your wallet on " + f"{moved['chain'].replace('_', ' ')} into your Unified Balance.") sources = ", ".join(f"{s['chain'].replace('_', ' ')} {s['amount']}" for s in moved["from"]) return (f"Brought {moved['arrives_on_arc']} USDC to your wallet on Arc from your Unified " f"Balance ({sources}).") diff --git a/services/shops/agent.py b/services/shops/agent.py index afc0c19..88ff6dc 100644 --- a/services/shops/agent.py +++ b/services/shops/agent.py @@ -225,9 +225,15 @@ def unified_view(owner_funds: OwnerFunds | None) -> dict[str, Any] | None: chains = owner_funds.chains() except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error: return {"error": str(error)} + try: + wallets = [{"chain": w.chain, "in_wallet": str(w.in_wallet), "funding": w.funding, + "approved": str(w.approved), "agent_may_move": str(w.can_move)} + for w in owner_funds.in_wallets()] + except (ChainError, CircleError, PaymentSetupError) as error: + wallets = {"error": str(error)} return {"chains": [{"chain": c.chain, "available": str(c.confirmed), "arriving": str(c.pending), "agent_authorized": c.agent_can_spend} - for c in chains]} + for c in chains], "wallets": wallets} def run_shop( diff --git a/services/unified_balance/client.py b/services/unified_balance/client.py index bedafb1..81af31d 100644 --- a/services/unified_balance/client.py +++ b/services/unified_balance/client.py @@ -37,6 +37,16 @@ def agent_can_spend(self) -> bool: return self.delegate == "ready" +@dataclass(frozen=True) +class ChainFacts: + """A source chain as Circle's kit defines it.""" + + chain: str # the kit's name, e.g. Base_Sepolia + rpc: str # its public node + usdc: str # USDC's address there + circle: str # Circle wallets' name for it, e.g. BASE-SEPOLIA + + @dataclass(frozen=True) class Allocation: chain: str @@ -90,6 +100,10 @@ def balances(self, owner: str, agent: str) -> list[ChainFunds]: raise UnifiedBalanceError( "the Unified Balance Kit answered in an unknown form") from None + def chains(self) -> list[ChainFacts]: + return [ChainFacts(c["chain"], c["rpc"], c["usdc"], c["circle"]) + for c in self._call("chains", {})["chains"]] + def prepare(self, agent: str) -> list[str]: """Give the agent's Circle wallet the same address on every source chain.""" return self._call("prepare", {"agent": agent})["chains"] diff --git a/services/unified_balance/funding.py b/services/unified_balance/funding.py new file mode 100644 index 0000000..1c34d9b --- /dev/null +++ b/services/unified_balance/funding.py @@ -0,0 +1,118 @@ +"""The owner's USDC in their own wallets on other chains, and the ShopFunding contract there that +lets the agent move it into the owner's Gateway balance, within limits only the owner sets. + +Reads go to each chain's public node, as Circle's kit defines it. A move is the agent's Circle +wallet calling ShopFunding.fund on that chain, simulated first and sent with an idempotency key. +The contract only ever deposits for the owner, so the money stays the owner's. +""" + +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from eth_abi import decode, encode +from eth_utils import keccak, to_checksum_address + +from services.circle import CircleClient, CircleError +from services.payments import ArcRpc, ChainError +from services.payments.encoding import USDC_DECIMALS, usdc_units +from services.unified_balance.client import ChainFacts + +# ShopFundingFactory on each source chain (contracts/src/ShopFundingFactory.sol); a chain without +# one is not offered. +FACTORIES: dict[str, str] = {"Base_Sepolia": "0x69e31Af302Ee222204AA78099D39a2956F5C95d6", + "Ethereum_Sepolia": "0x69e31Af302Ee222204AA78099D39a2956F5C95d6"} +FOLLOW_SECONDS = 90 # how long a move is followed before it is reported as sent +POLL_SECONDS = 3 + + +def _usdc(units: int) -> Decimal: + return Decimal(units).scaleb(-USDC_DECIMALS) + + +@dataclass(frozen=True) +class WalletFunds: + """The owner's USDC on one chain and how much of it the agent may move into Gateway now.""" + + chain: str + in_wallet: Decimal + funding: str | None # the owner's ShopFunding there; None until the owner creates it + approved: Decimal = Decimal(0) # what the owner lets it take + max_per_move: Decimal = Decimal(0) + left_this_week: Decimal = Decimal(0) + paused: bool = False + agent_is_set: bool = False + + @property + def can_move(self) -> Decimal: + if not self.funding or self.paused or not self.agent_is_set: + return Decimal(0) + return min(self.in_wallet, self.approved, self.max_per_move, self.left_this_week) + + +class OwnerWallets: + def __init__(self, circle: CircleClient, facts: Callable[[], list[ChainFacts]], *, + factories: Mapping[str, str] = FACTORIES, + node: Callable[[str], ArcRpc] = ArcRpc, + sleep: Callable[[float], None] = time.sleep) -> None: + self._circle = circle + self._facts = facts + self._factories = {chain: f for chain, f in factories.items() if f} + self._node = node + self._sleep = sleep + + def _chains(self) -> list[ChainFacts]: + return [c for c in self._facts() if c.chain in self._factories] + + def read(self, owner: str, agent: str) -> list[WalletFunds]: + return [self._read(c, owner, agent) for c in self._chains()] + + def _read(self, chain: ChainFacts, owner: str, agent: str) -> WalletFunds: + rpc = self._node(chain.rpc) + view = lambda to, sig, types=(), args=(): rpc.call( # noqa: E731 + to, keccak(text=sig)[:4] + encode(list(types), list(args))) + (in_wallet,) = decode(["uint256"], view(chain.usdc, "balanceOf(address)", + ["address"], [owner])) + (fundings,) = decode(["address[]"], view(self._factories[chain.chain], + "fundingsOf(address)", ["address"], [owner])) + if not fundings: + return WalletFunds(chain.chain, _usdc(in_wallet), None) + funding = to_checksum_address(fundings[-1]) + (approved,) = decode(["uint256"], view(chain.usdc, "allowance(address,address)", + ["address", "address"], [owner, funding])) + (per_move,) = decode(["uint256"], view(funding, "maxPerMove()")) + (left,) = decode(["uint256"], view(funding, "remainingThisWeek()")) + (paused,) = decode(["bool"], view(funding, "paused()")) + (its_agent,) = decode(["address"], view(funding, "agent()")) + return WalletFunds(chain.chain, _usdc(in_wallet), funding, _usdc(approved), + _usdc(per_move), _usdc(left), paused, + to_checksum_address(its_agent) == to_checksum_address(agent)) + + def deposit(self, owner: str, agent: str, chain: str, amount: Decimal) -> dict[str, Any]: + """Move `amount` of the owner's USDC on `chain` into the owner's Gateway balance.""" + facts = next((c for c in self._chains() if c.chain == chain), None) + if facts is None: + raise ChainError(f"no ShopFunding factory on {chain}; chains offered: " + f"{', '.join(self._factories) or 'none yet'}") + funds = self._read(facts, owner, agent) + if funds.funding is None: + raise ChainError(f"the owner has not set up {chain} yet (dashboard: USDC on other " + "chains)") + call = keccak(text="fund(uint256)")[:4] + encode(["uint256"], [usdc_units(amount)]) + self._node(facts.rpc).call(funds.funding, call, sender=agent) # reverts say why + wallet = self._circle.wallet_on(agent, facts.circle) + sent = self._circle.execute_contract(wallet.id, funds.funding, call, + idempotency_key=str(uuid.uuid4())) + waited = 0 + while not sent.finished and waited < FOLLOW_SECONDS: + self._sleep(POLL_SECONDS) + waited += POLL_SECONDS + sent = self._circle.get_transaction(sent.id) + if sent.finished and sent.state != "COMPLETE": + raise CircleError(f"the move on {chain} {sent.state.lower()}: " + f"{sent.error_reason or 'no reason given'}") + return {"chain": chain, "amount": str(amount), "state": sent.state.lower(), + "tx": sent.tx_hash or "", "circle_tx": sent.id} diff --git a/services/unified_balance/kit/unified.mjs b/services/unified_balance/kit/unified.mjs index 02f1973..bab11f7 100644 --- a/services/unified_balance/kit/unified.mjs +++ b/services/unified_balance/kit/unified.mjs @@ -103,7 +103,15 @@ async function spend(input, env) { return kit.spend(spendParams({ adapter, ...input })); } -export const COMMANDS = { balances, prepare, estimate, spend, chains: async () => ({ chains: sourceChains() }) }; +/** Each source chain as the kit defines it: its public node, its USDC and its Circle name. */ +export function chainFacts() { + return sourceChains().map((name) => { + const chain = resolveChainIdentifier(name); + return { chain: name, rpc: chain.rpcEndpoints[0], usdc: chain.usdcAddress, circle: toCircleBlockchain(name) }; + }); +} + +export const COMMANDS = { balances, prepare, estimate, spend, chains: async () => ({ chains: chainFacts() }) }; /** Run one command; the answer is plain data, or the kit's own error message. */ export async function run(command, input, env) { diff --git a/services/unified_balance/owner.py b/services/unified_balance/owner.py index 275c7a2..9c05586 100644 --- a/services/unified_balance/owner.py +++ b/services/unified_balance/owner.py @@ -4,16 +4,19 @@ from collections.abc import Sequence from dataclasses import dataclass +from decimal import Decimal from typing import Any from services.payments import Payer from services.unified_balance.client import Allocation, ChainFunds, UnifiedBalance +from services.unified_balance.funding import OwnerWallets, WalletFunds @dataclass class OwnerFunds: kit: UnifiedBalance payer: Payer # knows the shop contract (whose owner receives) and the agent's wallet + wallets: OwnerWallets | None = None # the owner's own wallets on other chains def _owner(self) -> str: return self.payer.chain.owner_of(self.payer.shop) @@ -30,3 +33,16 @@ def estimate(self, allocations: Sequence[Allocation]) -> Any: def bring(self, allocations: Sequence[Allocation]) -> Any: return self.kit.spend(self._owner(), self.payer.agent_address(), allocations) + + def in_wallets(self) -> list[WalletFunds]: + """The owner's USDC in their own wallets on other chains, and what the agent may move.""" + if self.wallets is None: + return [] + return self.wallets.read(self._owner(), self.payer.agent_address()) + + def deposit(self, chain: str, amount: Decimal) -> dict[str, Any]: + """Move the owner's USDC on `chain` into the owner's Gateway balance, through the + owner's ShopFunding there.""" + if self.wallets is None: + raise ValueError("the owner's wallets on other chains are not connected") + return self.wallets.deposit(self._owner(), self.payer.agent_address(), chain, amount) diff --git a/tests/test_unified_balance.py b/tests/test_unified_balance.py index 29f5c93..71c83ed 100644 --- a/tests/test_unified_balance.py +++ b/tests/test_unified_balance.py @@ -24,12 +24,15 @@ UnifiedBalance, UnifiedBalanceError, ) +from services.unified_balance.client import ChainFacts +from services.unified_balance.funding import OwnerWallets, WalletFunds NOW = datetime(2026, 10, 14, 9, 0, tzinfo=UTC) OWNER = "0x2471658CD7A7B10d4C92113B0777dA59317e839a" AGENT = "0xeAc91C9491F0D391c80f17C4267A3329Ad7Ba862" KEY, SECRET = "fake-circle-key", "fake-entity-secret" INVOICE = "ACC-PINV-2026-00017" +FUNDING = "0x" + "f1" * 20 FEES = [{"type": "provider", "token": "USDC", "amount": "0.0155"}, {"type": "gasFee", "token": "USDC", "amount": "0.048274"}, {"type": "forwarder", "token": "USDC", "amount": "0.014162"}] @@ -114,7 +117,7 @@ class Funds: authorized), 50 on Arbitrum Sepolia (not yet).""" def __init__(self): - self.brought = [] + self.brought, self.deposited = [], [] def chains(self): return [ChainFunds("Base_Sepolia", Decimal(320), Decimal(0), "ready"), @@ -128,6 +131,16 @@ def bring(self, allocations): self.brought.append(allocations) return {"txHash": "0x" + "2" * 64, "fees": FEES} + def in_wallets(self): + return [WalletFunds("Base_Sepolia", Decimal(400), FUNDING, Decimal(1000), Decimal(200), + Decimal(150), False, True), + WalletFunds("Ethereum_Sepolia", Decimal(80), None)] + + def deposit(self, chain, amount): + self.deposited.append((chain, amount)) + return {"chain": chain, "amount": str(amount), "state": "complete", + "tx": "0x" + "3" * 64, "circle_tx": "c1"} + def box(journal, owner_funds, *, can_pay=True, money=None): d = Decision(invoice=INVOICE, supplier="Adeyemi Foods Distribution Ltd", @@ -248,3 +261,65 @@ def chains(self): raise UnifiedBalanceError("the Unified Balance Kit did not run") assert unified_view(Down()) == {"error": "the Unified Balance Kit did not run"} + + +# ---- the owner's own wallets on other chains + + +def test_cash_position_shows_what_the_agent_may_deposit_on_each_chain(tmp_path): + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + seen = json.loads(box(journal, Funds()).call("cash_position", {})) + assert seen["usdc_in_owner_wallets_on_other_chains"] == [ + {"chain": "Base_Sepolia", "in_wallet": "400", "you_may_deposit_now": "150"}, + {"chain": "Ethereum_Sepolia", "in_wallet": "80", + "you_may_deposit_now": "no: the owner sets up this chain in the dashboard"}] + + +def test_the_agent_deposits_what_it_chose_and_it_is_told(tmp_path): + funds = Funds() + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + tools = box(journal, funds) + moved = json.loads(tools.call("deposit_to_gateway", { + "chain": "Base_Sepolia", "amount": "120.5", "reason": "00017 is due in 10 days."})) + assert funds.deposited == [("Base_Sepolia", Decimal("120.5"))] + assert moved["state"] == "complete" and moved["kind"] == "deposit" + session = Session(at=NOW, wakes=(Wake("round", "r"),), finished=True, funds_moved=(moved,)) + [line] = [e for e in activity.session_events(session) if e.get("tx")] + assert line["text"] == ("Moved 120.5 USDC from your wallet on Base Sepolia into your " + "Unified Balance.") + + +@pytest.mark.parametrize("amount", ["0", "-1", "ten", ""]) +def test_a_deposit_needs_an_amount_above_zero(tmp_path, amount): + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + with pytest.raises(ToolError, match="above 0"): + box(journal, Funds()).call("deposit_to_gateway", + {"chain": "Base_Sepolia", "amount": amount, "reason": "r"}) + + +def test_no_deposit_while_payments_are_paused(tmp_path): + funds = Funds() + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + with pytest.raises(ToolError, match="paused"): + box(journal, funds, can_pay=False).call( + "deposit_to_gateway", {"chain": "Base_Sepolia", "amount": "10", "reason": "r"}) + assert funds.deposited == [] + + +def test_what_the_agent_may_move_is_the_smallest_of_balance_approval_and_limits(): + w = WalletFunds("Base_Sepolia", Decimal(400), FUNDING, Decimal(1000), Decimal(200), + Decimal(150), False, True) + assert w.can_move == Decimal(150) + assert WalletFunds("Base_Sepolia", Decimal(400), FUNDING, Decimal(1000), Decimal(200), + Decimal(150), True, True).can_move == 0 # paused + assert WalletFunds("Base_Sepolia", Decimal(400), FUNDING, Decimal(1000), Decimal(200), + Decimal(150), False, False).can_move == 0 # another agent + assert WalletFunds("Base_Sepolia", Decimal(400), None).can_move == 0 + + +def test_a_chain_without_its_factory_is_not_offered(): + facts = lambda: [ChainFacts("Base_Sepolia", "https://node", "0x" + "11" * 20, "BASE-SEPOLIA")] # noqa: E731 + wallets = OwnerWallets(circle=None, facts=facts, factories={"Base_Sepolia": ""}) + assert wallets.read(OWNER, AGENT) == [] + with pytest.raises(Exception, match="no ShopFunding factory on Base_Sepolia"): + wallets.deposit(OWNER, AGENT, "Base_Sepolia", Decimal(1))