From 059a6a0d763bd391d8b8ea9daa42bf0f227d921b Mon Sep 17 00:00:00 2001 From: JA VI <127710324+JaDi03@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:40 -0600 Subject: [PATCH] feat: the agent brings USDC from the owner's Unified Balance to pay invoices --- .github/workflows/ci.yml | 14 + agent/memory/sessions.py | 2 + agent/models.py | 1 + agent/prompt.py | 5 +- agent/think.py | 13 +- agent/tools/definitions.py | 18 +- agent/tools/toolbox.py | 79 +- agent/wiring.py | 10 + dashboard/app.js | 62 + dashboard/index.html | 17 + dashboard/test/unified.test.mjs | 33 + dashboard/unified.js | 67 + .../unified-balance-kit-1.8.0/entry.mjs | 4 + .../vendor/unified-balance-kit-1.8.0/index.js | 56 + .../index.js.LEGAL.txt | 269 ++ .../unified-balance-kit-1.8.0/package.json | 16 + .../unified-balance-kit-1.8.0/pnpm-lock.yaml | 1461 +++++++++++ .../pnpm-workspace.yaml | 5 + dashboard/wallet.js | 6 + deploy/Caddyfile | 4 +- deploy/install.sh | 10 + docs/architecture.md | 7 +- docs/connect.md | 6 + docs/how-it-works.md | 1 + services/payments/chain.py | 4 + services/shops/activity.py | 8 + services/shops/agent.py | 41 +- services/unified_balance/__init__.py | 13 + services/unified_balance/client.py | 107 + services/unified_balance/kit/cli.mjs | 12 + services/unified_balance/kit/package.json | 18 + services/unified_balance/kit/pnpm-lock.yaml | 2186 +++++++++++++++++ .../unified_balance/kit/pnpm-workspace.yaml | 4 + .../unified_balance/kit/test/unified.test.mjs | 27 + services/unified_balance/kit/unified.mjs | 117 + services/unified_balance/owner.py | 32 + tests/test_unified_balance.py | 250 ++ 37 files changed, 4968 insertions(+), 17 deletions(-) create mode 100644 dashboard/test/unified.test.mjs create mode 100644 dashboard/unified.js create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/entry.mjs create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/index.js create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/index.js.LEGAL.txt create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/package.json create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/pnpm-lock.yaml create mode 100644 dashboard/vendor/unified-balance-kit-1.8.0/pnpm-workspace.yaml create mode 100644 services/unified_balance/__init__.py create mode 100644 services/unified_balance/client.py create mode 100644 services/unified_balance/kit/cli.mjs create mode 100644 services/unified_balance/kit/package.json create mode 100644 services/unified_balance/kit/pnpm-lock.yaml create mode 100644 services/unified_balance/kit/pnpm-workspace.yaml create mode 100644 services/unified_balance/kit/test/unified.test.mjs create mode 100644 services/unified_balance/kit/unified.mjs create mode 100644 services/unified_balance/owner.py create mode 100644 tests/test_unified_balance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79d87e6..d731c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,17 @@ jobs: with: node-version: 22 - run: node --test + + unified-balance: + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/unified_balance/kit + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: corepack enable + - run: pnpm install --frozen-lockfile --prod + - run: node --test diff --git a/agent/memory/sessions.py b/agent/memory/sessions.py index 2cf2225..a12fe83 100644 --- a/agent/memory/sessions.py +++ b/agent/memory/sessions.py @@ -31,6 +31,7 @@ def session_body(session: Session) -> dict[str, Any]: "emails": list(session.emails), "wallet_notices": list(session.wallet_notices), "seen_payments": list(session.seen_payments), + "funds_moved": list(session.funds_moved), } @@ -70,6 +71,7 @@ def session_from(at: datetime, body: dict[str, Any], emails=tuple(body.get("emails", [])), wallet_notices=tuple(body.get("wallet_notices", [])), seen_payments=tuple(body.get("seen_payments", [])), + funds_moved=tuple(body.get("funds_moved", [])), ) diff --git a/agent/models.py b/agent/models.py index 0b70fa0..af1ceb2 100644 --- a/agent/models.py +++ b/agent/models.py @@ -100,6 +100,7 @@ class Session: emails: tuple[str, ...] = () # suppliers the agent wrote to in this session wallet_notices: tuple[str, ...] = () # suppliers sent the notice to confirm their wallet seen_payments: tuple[str, ...] = () # payment results the agent saw in this session + funds_moved: tuple[dict, ...] = () # what the agent brought from the owner's other chains def payment_seen_key(event) -> str: diff --git a/agent/prompt.py b/agent/prompt.py index a5432db..2787347 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -46,7 +46,10 @@ numbers you read. When to pay is your call: the due date, this week's room in the contract (cash_position says when it renews), the owner's balance and what else is waiting. Paying late is the owner's call: when something you would pay cannot go out by its due date, ask, with the -options and your recommendation. +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. 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/think.py b/agent/think.py index 9349a2f..4984d28 100644 --- a/agent/think.py +++ b/agent/think.py @@ -75,6 +75,7 @@ class BrainSetup: funds: Callable[[], dict[str, str] | None] = field(default=lambda: None) mailer: Callable[[str, str, str], str] | None = None # writes to a supplier from the ERP wallet_notice: Callable[[str], str] | None = None # the standard wallet notice, from the ERP + owner_funds: Any = None # the owner's Unified Balance (services.unified_balance.OwnerFunds) @dataclass(frozen=True) @@ -128,7 +129,7 @@ def think( toolbox = Toolbox(decisions=list(decisions), ledger=ledger, journal=journal, policy=policy, now=now, zone=setup.zone, done=set(done), standing=standing, room=setup.room, funds=setup.funds, mailer=setup.mailer, - wallet_notice=setup.wallet_notice, + wallet_notice=setup.wallet_notice, owner_funds=setup.owner_funds, emails_today=emails_today(sessions, setup.zone, now), past_notes=tuple(recent_notes(sessions)), messages=tuple(messages), payer=payer, @@ -152,6 +153,7 @@ def think( emails=tuple(toolbox.emailed), wallet_notices=tuple(toolbox.notified_wallets), seen_payments=tuple(payment_seen_key(e) for e in toolbox.payments), + funds_moved=tuple(toolbox.funds_moved), **({"usage": usage} if usage else {}), ) journal.record_session(session) @@ -189,7 +191,7 @@ def converse( toolbox = Toolbox(decisions=list(decisions), ledger=ledger, journal=journal, policy=policy, now=now, zone=setup.zone, done=set(done), standing=standing, room=setup.room, funds=setup.funds, mailer=setup.mailer, - wallet_notice=setup.wallet_notice, + wallet_notice=setup.wallet_notice, owner_funds=setup.owner_funds, emails_today=emails_today(sessions, setup.zone, now), past_notes=tuple(recent_notes(sessions)), messages=tuple(messages), require_all=False, payer=payer, @@ -214,6 +216,7 @@ def converse( emails=tuple(toolbox.emailed), wallet_notices=tuple(toolbox.notified_wallets), seen_payments=tuple(payment_seen_key(e) for e in toolbox.payments), + funds_moved=tuple(toolbox.funds_moved), **({"usage": usage} if usage else {}), ) journal.record_session(session) @@ -223,9 +226,9 @@ def converse( def money_after(setup: BrainSetup, toolbox: Toolbox, before: dict[str, str] | None) -> dict[str, str] | None: - """The money the agent leaves the session knowing: read again when it paid, so its own - payments are not a change of money that wakes it in the next check.""" - if not toolbox.payments: + """The money the agent leaves the session knowing: read again when it paid or brought funds, + so what it did itself is not a change of money that wakes it in the next check.""" + if not (toolbox.payments or toolbox.funds_moved): return before return setup.funds() or before diff --git a/agent/tools/definitions.py b/agent/tools/definitions.py index a978b02..fbca993 100644 --- a/agent/tools/definitions.py +++ b/agent/tools/definitions.py @@ -26,6 +26,9 @@ def _text(description: str) -> dict[str, Any]: INVOICE = _text("The invoice name exactly as listed, e.g. ACC-PINV-2026-00012.") REASON = _text("Why, in one or two plain sentences the owner will read.") +ALLOCATIONS = _text("How much USDC from each chain, as chain:amount separated by commas, e.g. " + "Base_Sepolia:120, Arbitrum_Sepolia:30.5. Chain names as cash_position " + "lists them.") TOOLS: list[dict[str, Any]] = [ _tool("list_open_invoices", @@ -53,7 +56,8 @@ def _text(description: str) -> dict[str, Any]: item_code=_text("The item code exactly as on the invoice line.")), _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, and the payments scheduled or due soon."), + "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."), _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 " @@ -70,6 +74,18 @@ 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("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. " + "Nothing moves.", + allocations=ALLOCATIONS), + _tool("bring_funds", + "Bring USDC from the owner's Unified Balance on other chains to the owner's own wallet " + "on Arc, for real, so the shop contract can pay from it. You choose how much comes " + "from each chain; it always arrives at the owner's wallet, never anywhere else. Only " + "chains where the owner authorized you. Returns what arrived, the fees and the " + "transaction, or the real error.", + allocations=ALLOCATIONS, reason=REASON), _tool("schedule_payment", "Plan to pay this invoice on a later date you choose (shop's local date). Only " "invoices the checks allow. On that day you are woken to pay it with pay_now, or to " diff --git a/agent/tools/toolbox.py b/agent/tools/toolbox.py index ae24be2..67f3c76 100644 --- a/agent/tools/toolbox.py +++ b/agent/tools/toolbox.py @@ -24,7 +24,8 @@ from services.circle import CircleError from services.erp import LedgerAdapter, LedgerError from services.payments import ChainError, PaymentEvent, PaymentStatus -from services.payments.payer import PaymentRefused +from services.payments.payer import PaymentRefused, PaymentSetupError +from services.unified_balance import Allocation, UnifiedBalanceError MAX_ALARMS = 5 MAX_NOTES = 10 @@ -38,6 +39,8 @@ MAX_NOTICES = 3 MAX_EMAILS_PER_DAY = 3 MAX_PAYMENTS = 10 # per session; the contract's weekly cap is the real limit +MAX_FUND_MOVES = 3 # bring_funds calls per session +FORWARDER = "forwarder" # the fee taken from what is minted on Arc; the others come off the balance LINKS = re.compile(r"https?://|www\.|\b[a-z0-9-]+\.(com|net|org|xyz|io|app|link)\b", re.I) UNKNOWN_FUNDS = {"weekly_room_resets_at_utc": "unknown", "owner_balance": "unknown", "payments_authorized": "unknown"} # the chain did not answer: never a guess @@ -70,6 +73,8 @@ class Toolbox: payer: Any = None # pays one invoice when the agent chooses to (services.payments.Payer) can_pay: bool = True # False while the owner has paused payments payments: list[PaymentEvent] = field(default_factory=list) # what the agent's payments did + owner_funds: Any = None # the owner's Unified Balance (services.unified_balance.OwnerFunds) + funds_moved: list[dict[str, Any]] = field(default_factory=list) # what bring_funds brought chosen: dict[str, AgentDecision] = field(default_factory=dict) alarms: list[Alarm] = field(default_factory=list) notes: list[Note] = field(default_factory=list) @@ -215,8 +220,23 @@ def _cash_position(self) -> dict[str, Any]: "payable_due_within_7_days": fmt(sum((d.amount for d in due_soon), Decimal(0))), "today": self.today.isoformat(), **(self._funds_once() or UNKNOWN_FUNDS), + "usdc_on_other_chains": self._other_chains(), } + 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: + return "not connected for this shop" + try: + chains = self.owner_funds.chains() + except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error: + return f"unknown: {error}" + rows = [{"chain": c.chain, "available": fmt(c.confirmed), "arriving": fmt(c.pending), + "you_may_spend_it": "yes" if c.agent_can_spend + else "no: the owner authorizes you on this chain in the dashboard"} + for c in chains if c.confirmed or c.pending or c.agent_can_spend] + return rows or "the owner has no USDC on other chains" + # acting def _pay_now(self, invoice: str, reason: str) -> str | dict[str, Any]: @@ -271,6 +291,47 @@ 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 _estimate_bring_funds(self, allocations: str) -> dict[str, Any]: + split = self._allocations(allocations) + try: + estimate = self.owner_funds.estimate(split) + except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error: + raise ToolError(f"could not estimate it: {error}") from None + return _moved(split, estimate.get("fees", [])) + + def _bring_funds(self, allocations: str, reason: str) -> dict[str, Any]: + split = self._allocations(allocations) + 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: + result = self.owner_funds.bring(split) + except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error: + raise ToolError(f"nothing was brought: {error}") from None + moved = {**_moved(split, result.get("fees", [])), "reason": reason.strip(), + "tx": result.get("txHash") or result.get("transferId") or ""} + self.funds_moved.append(moved) + if hasattr(self, "_funds"): + del self._funds # the owner's balance on Arc changed: read it again when asked + return moved + + def _allocations(self, text: str) -> list[Allocation]: + """chain:amount pairs, each chain once, each amount positive.""" + if self.owner_funds is None: + raise ToolError("the owner's Unified Balance is not connected for this shop") + split: dict[str, Decimal] = {} + for part in filter(None, (p.strip() for p in text.split(","))): + chain, _, amount = part.partition(":") + chain, value = chain.strip(), _decimal(amount) + if not chain or value is None or value <= 0 or chain in split: + raise ToolError("allocations are chain:amount pairs, each chain once, each " + "amount above 0, e.g. Base_Sepolia:120, Arbitrum_Sepolia:30.5") + split[chain] = value + if not split: + raise ToolError("say how much from which chain, e.g. Base_Sepolia:120") + return [Allocation(chain, amount) for chain, amount in split.items()] + def _schedule_payment(self, invoice: str, pay_on: str, reason: str) -> str: decision = self._by_invoice.get(invoice) day = _parse_day(pay_on) @@ -534,3 +595,19 @@ def _parse_day(text: str) -> date: return date.fromisoformat(text) except ValueError: raise ToolError("use a date as YYYY-MM-DD") from None + + +def _decimal(text: str) -> Decimal | None: + try: + return Decimal(text.strip()) + except (ArithmeticError, ValueError): + return None + + +def _moved(split: list[Allocation], fees: list[dict[str, Any]]) -> dict[str, Any]: + """What a move takes from each chain, its fees and what reaches the owner's wallet on Arc.""" + total = sum((a.amount for a in split), Decimal(0)) + fee = {f.get("type", ""): Decimal(str(f.get("amount", "0"))) for f in fees} + return {"from": [{"chain": a.chain, "amount": fmt(a.amount)} for a in split], + "total": fmt(total), "fees": {k: fmt(v) for k, v in fee.items()}, + "arrives_on_arc": fmt(total - fee.get(FORWARDER, Decimal(0)))} diff --git a/agent/wiring.py b/agent/wiring.py index 15f9082..6daacec 100644 --- a/agent/wiring.py +++ b/agent/wiring.py @@ -10,6 +10,7 @@ from services.payments import ChainError, Payer from services.payments.encoding import USDC_DECIMALS from services.settings import Settings +from services.unified_balance import OwnerFunds, UnifiedBalance def room_of(payer: Payer | None) -> Callable[[], Decimal | None] | None: @@ -51,11 +52,19 @@ def funds() -> dict[str, str] | None: return funds +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) + + def brain_setup( settings: Settings, room: Callable[[], Decimal | None] | None = None, funds: Callable[[], dict[str, str] | None] | None = None, mailer: Callable[[str, str, str], str] | None = None, wallet_notice: Callable[[str], str] | None = None, + owner_funds: OwnerFunds | None = None, ) -> BrainSetup | None: if not settings.brain: return None @@ -73,4 +82,5 @@ def brain_setup( **({"funds": funds} if funds else {}), **({"mailer": mailer} if mailer else {}), **({"wallet_notice": wallet_notice} if wallet_notice else {}), + **({"owner_funds": owner_funds} if owner_funds else {}), ) diff --git a/dashboard/app.js b/dashboard/app.js index 5f9cd60..495beba 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -10,6 +10,7 @@ import * as chat from "./chat.js"; import * as hub from "./hub.js"; import { agentView, groupTasks, raisedLimits, taskFor } from "./needs.js"; import * as suppliers from "./suppliers.js"; +import * as unified from "./unified.js"; import { factory, memoNames, read, shopContract, shopHistory, shopsOf, shopState, usdc } from "./shop.js"; import { AUTHORIZE_ALL, authorizationView, formatUsdc, parseUsdc, shortAddress, ZERO_ADDRESS } from "./units.js"; import { chooseWallet } from "./picker.js"; @@ -171,6 +172,8 @@ async function renderShop() { ? `${today.sessions} session${today.sessions === 1 ? "" : "s"}${today.cost ? ` · ${today.cost} est.` : ""}` : "-"; $("agent-address").replaceChildren(hasAgent ? link(shortAddress(state.agent), addressUrl(state.agent)) : "none"); + session.agent = hasAgent ? state.agent : null; + renderUnified(isOwner, service); const telegram = Boolean(service?.telegram_linked); $("telegram-state").textContent = !service ? "sign in to the embco service first" : telegram ? "linked: /stop and /pause work from your phone" : "not linked"; $("telegram-link").hidden = !isOwner || !service?.connected || telegram; @@ -182,6 +185,44 @@ async function renderShop() { await Promise.all([renderHistory(isOwner), renderApprovals(isOwner), renderErp(isOwner, state)]); } +// The owner's Unified Balance on other chains: what the agent last read, and the owner's two +// actions there, made with their own wallet through Circle's kit. + +function renderUnified(isOwner, service) { + const view = unified.unifiedView(service?.last_run); + $("unified-note").textContent = isOwner ? view.text : ""; + $("unified-chains").replaceChildren( + ...view.rows + .filter((r) => r.available || r.arriving || r.authorized) + .map((r) => { + const item = document.createElement("li"); + const arriving = r.arriving ? ` (+${formatUsdc(r.arriving)} arriving)` : ""; + item.textContent = `${r.name}: ${formatUsdc(r.available)} USDC${arriving} · ${r.authorized ? "agent authorized" : "agent not authorized"}`; + return item; + }), + ); + const select = $("unified-chain"); + const picked = select.value; + 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; +} + +async function unifiedAct(button, label, run) { + button.disabled = true; + try { + notify(`${label}: confirm it in your wallet (it switches to that chain)...`); + await run(wallet.eip1193()); + notify(`${label}: done. The agent sees it at its next check.`, "success"); + } catch (error) { + notify(describeError(error), "error"); + } finally { + button.disabled = false; + await wallet.switchToArc().catch(() => null); // the rest of the page works on Arc + await render(); + } +} + // The shop's own agent on the owner's ERPNext, through the hub API. function showErp(part) { @@ -1013,6 +1054,27 @@ function wireActions() { act(event.currentTarget, "Revoke", (_shop, signer) => usdc(signer).approve(session.shop, 0n)), ); + onSubmit("unified-form", (data, button, form) => { + const chain = data.get("chain"); + const amount = String(data.get("amount") ?? "").trim(); + try { + parseUsdc(amount); + } catch (error) { + notify(describeError(error), "error"); + return; + } + unifiedAct(button, "Deposit", (provider) => unified.deposit(provider, chain, amount)).then(() => form.reset()); + }); + + $("unified-authorize").addEventListener("click", (event) => { + if (!session.agent) { + notify("Set the agent wallet in Settings first.", "error"); + return; + } + const chain = $("unified-chain").value; + unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.agent)); + }); + onSubmit("limits-form", (data, button, form) => { let maxPerPayment; let weeklyCap; diff --git a/dashboard/index.html b/dashboard/index.html index 4b1eb61..ab7939e 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -294,6 +294,23 @@
+ Keep USDC on other chains in your Unified Balance (Circle Gateway). When an invoice needs more than your + wallet on Arc holds, the agent can bring it to your wallet on Arc from the chains you authorize it on. + It never sends it anywhere else, and the contract's limits still apply. +
+ +