diff --git a/agent/wiring.py b/agent/wiring.py index a18e88f..9dc3b5b 100644 --- a/agent/wiring.py +++ b/agent/wiring.py @@ -60,7 +60,7 @@ def owner_funds_of(settings: Settings, payer: Payer | None) -> OwnerFunds | None return None 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)) + return OwnerFunds(kit, payer, OwnerWallets(payer.circle, facts), settings.signer_address) def brain_setup( diff --git a/dashboard/app.js b/dashboard/app.js index 9d1312c..c671f64 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -60,9 +60,8 @@ function accountMenu(open) { $("account-button").setAttribute("aria-expanded", String(open)); } -function readAddress(text, { allowEmpty = false } = {}) { +function readAddress(text) { const value = String(text ?? "").trim(); - if (!value && allowEmpty) return ZERO_ADDRESS; if (!ethers.isAddress(value)) throw new Error("That is not a valid wallet address (0x followed by 40 characters)."); return ethers.getAddress(value); } @@ -273,6 +272,7 @@ function hideAgent() { } async function renderErp(isOwner, state) { + session.signer = null; // known once the owner is signed in if (!isOwner) { hideAgent(); return; @@ -293,6 +293,7 @@ async function renderErp(isOwner, state) { showErp("erp-signin"); return; } + session.signer = hub.delegateOf(await hub.agentWallets(session.shop).catch(() => null), state.agent); if (!shop.connected) { hideAgent(); showErp("erp-form"); @@ -1047,13 +1048,11 @@ function wireActions() { const maxPerPayment = parseUsdc(data.get("maxPerPayment")); const weeklyCap = parseUsdc(data.get("weeklyCap")); if (maxPerPayment > weeklyCap) throw new Error("The per-payment limit cannot be above the weekly limit."); - const agent = readAddress(data.get("agent"), { allowEmpty: true }); - if (agent.toLowerCase() === session.account.toLowerCase()) { - throw new Error("The agent must be a different wallet from yours."); - } - notify("Create your shop contract: confirm it in MetaMask..."); const signer = await session.provider.getSigner(); - const tx = await factory(signer).create(agent, maxPerPayment, weeklyCap); + notify("Preparing your shop's agent (sign in if MetaMask asks: it is free)..."); + const wallets = await hub.newAgentWallets(signer); // the contract is created with its agent + notify("Create your shop contract: confirm it in MetaMask..."); + const tx = await factory(signer).create(wallets.agent, maxPerPayment, weeklyCap); notify("Creating your shop contract on Arc..."); await tx.wait(); settings(false); @@ -1087,8 +1086,8 @@ function wireActions() { }); onSubmit("funding-form", (data, button, form) => { - if (!session.agent) { - notify("Set the agent wallet in Settings first.", "error"); + if (!session.signer) { + notify("Sign in first: it is a signature, not a transaction, and costs nothing.", "error"); return; } let maxPerMove; @@ -1103,17 +1102,17 @@ function wireActions() { } 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 }), + unified.setUpChain(provider, chain, { agent: session.agent, signer: session.signer, maxPerMove, weeklyCap }), ).then(() => form.reset()); }); $("unified-authorize").addEventListener("click", (event) => { - if (!session.agent) { - notify("Set the agent wallet in Settings first.", "error"); + if (!session.signer) { + notify("Sign in first: it is a signature, not a transaction, and costs nothing.", "error"); return; } const chain = $("unified-chain").value; - unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.agent)); + unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.signer)); }); onSubmit("limits-form", (data, button, form) => { @@ -1215,7 +1214,7 @@ function wireActions() { button.disabled = true; try { notify("Sign in: confirm the signature in MetaMask (it is free)..."); - await hub.signIn(await session.provider.getSigner(), session.shop); + await hub.signIn(await session.provider.getSigner()); notify(""); await renderShop(); } catch (error) { diff --git a/dashboard/hub.js b/dashboard/hub.js index 745a5c0..e5ab27b 100644 --- a/dashboard/hub.js +++ b/dashboard/hub.js @@ -41,18 +41,50 @@ export function forEthers(typed) { return { domain: typed.domain, types, value: typed.message }; } -/** Sign-In with Ethereum (EIP-4361): the hub writes the message, the wallet signs it as is. */ -export async function signIn(signer, shop, fetchImpl = fetch) { +/** Sign-In with Ethereum (EIP-4361): the hub writes the message, the wallet signs it as is. One + * signature covers every shop of the wallet, and creating a new one. */ +export async function signIn(signer, fetchImpl = fetch) { const address = await signer.getAddress(); - const request = await call(`${shopPath(shop)}/sign-in-request`, { method: "POST", body: { address }, fetchImpl }); + const request = await call(`${HUB_PATH}/owners/sign-in-request`, { method: "POST", body: { address }, fetchImpl }); const signature = await signer.signMessage(request.message); - return call(`${shopPath(shop)}/sign-in`, { + return call(`${HUB_PATH}/owners/sign-in`, { method: "POST", body: { nonce: request.nonce, signature }, fetchImpl, }); } +/** The agent wallets of the owner's next shop ({agent, signer}): its contract is created with + * them. Signs in first when the owner has not yet. */ +export async function newAgentWallets(signer, fetchImpl = fetch) { + const ask = () => call(`${HUB_PATH}/owners/agent-wallets`, { method: "POST", fetchImpl }); + try { + return await ask(); + } catch (error) { + if (!(error instanceof HubError && error.status === 401)) throw error; + } + await signIn(signer, fetchImpl); + return ask(); +} + +/** The contract's agent and the wallet that signs the owner's Unified Balance spends; null when + * the owner has to sign in first. */ +export async function agentWallets(shop, fetchImpl = fetch) { + try { + return await call(`${shopPath(shop)}/agent`, { fetchImpl }); + } catch (error) { + if (error instanceof HubError && error.status === 401) return null; + throw error; + } +} + +/** Who the owner authorizes in Gateway: the agent's signer, or for an agent wallet that is not + * ours, the agent itself. */ +export function delegateOf(wallets, contractAgent) { + if (!wallets) return null; + return wallets.ours ? wallets.signer : contractAgent; +} + /** The shop as the hub knows it, or null when the owner has to sign in first. */ export async function status(shop, fetchImpl = fetch) { try { diff --git a/dashboard/index.html b/dashboard/index.html index 15884e0..ee19253 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -262,6 +262,7 @@
You will be its owner. Only you can change these limits later. The agent can never go above them. + Your shop comes with its own agent: you do not set it up, and it never needs gas.
diff --git a/dashboard/test/hub.test.mjs b/dashboard/test/hub.test.mjs index bfa14e8..2ab7dc2 100644 --- a/dashboard/test/hub.test.mjs +++ b/dashboard/test/hub.test.mjs @@ -33,15 +33,14 @@ test("ethers gets the types without the domain type", () => { }); test("signing in signs the hub's EIP-4361 message as is and sends back its nonce, on this site only", async () => { - const shop = SHOP.toLowerCase(); const OWNER = "0x2137" + "0".repeat(32) + "0379"; const message = "app.embco.xyz wants you to sign in with your Ethereum account:\n" + OWNER; const { impl, calls } = fakeFetch({ - [`POST /api/shops/${shop}/sign-in-request`]: [200, { message, nonce: "n1n1n1n1" }], - [`POST /api/shops/${shop}/sign-in`]: [200, { owner: OWNER }], + "POST /api/owners/sign-in-request": [200, { message, nonce: "n1n1n1n1" }], + "POST /api/owners/sign-in": [200, { owner: OWNER }], }); const signer = { getAddress: async () => OWNER, signMessage: async (text) => `sig:${text.length}` }; - assert.deepEqual(await hub.signIn(signer, SHOP, impl), { owner: OWNER }); + assert.deepEqual(await hub.signIn(signer, impl), { owner: OWNER }); assert.deepEqual(calls[0].body, { address: OWNER }); assert.deepEqual(calls[1].body, { nonce: "n1n1n1n1", signature: `sig:${message.length}` }); assert.ok(calls.every((c) => c.credentials === "same-origin")); @@ -145,3 +144,32 @@ test("linking Telegram tells the owner exactly what to send, and to which bot", "In Telegram, send this to @embco_agent_bot within 10 minutes: /link ABCD-EFGH"); assert.match(hub.linkInstruction({ code: "ABCD-EFGH", bot: null }), /your embco bot/); }); + +test("a new shop's agent wallets sign the owner in once when needed, and only then", async () => { + const OWNER = "0x2137" + "0".repeat(32) + "0379"; + const wallets = { agent: "0x" + "5c".repeat(20), signer: "0x" + "e0".repeat(20) }; + let signedIn = false; + const signatures = []; + const answer = (status, body) => ({ ok: status < 400, status, json: async () => body }); + const impl = async (path) => { + if (path === "/api/owners/sign-in-request") return answer(200, { message: "m", nonce: "n" }); + if (path === "/api/owners/sign-in") { + signedIn = true; + return answer(200, { owner: OWNER }); + } + if (path === "/api/owners/agent-wallets") return signedIn ? answer(200, wallets) : answer(401, { error: "sign in" }); + return answer(404, {}); + }; + const signer = { getAddress: async () => OWNER, signMessage: async (text) => signatures.push(text) }; + assert.deepEqual(await hub.newAgentWallets(signer, impl), wallets); + assert.deepEqual(await hub.newAgentWallets(signer, impl), wallets); + assert.equal(signatures.length, 1); +}); + +test("the owner authorizes the agent's signer in Gateway, or an outside agent itself", () => { + const agent = "0x" + "5c".repeat(20); + const signer = "0x" + "e0".repeat(20); + assert.equal(hub.delegateOf({ ours: true, signer }, agent), signer); + assert.equal(hub.delegateOf({ ours: false, signer: null }, agent), agent); + assert.equal(hub.delegateOf(null, agent), null); +}); diff --git a/dashboard/test/unified.test.mjs b/dashboard/test/unified.test.mjs index 3a013cd..15666bb 100644 --- a/dashboard/test/unified.test.mjs +++ b/dashboard/test/unified.test.mjs @@ -66,3 +66,16 @@ test("setting up a chain is create, approve and authorize, skipping what is done 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]); }); + +test("a gasless agent's funding contract is the agent's, and Gateway authorizes its signer", async () => { + const { setupCalls } = await import("../unified.js"); + const agent = "0x" + "a9".repeat(20); + const signer = "0x" + "e0".repeat(20); + const [create, , authorize] = setupCalls({ + factory: "0x" + "fa".repeat(20), funding: "0x" + "f1".repeat(20), usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + gateway: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", agent, signer, + maxPerMove: 1n, weeklyCap: 2n, create: true, delegate: true, + }); + assert.ok(create.data.includes(agent.slice(2)) && !create.data.includes(signer.slice(2))); + assert.ok(authorize.data.includes(signer.slice(2)) && !authorize.data.includes(agent.slice(2))); +}); diff --git a/dashboard/unified.js b/dashboard/unified.js index ad2e7ba..68addb9 100644 --- a/dashboard/unified.js +++ b/dashboard/unified.js @@ -84,8 +84,9 @@ 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 }) { + * for the agent (skipped when it exists), approve it, and authorize the agent's signer in Gateway + * (skipped when it is). The signer is the agent itself for an older shop. */ +export function setupCalls({ factory, funding, create, usdc, gateway, agent, signer = agent, maxPerMove, weeklyCap, delegate }) { const calls = []; if (create) { const data = new ethers.Interface(FUNDING_FACTORY_ABI).encodeFunctionData("create", [agent, maxPerMove, weeklyCap]); @@ -93,7 +94,7 @@ export function setupCalls({ factory, funding, create, usdc, gateway, agent, max } 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" }); + calls.push({ to: gateway, data: new ethers.Interface(GATEWAY_WALLET_ABI).encodeFunctionData("addDelegate", [usdc, signer]), value: "0x0" }); } return calls; } @@ -113,7 +114,7 @@ async function waitForCalls(provider, id) { * 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) { +export async function setUpChain(provider, chain, { agent, signer: delegateAddress = 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 @@ -123,10 +124,10 @@ export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap 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 delegate = await new UnifiedBalanceKit().getDelegateStatus({ from: { adapter, chain }, delegateAddress }); const calls = setupCalls({ factory: factories[chain], funding, create: !existing, usdc: definition.usdcAddress, - gateway: GATEWAY_WALLET_TESTNET, agent, maxPerMove, weeklyCap, delegate: delegate === "none", + gateway: GATEWAY_WALLET_TESTNET, agent, signer: delegateAddress, maxPerMove, weeklyCap, delegate: delegate === "none", }); const chainId = ethers.toQuantity(definition.chainId); const capabilities = await provider.request({ method: "wallet_getCapabilities", params: [owner, [chainId]] }) @@ -144,9 +145,9 @@ export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap 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) { +/** Let the agent's signer spend the owner's Unified Balance on this chain (only to the owner). */ +export async function authorizeAgent(provider, chain, signer) { const { UnifiedBalanceKit } = await kit(); const adapter = await adapterOn(provider, chain); - return new UnifiedBalanceKit().addDelegate({ from: { adapter, chain }, delegateAddress: agent }); + return new UnifiedBalanceKit().addDelegate({ from: { adapter, chain }, delegateAddress: signer }); } diff --git a/docs/connect.md b/docs/connect.md index 27debb7..43b1218 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -6,7 +6,9 @@ and the agent never gets admin rights. !!! info "Before you start" - Your shop is already created in the [dashboard](https://app.embco.xyz): your wallet is - connected and your shop contract exists (create it in **Settings**). + connected and your shop contract exists (create it in **Settings**). Creating it asks for + one free signature and one transaction, and the shop comes with its own agent: you never + set it up or send it gas. - You can sign in to ERPNext as an administrator (System Manager) to make the changes below. - Your ERPNext opens with an `https://` address. embco is tested with ERPNext v16, including Frappe Cloud. @@ -67,8 +69,8 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next ## Step 5: Connect in the dashboard -1. Open [app.embco.xyz](https://app.embco.xyz) and **Sign in** with your wallet. It is a - signature, not a transaction: it costs nothing. +1. Open [app.embco.xyz](https://app.embco.xyz) and **Sign in** with your wallet, if you did + not when you created the shop. It is a signature, not a transaction: it costs nothing. 2. In **Your ERPNext**, fill in: | Field | What to write | @@ -91,8 +93,8 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next | Payments | ERPNext can draft a payment for an invoice (nothing is saved) | | Wallets | How many supplier wallets were found at `USDC on Arc` | -4. Click **Make it the agent**. This lets your shop's agent wallet pay approved suppliers, within - the limits you set. +4. A shop created before its agent came with it shows **Make it the agent**: click it once. This + lets your shop's agent wallet pay approved suppliers, within the limits you set. ## What happens next @@ -110,8 +112,9 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next 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. + never sends it anywhere else, and your limits still apply. Your wallet needs that chain's gas + token for this setup (test ETH on Base Sepolia and Ethereum Sepolia). The agent needs none: its + network fees are paid by Circle Gas Station. - **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/services/circle/client.py b/services/circle/client.py index 5eafa4d..72d0bfe 100644 --- a/services/circle/client.py +++ b/services/circle/client.py @@ -30,6 +30,7 @@ class CircleWallet: address: str blockchain: str wallet_set_id: str + account_type: str = "EOA" # or "SCA": a smart contract account, whose gas Circle pays @dataclass(frozen=True) @@ -126,35 +127,53 @@ def create_eoa_wallet( blockchain: str = ARC_TESTNET, idempotency_key: str | None = None, ) -> CircleWallet: + return self.create_wallet(wallet_set_id, name, account_type="EOA", + blockchain=blockchain, idempotency_key=idempotency_key) + + def create_wallet( + self, + wallet_set_id: str, + name: str, + *, + account_type: str, + blockchain: str = ARC_TESTNET, + idempotency_key: str | None = None, + ) -> CircleWallet: + """An SCA is deployed by its first transaction, with the gas paid by Gas Station.""" body = self._signed_body( idempotency_key, walletSetId=wallet_set_id, blockchains=[blockchain], - accountType="EOA", + accountType=account_type, count=1, metadata=[{"name": name}], ) wallets = self._request("POST", "/v1/w3s/developer/wallets", body)["wallets"] if len(wallets) != 1: raise CircleError(f"Circle created {len(wallets)} wallets, expected 1") - w = wallets[0] - return CircleWallet(id=w["id"], address=w["address"], blockchain=w["blockchain"], - wallet_set_id=w["walletSetId"]) + return _wallet(wallets[0]) def get_wallet(self, wallet_id: str) -> CircleWallet: - w = self._request("GET", f"/v1/w3s/wallets/{wallet_id}")["wallet"] - return CircleWallet(id=w["id"], address=w["address"], blockchain=w["blockchain"], - wallet_set_id=w["walletSetId"]) + return _wallet(self._request("GET", f"/v1/w3s/wallets/{wallet_id}")["wallet"]) + + def wallets_in_set(self, wallet_set_id: str, + blockchain: str = ARC_TESTNET) -> list[CircleWallet]: + wallets = self._request( + "GET", f"/v1/w3s/wallets?walletSetId={wallet_set_id}&blockchain={blockchain}") + return [_wallet(w) for w in wallets["wallets"]] def wallet_on(self, address: str, blockchain: str) -> CircleWallet: """The wallet with this address on this chain (one address, one wallet per chain).""" + wallet = self.find_wallet(address, blockchain) + if wallet is None: + raise CircleError(f"no Circle wallet {address} on {blockchain}") + return wallet + + def find_wallet(self, address: str, blockchain: str) -> CircleWallet | None: + """None when no wallet of this account has the address on this 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"]) + return _wallet(wallets[0]) if wallets else None def execute_contract( self, @@ -186,6 +205,11 @@ def get_transaction(self, transaction_id: str) -> CircleTransaction: error_reason=tx.get("errorReason")) +def _wallet(w: dict[str, Any]) -> CircleWallet: + return CircleWallet(id=w["id"], address=w["address"], blockchain=w["blockchain"], + wallet_set_id=w["walletSetId"], account_type=w.get("accountType", "EOA")) + + def _error_detail(response: httpx.Response) -> str: try: body = response.json() diff --git a/services/hub/api.py b/services/hub/api.py index 94b0fb3..3432767 100644 --- a/services/hub/api.py +++ b/services/hub/api.py @@ -3,6 +3,9 @@ Every shop route needs a session from the shop owner's signature. ERP keys come in once, are checked, stored encrypted, and never sent back. Connecting starts the shop's own agent process; disconnecting stops it and forgets the keys, keeping the agent's memory. + +The dashboard signs in once for all the owner's shops. Before a new shop exists, the hub makes its +agent wallets, so the contract is created with its agent and the owner never sets one by hand. """ from collections.abc import Callable @@ -17,7 +20,7 @@ from services.circle import CircleClient, CircleError from services.erp.erpnext.adapter import PROTECTED_PAYMENT_FIELDS from services.erp.erpnext.client import FrappeClient -from services.hub.auth import SESSION_LIFETIME, Auth, AuthError +from services.hub.auth import ALL_SHOPS, SESSION_LIFETIME, Auth, AuthError from services.hub.chain import ShopLimits from services.hub.errors import HubError from services.hub.guard import UnsafeUrl, check_erp_url @@ -26,7 +29,7 @@ from services.hub.supplier_auth import SupplierAuth from services.hub.suppliers import supplier_routes from services.hub.units import UnitError, Units -from services.hub.wallets import create_agent_wallet +from services.hub.wallets import agent_wallets_of, create_agent_wallet, create_agent_wallets from services.payments.chain import ChainError from services.shops.store import ErpCredentials, ShopConfig, ShopError, ShopStore, check_shop from services.telegram import links @@ -73,6 +76,7 @@ def create_app( public_url: str = PUBLIC_URL, supplier_auth: SupplierAuth | None = None, telegram_bot: str | None = None, + shops_of: Callable[[str], list[str]] | None = None, ) -> FastAPI: app = FastAPI(title="embco hub", docs_url=None, redoc_url=None, openapi_url=None) frappe_for = frappe_for or (lambda url, key, secret: FrappeClient(url, key, secret, @@ -93,6 +97,8 @@ def owner_session(shop: str, token: str | None): return auth.session(token, shop) except AuthError as error: raise HubError(401, str(error)) from error + except ChainError as error: + raise HubError(502, "could not read your shops") from error def shop_of(raw: str) -> str: try: @@ -119,6 +125,59 @@ def sign_in(shop: str, body: SignIn) -> JSONResponse: httponly=True, secure=True, samesite="strict", path="/api") return response + @app.post("/api/owners/sign-in-request") + def owner_sign_in_request(body: SignInRequest) -> dict[str, Any]: + if not is_address(body.address): + raise HubError(422, "this is not a wallet address") + return auth.challenge(ALL_SHOPS, to_checksum_address(body.address)) + + @app.post("/api/owners/sign-in") + def owner_sign_in(body: SignIn) -> JSONResponse: + try: + token, session = auth.sign_in(ALL_SHOPS, body.nonce, body.signature) + except AuthError as error: + raise HubError(401, str(error)) from error + response = JSONResponse({"owner": session.owner}) + response.set_cookie(COOKIE, token, max_age=int(SESSION_LIFETIME.total_seconds()), + httponly=True, secure=True, samesite="strict", path="/api") + return response + + @app.post("/api/owners/agent-wallets") + def new_agent_wallets(embco_session: str | None = Cookie(default=None)) -> dict[str, Any]: + """The agent wallets for the owner's next shop: the contract is created with them.""" + owner = owner_session(ALL_SHOPS, embco_session).owner + if circle is None or shops_of is None: + raise HubError(503, "agent wallets are not available here") + try: + index = len(shops_of(owner)) + except ChainError as error: + raise HubError(502, "could not read your shops") from error + try: + wallets = create_agent_wallets(circle, owner, index) + except CircleError as error: + raise HubError(502, "could not create the agent wallets; try again") from error + return {"agent": wallets.agent.address, "signer": wallets.signer.address} + + @app.get("/api/shops/{shop}/agent") + def agent_wallets(shop: str, + embco_session: str | None = Cookie(default=None)) -> dict[str, Any]: + """The contract's agent and the wallet that signs the owner's Unified Balance spends, + with or without a connected ERP.""" + shop = shop_of(shop) + owner_session(shop, embco_session) + try: + agent = limits_of(shop).agent + except ChainError as error: + raise HubError(502, "could not read the shop contract") from error + try: + wallets = agent_wallets_of(circle, agent) if circle else None + except CircleError as error: + raise HubError(502, "could not read the agent wallets; try again") from error + if wallets is None: + return {"agent": agent, "ours": False, "signer": None, "gasless": False} + return {"agent": agent, "ours": True, "signer": wallets.signer.address, + "gasless": wallets.agent.account_type == "SCA"} + @app.post("/api/shops/{shop}/sign-out") def sign_out(shop: str, embco_session: str | None = Cookie(default=None)) -> JSONResponse: auth.sign_out(embco_session) @@ -182,18 +241,28 @@ def connect(shop: str, body: Connect, previous = None wallet_id = previous.agent_wallet_id if previous else None wallet_address = previous.agent_wallet_address if previous else None + signer_address = previous.signer_wallet_address if previous else None if circle and not wallet_id: try: - wallet = create_agent_wallet(circle, shop) + # a shop created with its agent: the contract already names it; an older shop + # gets its agent wallet now + wallets = agent_wallets_of(circle, limits.agent) + if wallets is None: + wallet = create_agent_wallet(circle, shop) except CircleError as error: raise HubError(502, "could not create the agent wallet; try again") from error + if wallets: + wallet = wallets.agent + if wallets.signer.id != wallet.id: + signer_address = wallets.signer.address wallet_id, wallet_address = wallet.id, wallet.address store.save( ShopConfig(shop=shop, erp_url=base, company=body.company, max_per_payment=str(limits.max_per_payment), weekly_budget=str(limits.weekly_cap), wallet_bank=body.wallet_bank, payment_extra=body.payment_extra, agent_wallet_id=wallet_id, - agent_wallet_address=wallet_address), + agent_wallet_address=wallet_address, + signer_wallet_address=signer_address), ErpCredentials(api_key=body.api_key, api_secret=body.api_secret), ) if store.agent_on(shop): # turned off by the owner: a new connection does not start it diff --git a/services/hub/auth.py b/services/hub/auth.py index 01a7e1e..fc7ea21 100644 --- a/services/hub/auth.py +++ b/services/hub/auth.py @@ -4,6 +4,10 @@ owner's wallet signs it, and the hub checks it with the siwe library, then checks on chain that our factory lists the shop under the signer. A nonce works once and for a few minutes; a session lives in memory, so a restart only asks the owner to sign again. + +The dashboard signs in for ALL_SHOPS instead: one signature covers every shop the wallet owns, +including one it creates later in the same session, and lets the hub make the agent wallets of a +new shop only for the wallet that asked for them. """ import secrets @@ -17,6 +21,7 @@ NONCE_LIFETIME = timedelta(minutes=5) SESSION_LIFETIME = timedelta(hours=12) +ALL_SHOPS = "all-shops" # a sign-in for every shop of the wallet, and for creating one Clock = Callable[[], datetime] @@ -33,6 +38,8 @@ class Session: def statement(shop: str) -> str: + if shop == ALL_SHOPS: + return "Sign in to create and manage your embco shops." return f"Sign in to manage the embco agent of the shop contract {shop}." @@ -47,6 +54,7 @@ def __init__(self, shops_of: Callable[[str], list[str]], *, self._clock = clock self._nonces: dict[str, tuple[str, str, datetime]] = {} # nonce: (shop, message, expiry) self._sessions: dict[str, Session] = {} + self._owned: dict[str, set[str]] = {} # owner: shops seen on chain, read again on a miss def challenge(self, shop: str, address: str) -> dict[str, Any]: """The sign-in message for `address` (checksummed) to sign with personal_sign.""" @@ -69,7 +77,7 @@ def sign_in(self, shop: str, nonce: str, signature: str) -> tuple[str, Session]: chain_id=self._chain_id, now=self._clock()) except VerificationError as error: raise AuthError("the signature is not valid") from error - if shop not in {s.lower() for s in self._shops_of(signer)}: + if shop != ALL_SHOPS and shop not in {s.lower() for s in self._shops_of(signer)}: raise AuthError("this wallet does not own the shop") token = secrets.token_urlsafe(32) session = Session(shop=shop, owner=signer, expires_at=self._clock() + SESSION_LIFETIME) @@ -80,8 +88,14 @@ def session(self, token: str | None, shop: str) -> Session: session = self._sessions.get(token or "") if session is None or session.expires_at < self._clock(): raise AuthError("sign in with the shop owner's wallet") - if session.shop != shop: + if session.shop == shop: + return session + if session.shop != ALL_SHOPS: raise AuthError("this session is for another shop") + if shop not in self._owned.get(session.owner, set()): + self._owned[session.owner] = {s.lower() for s in self._shops_of(session.owner)} + if shop not in self._owned[session.owner]: + raise AuthError("this wallet does not own the shop") return session def sign_out(self, token: str | None) -> None: diff --git a/services/hub/server.py b/services/hub/server.py index 73f5ac9..b25cd16 100644 --- a/services/hub/server.py +++ b/services/hub/server.py @@ -47,6 +47,7 @@ def build_app(env: Mapping[str, str]) -> FastAPI: circle=circle, public_url=public_url, telegram_bot=env.get("TELEGRAM_BOT_USERNAME", "").strip().lstrip("@") or None, + shops_of=chain.shops_of, ) diff --git a/services/hub/wallets.py b/services/hub/wallets.py index f07453b..3d055a9 100644 --- a/services/hub/wallets.py +++ b/services/hub/wallets.py @@ -1,10 +1,17 @@ -"""Each shop gets its own Circle wallet for its agent, so one contract's limits and brake cover -exactly one shop. The owner then sets that address as the agent of their contract. +"""Each shop gets its own Circle wallets for its agent, so one contract's limits and brake cover +exactly one shop. + +A new shop gets two, made before its contract so the contract is created with its agent: a smart +contract account (SCA) that is the agent of the contract and sends every transaction, its gas +paid by Circle Gas Station on every chain; and an EOA in the same wallet set that only signs the +owner's Unified Balance spends as their delegate, which an SCA cannot sign. Older shops have one +EOA that does both and pays its own gas. """ import uuid +from dataclasses import dataclass -from services.circle import CircleClient, CircleWallet +from services.circle import ARC_TESTNET, CircleClient, CircleError, CircleWallet _NAMESPACE = uuid.UUID("5b0c7e0e-6a43-4f43-9d8e-6d2f0b1f2a10") # fixed: same shop, same keys @@ -23,3 +30,37 @@ def create_agent_wallet(circle: CircleClient, shop: str) -> CircleWallet: set_name, idempotency_key=str(uuid.uuid5(_NAMESPACE, f"set:{shop}"))) return circle.create_eoa_wallet( wallet_set, wallet_name, idempotency_key=str(uuid.uuid5(_NAMESPACE, f"wallet:{shop}"))) + + +@dataclass(frozen=True) +class AgentWallets: + agent: CircleWallet # the agent of the contract + signer: CircleWallet # signs Unified Balance spends; the agent itself in older shops + + +def create_agent_wallets(circle: CircleClient, owner: str, index: int) -> AgentWallets: + """The wallets for the owner's shop number `index` (0 for their first). Idempotency keys come + from both, so asking again before the contract exists returns the same two wallets.""" + owner = owner.lower() + key = lambda part: str(uuid.uuid5(_NAMESPACE, f"{part}:{owner}:{index}")) # noqa: E731 + tag = f"{owner[:10]} #{index + 1}" + wallet_set = circle.create_wallet_set(f"embco {tag}", idempotency_key=key("set")) + agent = circle.create_wallet(wallet_set, f"embco agent {tag}", account_type="SCA", + idempotency_key=key("agent")) + signer = circle.create_wallet(wallet_set, f"embco signer {tag}", account_type="EOA", + idempotency_key=key("signer")) + return AgentWallets(agent=agent, signer=signer) + + +def agent_wallets_of(circle: CircleClient, agent: str) -> AgentWallets | None: + """Our wallets behind the agent of a contract; None when it is not one of ours.""" + wallet = circle.find_wallet(agent, ARC_TESTNET) + if wallet is None: + return None + if wallet.account_type != "SCA": + return AgentWallets(agent=wallet, signer=wallet) + signers = [w for w in circle.wallets_in_set(wallet.wallet_set_id) + if w.account_type == "EOA"] + if len(signers) != 1: + raise CircleError(f"the agent's wallet set has {len(signers)} signers, expected 1") + return AgentWallets(agent=wallet, signer=signers[0]) diff --git a/services/settings.py b/services/settings.py index ab552b2..561dbc6 100644 --- a/services/settings.py +++ b/services/settings.py @@ -63,6 +63,7 @@ class Settings: circle_api_key: str | None = field(default=None, repr=False) circle_entity_secret: str | None = field(default=None, repr=False) agent_wallet_id: str | None = None + signer_address: str | None = None # the agent's EOA for Unified Balance spends pay: bool = False erpnext_paid_from: str | None = None erpnext_wallet_bank: str | None = None # wallets in Bank Account rows of this bank @@ -113,6 +114,7 @@ def get(name: str) -> str: circle_api_key=env.get("CIRCLE_API_KEY", "").strip() or None, circle_entity_secret=env.get("CIRCLE_ENTITY_SECRET", "").strip() or None, agent_wallet_id=get("AGENT_WALLET_ID") or None, + signer_address=get("SIGNER_ADDRESS") or None, pay=pay, erpnext_paid_from=get("ERPNEXT_PAID_FROM") or None, erpnext_wallet_bank=get("ERPNEXT_WALLET_BANK") or None, diff --git a/services/shops/store.py b/services/shops/store.py index 0cddcd3..ee097bb 100644 --- a/services/shops/store.py +++ b/services/shops/store.py @@ -58,6 +58,7 @@ class ShopConfig: interval_minutes: int = 15 agent_wallet_id: str | None = None # the shop's own Circle wallet, when created agent_wallet_address: str | None = None + signer_wallet_address: str | None = None # the EOA that signs Unified Balance spends brain: bool = True # Claude decides what is paid and when; off means nothing is paid autonomy: str = "act" # or "observe": the agent decides and explains, pays nothing utc_offset: str = "" # the shop's local time, like +01:00 @@ -165,6 +166,7 @@ def settings(self, shop: str, platform: Mapping[str, str] | None = None) -> Sett env.update({name: (platform or {}).get(name, "").strip() for name in BRAIN_PLATFORM}) if pay: env.update(shared, EMBCO_AGENT_WALLET_ID=config.agent_wallet_id, + EMBCO_SIGNER_ADDRESS=config.signer_wallet_address or "", EMBCO_SHOP_ADDRESS=shop) return Settings.from_env(env) diff --git a/services/unified_balance/owner.py b/services/unified_balance/owner.py index 9c05586..4e9c9fa 100644 --- a/services/unified_balance/owner.py +++ b/services/unified_balance/owner.py @@ -17,22 +17,32 @@ 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 + # The agent's EOA that signs spends as the owner's delegate: a Circle smart contract account + # cannot sign them. None for a shop whose agent wallet is that EOA itself. + signer: str | None = None def _owner(self) -> str: return self.payer.chain.owner_of(self.payer.shop) + def _signer(self) -> str: + return self.signer or self.payer.agent_address() + def prepare(self) -> list[str]: - """The agent's Circle wallet on every source chain, so it can sign spends there.""" - return self.kit.prepare(self.payer.agent_address()) + """The agent's Circle wallets on every source chain: the signer signs spends there, and + the agent wallet moves the owner's USDC into the Unified Balance there.""" + chains = self.kit.prepare(self._signer()) + if self.signer: + self.kit.prepare(self.payer.agent_address()) + return chains def chains(self) -> list[ChainFunds]: - return self.kit.balances(self._owner(), self.payer.agent_address()) + return self.kit.balances(self._owner(), self._signer()) def estimate(self, allocations: Sequence[Allocation]) -> Any: - return self.kit.estimate(self._owner(), self.payer.agent_address(), allocations) + return self.kit.estimate(self._owner(), self._signer(), allocations) def bring(self, allocations: Sequence[Allocation]) -> Any: - return self.kit.spend(self._owner(), self.payer.agent_address(), allocations) + return self.kit.spend(self._owner(), self._signer(), allocations) def in_wallets(self) -> list[WalletFunds]: """The owner's USDC in their own wallets on other chains, and what the agent may move.""" diff --git a/tests/test_circle_client.py b/tests/test_circle_client.py index 6914be4..0041b8d 100644 --- a/tests/test_circle_client.py +++ b/tests/test_circle_client.py @@ -75,6 +75,31 @@ def test_create_wallet_set_and_eoa_wallet_on_arc_testnet(): assert len(set(keys)) == 2 +def test_a_smart_contract_account_and_the_wallets_of_a_set(): + sca = {"id": "w-2", "address": "0xdef", "blockchain": "ARC-TESTNET", "walletSetId": "ws-1", + "accountType": "SCA"} + eoa = {**sca, "id": "w-3", "address": "0x123", "accountType": "EOA"} + fake = FakeCircle({ + ("POST", "/v1/w3s/developer/wallets"): (201, {"data": {"wallets": [sca]}}), + ("GET", "/v1/w3s/wallets"): (200, {"data": {"wallets": [sca, eoa]}}), + }) + client, _ = make(fake) + wallet = client.create_wallet("ws-1", "agent", account_type="SCA") + assert (wallet.id, wallet.account_type) == ("w-2", "SCA") + [body] = fake.bodies("/v1/w3s/developer/wallets") + assert body["accountType"] == "SCA" and body["blockchains"] == ["ARC-TESTNET"] + assert [w.account_type for w in client.wallets_in_set("ws-1")] == ["SCA", "EOA"] + assert fake.requests[-1].url.params["walletSetId"] == "ws-1" + + +def test_an_address_that_is_not_ours_is_none_not_an_error(): + fake = FakeCircle({("GET", "/v1/w3s/wallets"): (200, {"data": {"wallets": []}})}) + client, _ = make(fake) + assert client.find_wallet("0xabc", "ARC-TESTNET") is None + with pytest.raises(CircleError, match="no Circle wallet"): + client.wallet_on("0xabc", "ARC-TESTNET") + + def test_errors_carry_circle_code_but_never_the_credentials(): api_key = "TEST_API_KEY:id:" + secrets.token_hex(16) fake = FakeCircle({("POST", "/v1/w3s/developer/walletSets"): diff --git a/tests/test_hub.py b/tests/test_hub.py index f93582e..5143ab8 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -172,33 +172,52 @@ def stop(self, shop): class FakeCircle: + """Circle as the hub sees it: wallets by idempotency key, so asking again returns the same.""" + def __init__(self): self.created = 0 self.address = Account.create().address + self.wallets: dict[str, CircleWallet] = {} def create_wallet_set(self, name, *, idempotency_key=None): - return "set-1" + return "set-" + str(idempotency_key)[:8] def create_eoa_wallet(self, wallet_set_id, name, *, idempotency_key=None): self.created += 1 return CircleWallet(id="w-1", address=self.address, blockchain="ARC-TESTNET", wallet_set_id=wallet_set_id) + def create_wallet(self, wallet_set_id, name, *, account_type, idempotency_key=None): + if idempotency_key not in self.wallets: + self.wallets[idempotency_key] = CircleWallet( + id=f"w-{len(self.wallets) + 2}", address=Account.create().address, + blockchain="ARC-TESTNET", wallet_set_id=wallet_set_id, account_type=account_type) + return self.wallets[idempotency_key] + + def find_wallet(self, address, blockchain): + return next((w for w in self.wallets.values() if w.address == address), None) + + def wallets_in_set(self, wallet_set_id, blockchain="ARC-TESTNET"): + return [w for w in self.wallets.values() if w.wallet_set_id == wallet_set_id] + @pytest.fixture -def hub(tmp_path, auth): +def hub(tmp_path, auth, owner): store = ShopStore(tmp_path / "shops", new_key()) units, circle = FakeUnits(), FakeCircle() erp = {"status": 200} + chain = {"agent": AGENT_ON_CONTRACT, "shops": []} app = create_app( store=store, auth=auth, units=units, circle=circle, - limits_of=lambda shop: ShopLimits(Decimal("300"), Decimal("2000"), AGENT_ON_CONTRACT), + shops_of=lambda wallet: list(chain["shops"]) if wallet == owner.address else [], + limits_of=lambda shop: ShopLimits(Decimal("300"), Decimal("2000"), chain["agent"]), frappe_for=lambda url, key, secret: FrappeClient( url, key, secret, client=httpx.Client(transport=erp_transport(status=erp["status"]))), check_url=lambda url: check_erp_url(url, resolve=public), ) client = TestClient(app, base_url="https://testserver") - return {"client": client, "store": store, "units": units, "circle": circle, "erp": erp} + return {"client": client, "store": store, "units": units, "circle": circle, "erp": erp, + "chain": chain} def signed_in(hub, owner, shop=SHOP): @@ -315,6 +334,96 @@ def create_eoa_wallet(self, wallet_set_id, name, *, idempotency_key=None): assert calls[0] == ("embco " + SHOP[:10], calls[0][1]) +# ---- a new shop is created with its agent + + +def signed_in_to_create(hub, account): + client = hub["client"] + message = client.post("/api/owners/sign-in-request", json={"address": account.address}).json() + assert "create and manage your embco shops" in message["message"] + response = client.post("/api/owners/sign-in", json={ + "nonce": message["nonce"], "signature": sign(account, message)}) + assert response.status_code == 200, response.text + return client + + +def test_no_agent_wallets_without_the_owners_signature(hub): + assert hub["client"].post("/api/owners/agent-wallets").status_code == 401 + assert hub["circle"].wallets == {} + + +def test_a_new_shop_gets_a_gasless_agent_and_a_signer_before_its_contract(hub, owner): + client = signed_in_to_create(hub, owner) + first = client.post("/api/owners/agent-wallets").json() + assert client.post("/api/owners/agent-wallets").json() == first # asking again: same two + kinds = {w.address: w.account_type for w in hub["circle"].wallets.values()} + assert kinds == {first["agent"]: "SCA", first["signer"]: "EOA"} + hub["chain"]["shops"].append(SHOP) # the owner created the shop: the next one is new + second = client.post("/api/owners/agent-wallets").json() + assert {second["agent"], second["signer"]}.isdisjoint({first["agent"], first["signer"]}) + + +def test_one_signature_covers_the_owners_shops_only(hub, owner): + client = signed_in_to_create(hub, owner) + assert client.get(f"/api/shops/{SHOP}").status_code == 200 + assert client.get(f"/api/shops/{OTHER_SHOP}").status_code == 401 + + +def test_a_shop_created_after_signing_in_needs_no_second_signature(owner, clock): + from services.hub.auth import ALL_SHOPS + + owned, reads = [], [] + auth = Auth(lambda wallet: reads.append(wallet) or list(owned), + public_url="https://app.embco.xyz", clock=clock) + message = auth.challenge(ALL_SHOPS, owner.address) + token, _ = auth.sign_in(ALL_SHOPS, message["nonce"], sign(owner, message)) + with pytest.raises(AuthError, match="does not own"): + auth.session(token, SHOP) + owned.append(SHOP) # the owner creates the shop + assert auth.session(token, SHOP).owner == owner.address + auth.session(token, SHOP) + assert len(reads) == 2 # read again only on a miss + + +def test_a_shop_created_with_its_agent_needs_no_erp_to_show_it(hub, owner): + client = signed_in_to_create(hub, owner) + wallets = client.post("/api/owners/agent-wallets").json() + hub["chain"].update(agent=wallets["agent"], shops=[SHOP]) + assert client.get(f"/api/shops/{SHOP}/agent").json() == { + "agent": wallets["agent"], "ours": True, "signer": wallets["signer"], "gasless": True} + body = client.post(f"/api/shops/{SHOP}/erp", json=connect_body(secrets.token_hex(16))).json() + assert body["agent_wallet"] == wallets["agent"] and body["set_agent_needed"] is False + config = hub["store"].config(SHOP) + assert config.signer_wallet_address == wallets["signer"] + assert hub["circle"].created == 0 # no older single wallet was made + + +def test_an_agent_that_is_not_ours_is_shown_as_such(hub, owner): + client = signed_in(hub, owner) + assert client.get(f"/api/shops/{SHOP}/agent").json() == { + "agent": AGENT_ON_CONTRACT, "ours": False, "signer": None, "gasless": False} + + +def test_new_shop_wallet_names_stay_short(): + from services.hub.wallets import create_agent_wallets + + names = [] + + class Recording(FakeCircle): + def create_wallet_set(self, name, *, idempotency_key=None): + names.append(name) + return super().create_wallet_set(name, idempotency_key=idempotency_key) + + def create_wallet(self, wallet_set_id, name, *, account_type, idempotency_key=None): + names.append(name) + return super().create_wallet(wallet_set_id, name, account_type=account_type, + idempotency_key=idempotency_key) + + create_agent_wallets(Recording(), "0x" + "ab" * 20, 11) + assert names[0] == "embco 0xabababab #12" + assert all(len(name) <= 30 for name in names) # Circle refused 53 characters + + # ---- after a reboot diff --git a/tests/test_shops.py b/tests/test_shops.py index abc7588..f96ae93 100644 --- a/tests/test_shops.py +++ b/tests/test_shops.py @@ -89,6 +89,14 @@ def test_a_shop_with_its_agent_wallet_pays_on_testnet_and_leaves_drafts(store): assert settings.circle_api_key == shared["CIRCLE_API_KEY"] +def test_a_gasless_agent_runs_with_its_signer_and_an_older_one_without(store): + signer = "0x" + "e0" * 20 + store.save(config(agent_wallet_id="wallet-a", signer_wallet_address=signer), creds()) + assert store.settings(SHOP_A, platform()).signer_address == signer + store.save(config(agent_wallet_id="wallet-a"), creds()) + assert store.settings(SHOP_A, platform()).signer_address is None + + @pytest.mark.parametrize("missing", ["CIRCLE_API_KEY", "CIRCLE_ENTITY_SECRET", "ARC_TESTNET_RPC_URL"]) def test_without_the_platform_accounts_a_shop_only_observes(store, missing): diff --git a/tests/test_unified_balance.py b/tests/test_unified_balance.py index 6068497..ef76e3d 100644 --- a/tests/test_unified_balance.py +++ b/tests/test_unified_balance.py @@ -109,6 +109,34 @@ def spend(self, owner, agent, allocations): assert seen == [(OWNER, AGENT)] +def test_a_gasless_agent_has_its_spends_signed_by_its_signer(): + seen = [] + + class Kit: + def spend(self, owner, signer, allocations): + seen.append(("spend", signer)) + return {} + + def balances(self, owner, signer): + seen.append(("balances", signer)) + return [] + + def prepare(self, wallet): + seen.append(("prepare", wallet)) + return ["Base_Sepolia"] + + signer = "0x" + "e0" * 20 + payer = SimpleNamespace(shop="0x" + "5c" * 20, agent_address=lambda: AGENT, + chain=SimpleNamespace(owner_of=lambda shop: OWNER)) + funds = OwnerFunds(Kit(), payer, signer=signer) + funds.bring([Allocation("Base_Sepolia", Decimal(1))]) + funds.chains() + assert funds.prepare() == ["Base_Sepolia"] + # the signer signs spends; both wallets exist on every chain (the agent deposits there) + assert seen == [("spend", signer), ("balances", signer), ("prepare", signer), + ("prepare", AGENT)] + + # ---- the agent's tools