Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion agent/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion agent/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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. "
Expand Down
35 changes: 35 additions & 0 deletions agent/tools/toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion agent/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions contracts/script/DeployFunding.s.sol
Original file line number Diff line number Diff line change
@@ -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 <keystore-name>
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));
}
}
140 changes: 140 additions & 0 deletions contracts/src/ShopFunding.sol
Original file line number Diff line number Diff line change
@@ -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_);
}
}
54 changes: 54 additions & 0 deletions contracts/src/ShopFundingFactory.sol
Original file line number Diff line number Diff line change
@@ -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)));
}
}
47 changes: 47 additions & 0 deletions contracts/test/GatewayFork.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading