From 36214a0a60a576fbcd924c0337ea149388cb2a21 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 28 Oct 2026 04:07:15 -0500 Subject: [PATCH 1/4] feat(chain): Circle's Arc as a third x402 payment chain Arc (eip155:5042) is selectable everywhere Base and Solana are: `clawrouter chain arc` / `wallet arc`, OpenClaw `/wallet arc`, CLAWROUTER_PAYMENT_CHAIN=arc, and the persisted chain files. The fresh-install default stays Solana. - src/payment-chain.ts: one table for chain type, labels, gateways, Arc CAIP-2 and USDC address; auth/cli/proxy/doctor/index read from it. - proxy: arc -> https://arc.blockrun.ai/api. Signing needs no new code: the exact-EVM scheme takes chainId from the 402 network and the USDC domain ("USDC", "2") from its extra. Builder code is no longer stamped on Arc payments (a CDP registration; Arc settles through Circle's facilitator). Per-payment log and insufficient-funds help name Arc instead of Base. - balance: BalanceMonitor(address, "arc") reads micro-USDC from the Arc gateway's /v1/balance (no RPC key shipped; public Arc RPC is unreliable). Throws on failure so an outage never reads as an empty wallet. - doctor: balance, reachability probe and AI request use the Arc gateway. - spend policy: eip155:5042 is a payable network for allowedNetworks. - Tests: chain persistence/env, Arc balance reader, and an end-to-end proxy test that verifies the signature recovers over Arc's domain (and not Base's "USD Coin"), no builder code, /health reports arc. --- CLAUDE.md | 2 +- README.md | 20 ++- docs/configuration.md | 26 +-- skills/clawrouter/SKILL.md | 3 +- src/api-key.ts | 2 +- src/auth.payment-chain-arc.test.ts | 100 +++++++++++ src/auth.ts | 20 +-- src/balance.arc.test.ts | 88 ++++++++++ src/balance.ts | 61 ++++++- src/cli.ts | 30 +++- src/commands/policy.ts | 7 +- src/doctor.ts | 27 ++- src/index.ts | 70 ++++++-- src/payment-chain.ts | 66 +++++++ src/proxy.arc-payment.test.ts | 271 +++++++++++++++++++++++++++++ src/proxy.ts | 98 ++++++++--- src/spend-control.ts | 5 +- 17 files changed, 804 insertions(+), 92 deletions(-) create mode 100644 src/auth.payment-chain-arc.test.ts create mode 100644 src/balance.arc.test.ts create mode 100644 src/payment-chain.ts create mode 100644 src/proxy.arc-payment.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 41a6ac77..520bfbb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Smart LLM router for autonomous agents. 90 models. Two ways to pay: -- **Wallet (default)** — x402 USDC micropayments signed per call, against `blockrun.ai/api` (Base) or `sol.blockrun.ai/api` (Solana). New installs default to Solana. +- **Wallet (default)** — x402 USDC micropayments signed per call, against `blockrun.ai/api` (Base), `sol.blockrun.ai/api` (Solana) or `arc.blockrun.ai/api` (Circle's Arc, same EVM key as Base). New installs default to Solana. Per-chain facts (gateway, label, CAIP-2) live in `src/payment-chain.ts`. - **API key** — `brk_live_…` from [user.blockrun.ai](https://user.blockrun.ai), billed against card-funded account credit, against `api.blockrun.ai`. `src/api-key.ts` resolves it (env → `~/.blockrun/.api-key` → `~/.openclaw/blockrun/api-key`); a key present anywhere wins over a wallet, and no wallet is generated or read in that mode. ## Commands diff --git a/README.md b/README.md index e8ba5aa8..5c17cea3 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,14 @@ Full walkthrough: [Paying with a credit card](#paying-with-a-credit-card-api-key **Two ways to pay for the paid ones — pick one, the rest of ClawRouter is identical:** -| | Wallet (default) | API key | -| ----------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| **Who it is for** | Autonomous agents, and anyone happy holding USDC | People who would rather pay with a card | -| **Setup** | Nothing — a wallet is generated on first run | Sign up at [user.blockrun.ai](https://user.blockrun.ai), top up, mint a key, `clawrouter login brk_live_…` | -| **Auth** | An x402 signature per request | `Authorization: Bearer brk_live_…` | -| **Gateway** | `sol.blockrun.ai` (Solana, default) / `blockrun.ai` (Base) | `api.blockrun.ai` | -| **Billing** | USDC leaves your wallet per call, non-custodial | Card-funded account credit, drawn down at exact usage | -| **Fees** | $0.001/tx settlement fee (covers on-chain gas) | 5.5% + $0.30 on the top-up, then list price with **nothing per call** | +| | Wallet (default) | API key | +| ----------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| **Who it is for** | Autonomous agents, and anyone happy holding USDC | People who would rather pay with a card | +| **Setup** | Nothing — a wallet is generated on first run | Sign up at [user.blockrun.ai](https://user.blockrun.ai), top up, mint a key, `clawrouter login brk_live_…` | +| **Auth** | An x402 signature per request | `Authorization: Bearer brk_live_…` | +| **Gateway** | `sol.blockrun.ai` (Solana, default) / `blockrun.ai` (Base) / `arc.blockrun.ai` (Arc) | `api.blockrun.ai` | +| **Billing** | USDC leaves your wallet per call, non-custodial | Card-funded account credit, drawn down at exact usage | +| **Fees** | $0.001/tx settlement fee (covers on-chain gas) | 5.5% + $0.30 on the top-up, then list price with **nothing per call** | ClawRouter prefers the API key whenever one is configured, so `clawrouter login` is all it takes to switch and `clawrouter logout` is all it takes to switch back. @@ -583,12 +583,15 @@ USDC stays in your wallet until spent — non-custodial. Price is visible in the **Dual-chain support:** Pay with **USDC on Solana** (the default) or **USDC on Base (EVM)**. Both wallets are derived from a single BIP-39 mnemonic on first run. New installs get **Solana**; an install that predates the Solana default keeps its **Base** wallet — its USDC is there — until you switch with `/wallet solana`. +**Arc:** you can also pay with **USDC on [Circle's Arc](https://arc.network)** (`eip155:5042`) via `arc.blockrun.ai` — same models, same prices, same $0.001 per-call fee as Base. Arc uses the **same EVM key and address as Base**, so there is nothing new to back up: switch with `/wallet arc` (or `clawrouter chain arc`) and fund that address with USDC on Arc. + ```bash /wallet # Check balance and address (both chains) /wallet export # Export mnemonic + keys for backup /wallet recover # Restore wallet from mnemonic on a new machine /wallet solana # Switch to Solana USDC payments /wallet base # Switch to Base (EVM) USDC payments +/wallet arc # Switch to Arc (EVM) USDC payments — same address as Base /stats # View usage and savings /stats clear # Reset usage statistics /exclude # Show excluded models @@ -601,6 +604,7 @@ USDC stays in your wallet until spent — non-custodial. Price is visible in the - **Solana (default):** Send USDC on Solana to your Solana address - **Base (EVM):** Send USDC on Base to your EVM address +- **Arc (EVM):** Send USDC on Arc to the same EVM address - **Coinbase/CEX:** Withdraw USDC to either network - **Credit card:** don't fund a wallet at all — [sign up at user.blockrun.ai](https://user.blockrun.ai) and use an API key instead (below) diff --git a/docs/configuration.md b/docs/configuration.md index f61ad91a..5cb23b81 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,13 +41,13 @@ Complete reference for ClawRouter configuration options. ClawRouter has two ways to pay BlockRun, and they are mutually exclusive per process: -| | Wallet (default) | API key | -| ------------ | -------------------------------------------------------- | -------------------------------------------------------------------- | -| Credential | An EVM/Solana private key held locally | `brk_live_…`, issued at [user.blockrun.ai](https://user.blockrun.ai) | -| Gateway | `blockrun.ai/api` (Base), `sol.blockrun.ai/api` (Solana) | `api.blockrun.ai` | -| Wire auth | An x402 signature per request | `Authorization: Bearer brk_live_…` | -| Funding | USDC you send to the wallet | Credit card top-up on the portal | -| Out of money | Local balance check → free-model fallback | Gateway answers `402 insufficient_quota` | +| | Wallet (default) | API key | +| ------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Credential | An EVM/Solana private key held locally | `brk_live_…`, issued at [user.blockrun.ai](https://user.blockrun.ai) | +| Gateway | `blockrun.ai/api` (Base), `sol.blockrun.ai/api` (Solana), `arc.blockrun.ai/api` (Arc) | `api.blockrun.ai` | +| Wire auth | An x402 signature per request | `Authorization: Bearer brk_live_…` | +| Funding | USDC you send to the wallet | Credit card top-up on the portal | +| Out of money | Local balance check → free-model fallback | Gateway answers `402 insufficient_quota` | **An API key wins whenever one is present.** A machine holding both a legacy wallet and a key the user just added means "bill my account", not "keep spending @@ -287,7 +287,7 @@ payer `SolanaLLMClient` uses. ## Wallet Configuration -ClawRouter supports **two payment chains**: Base (EVM) and Solana. Both are USDC only — no SOL or ETH accepted for payments. +ClawRouter supports **three payment chains**: Base (EVM), Solana, and Circle's Arc (EVM, `eip155:5042`). All are USDC only — no SOL or ETH accepted for payments. Base and Arc share one EVM key and address; Arc's gateway (`arc.blockrun.ai/api`) serves the same routes and prices as Base, including the $0.001 per-call fee. Arc balances are read through the Arc gateway's `/v1/balance` endpoint (no RPC key is needed or shipped). ### Check Active Wallet @@ -319,9 +319,12 @@ Response (dual-chain): ```bash /wallet solana # Switch to Solana USDC payments /wallet base # Switch to Base (EVM) USDC payments +/wallet arc # Switch to Arc (EVM) USDC payments — same address as Base ``` -The selected chain is persisted across gateway restarts in `~/.blockrun/.chain` (and mirrored to the legacy `~/.openclaw/blockrun/payment-chain` file for rollback compatibility). New installs default to **Solana**; existing installs without a saved selection stay on **Base**, where their funds already live. The `CLAWROUTER_PAYMENT_CHAIN` environment variable (`solana` or `base`) overrides the persisted selection. +From the CLI: `clawrouter chain solana|base|arc`. + +The selected chain is persisted across gateway restarts in `~/.blockrun/.chain` (and mirrored to the legacy `~/.openclaw/blockrun/payment-chain` file for rollback compatibility). New installs default to **Solana**; existing installs without a saved selection stay on **Base**, where their funds already live. The `CLAWROUTER_PAYMENT_CHAIN` environment variable (`solana`, `base` or `arc`) overrides the persisted selection. ### Switch Wallets @@ -745,8 +748,9 @@ proxy startup — **edit it, then restart the proxy** for changes to take effect **Networks are CAIP-2, not nicknames.** `base` does not match `eip155:8453` and fails closed. Use `eip155:8453` for Base and -`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d` for Solana mainnet; both -are exported as `CAIP2_BASE` and `CAIP2_SOLANA_MAINNET`. +`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d` for Solana mainnet and +`eip155:5042` for Arc; they are exported as `CAIP2_BASE`, `CAIP2_SOLANA_MAINNET` +and `CAIP2_ARC`. EVM addresses in `allowedPayees`, `blockedPayees` and `allowedAssets` are compared case-insensitively, so checksummed and lowercase forms both match. diff --git a/skills/clawrouter/SKILL.md b/skills/clawrouter/SKILL.md index b840f87c..35916874 100644 --- a/skills/clawrouter/SKILL.md +++ b/skills/clawrouter/SKILL.md @@ -55,7 +55,7 @@ Source: https://github.com/BlockRunAI/ClawRouter · npm: https://www.npmjs.com/p ## Data Flow ``` -Your app → localhost proxy (ClawRouter) → https://blockrun.ai/api (or sol.blockrun.ai/api) +Your app → localhost proxy (ClawRouter) → https://blockrun.ai/api (or sol.blockrun.ai/api, arc.blockrun.ai/api) ↓ OpenAI / Anthropic / Google / etc. ↓ @@ -149,6 +149,7 @@ Model prices move (vendors reprice, promos end), so this skill carries no model ```bash curl -s https://blockrun.ai/api/v1/models # Base curl -s https://sol.blockrun.ai/api/v1/models # Solana +curl -s https://arc.blockrun.ai/api/v1/models # Arc (same catalog as Base) ``` Each entry in `data[]` has an `id`, a `billing_mode` and a `pricing` object. All amounts are USD. diff --git a/src/api-key.ts b/src/api-key.ts index 73f8b0b2..c15f2363 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -6,7 +6,7 @@ * credit card at https://user.blockrun.ai. Same catalogue, same model ids, * same OpenAI-compatible shape — a different host and a different auth header: * - * wallet → https://blockrun.ai/api | https://sol.blockrun.ai/api (x402) + * wallet → https://blockrun.ai/api | https://sol.blockrun.ai/api | https://arc.blockrun.ai/api (x402) * API key → https://api.blockrun.ai (Bearer brk_…) * * Resolution order, highest first: diff --git a/src/auth.payment-chain-arc.test.ts b/src/auth.payment-chain-arc.test.ts new file mode 100644 index 00000000..f17bb75f --- /dev/null +++ b/src/auth.payment-chain-arc.test.ts @@ -0,0 +1,100 @@ +/** + * Arc (Circle, eip155:5042) is a third selectable payment chain. It must be + * selectable everywhere Base and Solana are — env var, persisted chain files — + * without moving the fresh-install default off Solana. + * + * Temp HOME set BEFORE importing auth.js (paths are computed at module load). + */ + +import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const TEMP_HOME = mkdtempSync(join(tmpdir(), "clawrouter-chain-arc-")); +process.env.HOME = TEMP_HOME; +process.env.USERPROFILE = TEMP_HOME; +delete process.env.BLOCKRUN_WALLET_KEY; +delete process.env.SOLANA_WALLET_KEY; +delete process.env.CLAWROUTER_PAYMENT_CHAIN; + +const { loadPaymentChain, resolvePaymentChain, savePaymentChain, CHAIN_FILE, CORE_CHAIN_FILE } = + await import("./auth.js"); +const { isPaymentChain, isEvmPaymentChain, PAYMENT_CHAINS, PAYMENT_CHAIN_GATEWAYS } = + await import("./payment-chain.js"); + +const WALLET_DIR = join(TEMP_HOME, ".openclaw", "blockrun"); +const CORE_DIR = join(TEMP_HOME, ".blockrun"); + +beforeEach(() => { + if (existsSync(WALLET_DIR)) rmSync(WALLET_DIR, { recursive: true }); + if (existsSync(CORE_DIR)) rmSync(CORE_DIR, { recursive: true }); +}); + +afterEach(() => { + delete process.env.CLAWROUTER_PAYMENT_CHAIN; +}); + +afterAll(() => { + rmSync(TEMP_HOME, { recursive: true, force: true }); +}); + +describe("payment-chain table", () => { + it("lists all three chains and recognises only them", () => { + expect([...PAYMENT_CHAINS].sort()).toEqual(["arc", "base", "solana"]); + expect(isPaymentChain("arc")).toBe(true); + expect(isPaymentChain("ARC")).toBe(false); + expect(isPaymentChain("eip155:5042")).toBe(false); + expect(isPaymentChain(undefined)).toBe(false); + }); + + it("treats Arc as an EVM chain paid from the Base key", () => { + expect(isEvmPaymentChain("arc")).toBe(true); + expect(isEvmPaymentChain("base")).toBe(true); + expect(isEvmPaymentChain("solana")).toBe(false); + }); + + it("points Arc at arc.blockrun.ai", () => { + expect(PAYMENT_CHAIN_GATEWAYS.arc).toBe("https://arc.blockrun.ai/api"); + }); +}); + +describe("Arc selection persists and resolves", () => { + it("fresh install still defaults to Solana", async () => { + await expect(resolvePaymentChain()).resolves.toBe("solana"); + }); + + it("savePaymentChain('arc') writes both chain files and loads back as arc", async () => { + await savePaymentChain("arc"); + expect(readFileSync(CORE_CHAIN_FILE, "utf8").trim()).toBe("arc"); + expect(readFileSync(CHAIN_FILE, "utf8").trim()).toBe("arc"); + await expect(loadPaymentChain()).resolves.toBe("arc"); + await expect(resolvePaymentChain()).resolves.toBe("arc"); + }); + + it("reads arc from the legacy ClawRouter chain file alone", async () => { + mkdirSync(WALLET_DIR, { recursive: true }); + writeFileSync(join(WALLET_DIR, "payment-chain"), "arc\n"); + await expect(loadPaymentChain()).resolves.toBe("arc"); + }); + + it("a Core chain file of arc wins over a legacy base", async () => { + mkdirSync(WALLET_DIR, { recursive: true }); + mkdirSync(CORE_DIR, { recursive: true }); + writeFileSync(join(WALLET_DIR, "payment-chain"), "base\n"); + writeFileSync(join(CORE_DIR, ".chain"), "arc\n"); + await expect(loadPaymentChain()).resolves.toBe("arc"); + }); + + it("CLAWROUTER_PAYMENT_CHAIN=arc overrides a persisted chain", async () => { + await savePaymentChain("solana"); + process.env.CLAWROUTER_PAYMENT_CHAIN = "arc"; + await expect(resolvePaymentChain()).resolves.toBe("arc"); + }); + + it("an unknown env value is ignored, not trusted", async () => { + await savePaymentChain("base"); + process.env.CLAWROUTER_PAYMENT_CHAIN = "arbitrum"; + await expect(resolvePaymentChain()).resolves.toBe("base"); + }); +}); diff --git a/src/auth.ts b/src/auth.ts index 8546f4d8..4d7cb733 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -27,6 +27,7 @@ import { createPrivateKey, createPublicKey } from "node:crypto"; import { writeFile, mkdir } from "node:fs/promises"; import { readTextFile } from "./fs-read.js"; +import { isPaymentChain, type PaymentChain } from "./payment-chain.js"; import { join } from "node:path"; import { homedir } from "node:os"; import bs58 from "bs58"; @@ -171,7 +172,7 @@ export async function migrateLegacyWalletToCore(): Promise { +export async function savePaymentChain(chain: PaymentChain): Promise { await mkdir(WALLET_DIR, { recursive: true }); await mkdir(CORE_WALLET_DIR, { recursive: true }); await Promise.all([ @@ -671,21 +672,20 @@ export async function savePaymentChain(chain: "base" | "solana"): Promise * resolveOrGenerateWalletKey — and the wallet it did write keeps us on Base, * which is the only chain that proxy can actually sign for). */ -export async function loadPaymentChain(): Promise<"base" | "solana"> { +export async function loadPaymentChain(): Promise { const core = await readOptional(CORE_CHAIN_FILE); const legacy = await readOptional(CHAIN_FILE); - if (core === "solana" || core === "base") { + if (isPaymentChain(core)) { // Same precedence as the wallet, and the same silence it used to keep: a // chain another BlockRun product wrote moves this install to a different // signer and a different gateway, where its USDC may not be (#315). Core // still wins — Desktop reads the Core files directly — but say it. - if ((legacy === "solana" || legacy === "base") && legacy !== core) { + if (isPaymentChain(legacy) && legacy !== core) { warnChainOverride(core, legacy); } return core; } - if (legacy === "solana") return "solana"; - if (legacy === "base") return "base"; + if (isPaymentChain(legacy)) return legacy; // No selection recorded anywhere. Prefer Solana unless a wallet predates the // preference, in which case its funds decide. return (await hasExistingWallet()) ? "base" : "solana"; @@ -723,8 +723,8 @@ async function hasExistingWallet(): Promise { * Resolve payment chain: env var first → persisted file second → the * fresh-install preference (Solana) last. See loadPaymentChain. */ -export async function resolvePaymentChain(): Promise<"base" | "solana"> { - if (process["env"].CLAWROUTER_PAYMENT_CHAIN === "solana") return "solana"; - if (process["env"].CLAWROUTER_PAYMENT_CHAIN === "base") return "base"; +export async function resolvePaymentChain(): Promise { + const fromEnv = process["env"].CLAWROUTER_PAYMENT_CHAIN; + if (isPaymentChain(fromEnv)) return fromEnv; return loadPaymentChain(); } diff --git a/src/balance.arc.test.ts b/src/balance.arc.test.ts new file mode 100644 index 00000000..e7236439 --- /dev/null +++ b/src/balance.arc.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { BalanceMonitor, fetchArcUsdcBalance } from "./balance.js"; +import { RpcError } from "./errors.js"; + +const ADDRESS = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Arc USDC balance (read through arc.blockrun.ai, no RPC key)", () => { + it("reads micro-USDC from the Arc gateway's /v1/balance", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ address: ADDRESS, balance: "24253720", balanceUSD: "$24.25", network: "arc" }), + ); + await expect(fetchArcUsdcBalance(ADDRESS, fetchImpl as typeof fetch)).resolves.toBe( + 24_253_720n, + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe(`https://arc.blockrun.ai/api/v1/balance?address=${ADDRESS}`); + }); + + it("throws on an HTTP error instead of reading as an empty wallet", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ error: "down" }, 503)); + await expect(fetchArcUsdcBalance(ADDRESS, fetchImpl as typeof fetch)).rejects.toThrow(/503/); + }); + + it("throws when the body carries no integer balance", async () => { + for (const body of [{}, { balance: 24.25 }, { balance: "$24.25" }, { balance: "" }]) { + const fetchImpl = vi.fn(async () => jsonResponse(body)); + await expect(fetchArcUsdcBalance(ADDRESS, fetchImpl as typeof fetch)).rejects.toThrow( + /no balance/, + ); + } + }); +}); + +describe("BalanceMonitor on Arc", () => { + it("reports Arc balance, low/empty flags and the shared EVM address", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ balance: "500000" })); + const monitor = new BalanceMonitor(ADDRESS, "arc", fetchImpl as typeof fetch); + expect(monitor.getChain()).toBe("arc"); + const info = await monitor.checkBalance(); + expect(info.balance).toBe(500_000n); + expect(info.balanceUSD).toBe("$0.50"); + expect(info.isLow).toBe(true); + expect(info.isEmpty).toBe(false); + expect(info.walletAddress).toBe(ADDRESS); + }); + + it("flags an empty Arc wallet and re-reads it on the next check", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ balance: "0" })); + const monitor = new BalanceMonitor(ADDRESS, "arc", fetchImpl as typeof fetch); + expect((await monitor.checkBalance()).isEmpty).toBe(true); + await monitor.checkBalance(); + expect(fetchImpl).toHaveBeenCalledTimes(2); // zero is never cached + }); + + it("caches a positive balance and honours checkSufficient", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ balance: "2000000" })); + const monitor = new BalanceMonitor(ADDRESS, "arc", fetchImpl as typeof fetch); + expect((await monitor.checkSufficient(1_000_000n)).sufficient).toBe(true); + const short = await monitor.checkSufficient(3_000_000n); + expect(short.sufficient).toBe(false); + expect(short.shortfall).toBe("$1.00"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("surfaces a failed Arc read as RpcError, like the Base RPC path", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const monitor = new BalanceMonitor(ADDRESS, "arc", fetchImpl as typeof fetch); + await expect(monitor.checkBalance()).rejects.toBeInstanceOf(RpcError); + }); + + it("defaults to Base and never calls the Arc gateway there", () => { + const fetchImpl = vi.fn(); + const monitor = new BalanceMonitor(ADDRESS, undefined, fetchImpl as unknown as typeof fetch); + expect(monitor.getChain()).toBe("base"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/balance.ts b/src/balance.ts index 1aa23196..742f9653 100644 --- a/src/balance.ts +++ b/src/balance.ts @@ -1,7 +1,8 @@ /** * Balance Monitor for ClawRouter * - * Monitors USDC balance on Base network with intelligent caching. + * Monitors USDC balance on an EVM payment chain (Base, or Circle's Arc) with + * intelligent caching. * Provides pre-request balance checks to prevent failed payments. * * Caching Strategy: @@ -13,10 +14,46 @@ import { createPublicClient, http, erc20Abi } from "viem"; import { base } from "viem/chains"; import { RpcError } from "./errors.js"; +import { PAYMENT_CHAIN_GATEWAYS } from "./payment-chain.js"; /** USDC contract address on Base mainnet */ const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +/** EVM chains whose USDC the BalanceMonitor can read. */ +export type EvmBalanceChain = "base" | "arc"; + +/** Per-read timeout, shared by the Base RPC and the Arc gateway read. */ +const READ_TIMEOUT_MS = 10_000; + +/** + * Read an address's USDC on Arc, in micro-USDC. + * + * Arc is read through the Arc gateway's free `/v1/balance`, not an RPC: the + * public Arc RPC is unreliable (and public proxies that answer eth_chainId fail + * eth_call), and ClawRouter ships no RPC key. The gateway reads the USDC + * ERC-20 at 0x3600…0000 with BlockRun's own RPC and answers + * `{"balance":"24253720", ...}` — micro-USDC as a decimal string. + * + * Throws on any failure, like the Base read: an unreachable endpoint must never + * read as an empty wallet (that would push every request onto the free tier). + */ +export async function fetchArcUsdcBalance( + address: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = `${PAYMENT_CHAIN_GATEWAYS.arc}/v1/balance?address=${encodeURIComponent(address)}`; + const res = await fetchImpl(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(READ_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`Arc balance read failed (HTTP ${res.status})`); + const body = (await res.json()) as { balance?: unknown }; + if (typeof body.balance !== "string" || !/^\d+$/.test(body.balance)) { + throw new Error("Arc balance read returned no balance"); + } + return BigInt(body.balance); +} + /** Cache TTL in milliseconds (30 seconds) */ const CACHE_TTL_MS = 30_000; @@ -53,32 +90,44 @@ export type SufficiencyResult = { }; /** - * Monitors USDC balance on Base network. + * Monitors USDC balance on Base (default) or Arc. Both chains share the EVM + * wallet, so one address; `chain` picks which chain's USDC is read. * * Usage: - * const monitor = new BalanceMonitor("0x..."); + * const monitor = new BalanceMonitor("0x..."); // Base + * const arc = new BalanceMonitor("0x...", "arc"); // Arc * const info = await monitor.checkBalance(); * if (info.isLow) console.warn("Low balance!"); */ export class BalanceMonitor { private readonly client; private readonly walletAddress: `0x${string}`; + private readonly chain: EvmBalanceChain; + private readonly fetchImpl: typeof fetch; /** Cached balance (null = not yet fetched) */ private cachedBalance: bigint | null = null; /** Timestamp when cache was last updated */ private cachedAt = 0; - constructor(walletAddress: string) { + constructor(walletAddress: string, chain: EvmBalanceChain = "base", fetchImpl?: typeof fetch) { this.walletAddress = walletAddress as `0x${string}`; + this.chain = chain; + // Resolved per call when not injected, so a test's stubbed global fetch applies. + this.fetchImpl = fetchImpl ?? ((...args) => fetch(...args)); this.client = createPublicClient({ chain: base, transport: http(undefined, { - timeout: 10_000, // 10 second timeout to prevent hanging on slow RPC + timeout: READ_TIMEOUT_MS, // prevent hanging on slow RPC }), }); } + /** Which chain's USDC this monitor reads. */ + getChain(): EvmBalanceChain { + return this.chain; + } + /** * Check current USDC balance. * Uses cache if valid, otherwise fetches from RPC. @@ -175,6 +224,8 @@ export class BalanceMonitor { /** Fetch balance from RPC */ private async fetchBalance(): Promise { try { + if (this.chain === "arc") + return await fetchArcUsdcBalance(this.walletAddress, this.fetchImpl); const balance = await this.client.readContract({ address: USDC_BASE, abi: erc20Abi, diff --git a/src/cli.ts b/src/cli.ts index 5612cfd4..2521501c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { savePaymentChain, } from "./auth.js"; import { getSolanaAddress } from "./wallet.js"; +import { PAYMENT_CHAIN_GATEWAYS, isPaymentChain, type PaymentChain } from "./payment-chain.js"; import { generateReport } from "./report.js"; import { formatRecentLogs } from "./stats.js"; import { runDoctor } from "./doctor.js"; @@ -103,6 +104,7 @@ Management Commands: logout Delete the stored API key and fall back to the wallet chain solana Switch to Solana (persists) chain base Switch to Base EVM (persists) + chain arc Switch to Circle's Arc (EVM, same address as Base; persists) policy Edit spend limits and counterparty lists (fail-closed; restart proxy to apply) Paying for calls — two ways, and ClawRouter prefers the API key when both exist: @@ -115,6 +117,7 @@ Environment Variables: BLOCKRUN_API_KEY BlockRun API key (brk_...) — wins over the wallet when set BLOCKRUN_WALLET_KEY Private key for x402 payments (auto-generated if not set) BLOCKRUN_PROXY_PORT Default proxy port (default: 8402) + CLAWROUTER_PAYMENT_CHAIN solana | base | arc — overrides the saved chain For more info: https://blockrun.ai/clawrouter.md `); @@ -380,6 +383,11 @@ async function cmdWallet(port: number): Promise { console.log(` Base (EVM):`); console.log(` Address: ${data.wallet}`); console.log(` Fund: https://basescan.org/address/${data.wallet}`); + if (data.paymentChain === "arc") { + console.log(); + console.log(` Arc (EVM, same key as Base):`); + console.log(` Address: ${data.wallet}`); + } if (data.solana) { console.log(); console.log(` Solana:`); @@ -1090,7 +1098,7 @@ function parseArgs(args: string[]): { reportPeriod: "daily" | "weekly" | "monthly"; reportJson: boolean; walletRecover: boolean; - chain?: "solana" | "base"; + chain?: PaymentChain; port?: number; noReuse: boolean; // Query commands @@ -1134,7 +1142,7 @@ function parseArgs(args: string[]): { reportPeriod: "daily" as "daily" | "weekly" | "monthly", reportJson: false, walletRecover: false, - chain: undefined as "solana" | "base" | undefined, + chain: undefined as PaymentChain | undefined, port: undefined as number | undefined, noReuse: false, queryStatus: false, @@ -1220,17 +1228,14 @@ function parseArgs(args: string[]): { if (args[i + 1] === "recover") { result.walletRecover = true; i++; - } else if (args[i + 1] === "solana" || args[i + 1] === "base") { - result.chain = args[i + 1] as "solana" | "base"; + } else if (isPaymentChain(args[i + 1])) { + result.chain = args[i + 1] as PaymentChain; i++; } else { result.queryWallet = true; } - } else if ( - (arg === "chain" || arg === "/wallet") && - (args[i + 1] === "solana" || args[i + 1] === "base") - ) { - result.chain = args[i + 1] as "solana" | "base"; + } else if ((arg === "chain" || arg === "/wallet") && isPaymentChain(args[i + 1])) { + result.chain = args[i + 1] as PaymentChain; i++; } else if (arg === "login") { result.login = true; @@ -1645,6 +1650,13 @@ async function main(): Promise { console.log(`[ClawRouter] Payment chain → Solana`); console.log(`[ClawRouter] Solana address : ${solanaAddr}`); console.log(`[ClawRouter] Fund USDC at : https://solscan.io/account/${solanaAddr}`); + } else if (targetChain === "arc") { + // Arc pays from the EVM wallet — the same key and address as Base. + const wallet = await resolveOrGenerateWalletKey(); + await savePaymentChain("arc"); + console.log(`[ClawRouter] Payment chain → Arc (EVM, ${PAYMENT_CHAIN_GATEWAYS.arc})`); + console.log(`[ClawRouter] Arc address : ${wallet.address} (same as Base)`); + console.log(`[ClawRouter] Fund it with USDC on Arc.`); } else { await savePaymentChain("base"); console.log(`[ClawRouter] Payment chain → Base (EVM)`); diff --git a/src/commands/policy.ts b/src/commands/policy.ts index f3415a41..d5f391d0 100644 --- a/src/commands/policy.ts +++ b/src/commands/policy.ts @@ -19,6 +19,7 @@ import type { import { CAIP2_BASE, CAIP2_SOLANA_MAINNET, + CAIP2_ARC, PAYABLE_NETWORKS, POLICY_LISTS, SpendControl, @@ -66,7 +67,7 @@ const USAGE = [ ` policy set|add|remove ... : ${POLICY_LISTS.join(" | ")}`, " policy clear ", ` policy limit |clear : ${SPEND_WINDOWS.join(" | ")}`, - `Networks are CAIP-2 ids: ${CAIP2_BASE} (Base) or ${CAIP2_SOLANA_MAINNET} (Solana mainnet).`, + `Networks are CAIP-2 ids: ${CAIP2_BASE} (Base), ${CAIP2_SOLANA_MAINNET} (Solana mainnet) or ${CAIP2_ARC} (Arc).`, ].join("\n"); /** * Without a handle on the running proxy's SpendControl this command can only @@ -116,7 +117,7 @@ function rejectValue(list: PolicyList, value: string): string | undefined { if (list === "allowedNetworks") { return PAYABLE_NETWORKS.includes(value) ? undefined - : `"${value}" is not a network the proxy can pay on — allowedNetworks accepts only ${CAIP2_BASE} (Base) or ${CAIP2_SOLANA_MAINNET} (Solana mainnet), not a nickname. Other CAIP-2 ids are well formed but cannot appear in a payment quote, so allowlisting one would only block payments`; + : `"${value}" is not a network the proxy can pay on — allowedNetworks accepts only ${CAIP2_BASE} (Base), ${CAIP2_SOLANA_MAINNET} (Solana mainnet) or ${CAIP2_ARC} (Arc), not a nickname. Other CAIP-2 ids are well formed but cannot appear in a payment quote, so allowlisting one would only block payments`; } if (/^0x/i.test(value)) { return EVM_ADDRESS.test(value) @@ -129,7 +130,7 @@ function rejectValue(list: PolicyList, value: string): string | undefined { // is dead weight that reads as protection. return BASE58_ADDRESS.test(value) ? undefined - : `"${value}" is not an address — expected 0x plus 40 hex characters (Base) or a 32–44 character base58 id (Solana)`; + : `"${value}" is not an address — expected 0x plus 40 hex characters (Base/Arc) or a 32–44 character base58 id (Solana)`; } /** Turn argv into a plan or a rejection. Pure: nothing is read or written here. */ diff --git a/src/doctor.ts b/src/doctor.ts index 10cf8e1b..f3f2ac4e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -21,6 +21,11 @@ import { MNEMONIC_FILE, } from "./auth.js"; import { BalanceMonitor } from "./balance.js"; +import { + PAYMENT_CHAIN_GATEWAYS, + PAYMENT_CHAIN_LABELS, + type PaymentChain, +} from "./payment-chain.js"; import { resolveApiKey, maskApiKey, @@ -52,7 +57,7 @@ interface WalletInfo { isLow: boolean; isEmpty: boolean; source: "core" | "saved" | "env" | "config" | "generated" | null; - paymentChain: "base" | "solana"; + paymentChain: PaymentChain; } /** Set only when this install pays with a `brk_…` key instead of a wallet. */ @@ -230,7 +235,8 @@ async function collectWalletInfo(apiKeyConfigured: boolean): Promise const monitor = new SolanaBalanceMonitor(solanaAddress); balanceInfo = await monitor.checkBalance(); } else { - const monitor = new BalanceMonitor(address); + // Base and Arc share the EVM address; read the active chain's USDC. + const monitor = new BalanceMonitor(address, paymentChain === "arc" ? "arc" : "base"); balanceInfo = await monitor.checkBalance(); } return { @@ -275,13 +281,17 @@ async function collectWalletInfo(apiKeyConfigured: boolean): Promise // Collect network info async function collectNetworkInfo(): Promise { const port = getProxyPort(); + // Arc pays against its own gateway; probe that one when it is the active + // chain, so "reachable" describes the host paid calls will actually go to. + const chain = await resolvePaymentChain().catch(() => "base" as const); + const modelsUrl = `${chain === "arc" ? PAYMENT_CHAIN_GATEWAYS.arc : PAYMENT_CHAIN_GATEWAYS.base}/v1/models`; // Check BlockRun API let blockrunReachable = false; let blockrunLatency: number | null = null; try { const start = Date.now(); - const response = await fetch("https://blockrun.ai/api/v1/models", { + const response = await fetch(modelsUrl, { method: "GET", signal: AbortSignal.timeout(10000), }); @@ -346,7 +356,7 @@ function identifyIssues(result: DiagnosticResult): string[] { issues.push("No wallet found"); } if (result.wallet.isEmpty) { - const chain = result.wallet.paymentChain === "solana" ? "Solana" : "Base"; + const chain = PAYMENT_CHAIN_LABELS[result.wallet.paymentChain]; issues.push(`Wallet is empty - need to fund with USDC on ${chain}`); if (result.wallet.paymentChain === "base" && result.wallet.solanaAddress) { issues.push("Tip: if you funded Solana, run /wallet solana to switch chains"); @@ -427,7 +437,7 @@ function printDiagnostics(result: DiagnosticResult): void { if (result.wallet.solanaAddress) { console.log(` ${green(`Solana Address: ${result.wallet.solanaAddress}`)}`); } - const chainLabel = result.wallet.paymentChain === "solana" ? "Solana" : "Base"; + const chainLabel = PAYMENT_CHAIN_LABELS[result.wallet.paymentChain]; console.log(` ${green(`Chain: ${chainLabel}`)}`); if (result.wallet.isEmpty) { console.log( @@ -581,10 +591,9 @@ async function analyzeWithAI( } const paymentFetch = wrapFetchWithPayment(fetch, x402); - const apiUrl = - paymentChain === "solana" - ? "https://sol.blockrun.ai/api/v1/chat/completions" - : "https://blockrun.ai/api/v1/chat/completions"; + // Arc signs with the same EVM scheme as Base: the exact-EVM client takes the + // chain id from the 402's `eip155:5042` and the USDC domain from its `extra`. + const apiUrl = `${PAYMENT_CHAIN_GATEWAYS[paymentChain]}/v1/chat/completions`; const response = await paymentFetch(apiUrl, { method: "POST", diff --git a/src/index.ts b/src/index.ts index e4c5e68b..4fe0d061 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ import { import type { WalletResolution } from "./auth.js"; import type { RoutingConfig } from "./router/index.js"; import { BalanceMonitor } from "./balance.js"; +import { PAYMENT_CHAIN_LABELS } from "./payment-chain.js"; import { loadExcludeList } from "./exclude-models.js"; /** @@ -1153,7 +1154,7 @@ async function startProxyInBackground( const currentChain = await resolvePaymentChain(); const displayAddress = currentChain === "solana" && proxy.solanaAddress ? proxy.solanaAddress : wallet!.address; - const network = currentChain === "solana" ? "Solana" : "Base"; + const network = PAYMENT_CHAIN_LABELS[currentChain]; proxy.balanceMonitor .checkBalance() .then(async (balance) => { @@ -1902,6 +1903,30 @@ function createWalletCommand(api?: OpenClawPluginApi): OpenClawPluginCommandDefi } } + if (subcommand === "arc") { + // Switch to Circle's Arc. Same EVM key and address as Base — nothing to + // set up, only the chain (and gateway) the USDC is paid on changes. + try { + await savePaymentChain("arc"); + if (api) restartProxyForChainSwitch(api); + return { + text: [ + api + ? "✓ Payment chain switched to **Arc (EVM)**. Proxy restarting in background (~2s)." + : "Payment chain set to Arc (EVM). Restart the gateway to apply.", + "", + `**Arc Address:** \`${address}\` (same as your Base address)`, + "Fund with USDC on Arc to pay for calls.", + ].join("\n"), + }; + } catch (err) { + return { + text: `Failed to set payment chain: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + if (subcommand === "base") { // Switch back to Base (EVM) payment chain try { @@ -1931,6 +1956,24 @@ function createWalletCommand(api?: OpenClawPluginApi): OpenClawPluginCommandDefi } })(); + const chainPromise = resolvePaymentChain(); + + // Arc shares the EVM address; its USDC is only read when Arc is the + // active chain, so a Base or Solana user's /wallet never calls the Arc gateway. + const arcPromise = (async () => { + if ((await chainPromise) !== "arc") return ""; + let arcBalanceText = "Balance: (could not check)"; + try { + const balance = await new BalanceMonitor(address!, "arc").checkBalance(); + arcBalanceText = `Balance: ${balance.balanceUSD}`; + } catch { + // keep default + } + return ["", "**Arc (EVM):**", ` Address: \`${address}\``, ` ${arcBalanceText}`].join( + "\n", + ); + })(); + const solanaPromise = (async () => { try { if (!resolvedSolanaKey) return ""; @@ -1960,8 +2003,6 @@ function createWalletCommand(api?: OpenClawPluginApi): OpenClawPluginCommandDefi } })(); - const chainPromise = resolvePaymentChain(); - const usagePromise = (async () => { try { const stats = await getStats(7); @@ -1991,23 +2032,26 @@ function createWalletCommand(api?: OpenClawPluginApi): OpenClawPluginCommandDefi } })(); - const [evmBalanceText, solanaSection, currentChain, usageSection] = await Promise.all([ - evmBalancePromise, - solanaPromise, - chainPromise, - usagePromise, - ]); + const [evmBalanceText, solanaSection, currentChain, arcSection, usageSection] = + await Promise.all([ + evmBalancePromise, + solanaPromise, + chainPromise, + arcPromise, + usagePromise, + ]); return { text: [ "**ClawRouter Wallet**", "", - `**Payment Chain:** ${currentChain === "solana" ? "Solana" : "Base (EVM)"}`, + `**Payment Chain:** ${currentChain === "solana" ? "Solana" : `${PAYMENT_CHAIN_LABELS[currentChain]} (EVM)`}`, "", "**Base (EVM):**", ` Address: \`${address}\``, ` ${evmBalanceText}`, ` Fund (USDC only): https://basescan.org/address/${address}`, + arcSection, solanaSection, usageSection, "", @@ -2018,8 +2062,9 @@ function createWalletCommand(api?: OpenClawPluginApi): OpenClawPluginCommandDefi "• `/wallet export` - Export private key for backup", "• `/stats` - Detailed usage breakdown", !solanaSection ? "• `/wallet solana` - Enable Solana payments" : "", - solanaSection ? "• `/wallet base` - Switch to Base (EVM)" : "", - solanaSection ? "• `/wallet solana` - Switch to Solana" : "", + currentChain !== "base" ? "• `/wallet base` - Switch to Base (EVM)" : "", + currentChain !== "arc" ? "• `/wallet arc` - Switch to Arc (EVM, same address)" : "", + solanaSection && currentChain !== "solana" ? "• `/wallet solana` - Switch to Solana" : "", ] .filter(Boolean) .join("\n"), @@ -2791,6 +2836,7 @@ export { UnreadableSpendPolicyError, CAIP2_BASE, CAIP2_SOLANA_MAINNET, + CAIP2_ARC, PAYABLE_NETWORKS, } from "./spend-control.js"; export type { diff --git a/src/payment-chain.ts b/src/payment-chain.ts new file mode 100644 index 00000000..b1bd20d6 --- /dev/null +++ b/src/payment-chain.ts @@ -0,0 +1,66 @@ +/** + * The x402 payment chains ClawRouter can pay on, and the per-chain facts every + * surface (CLI, OpenClaw command, proxy, balance, doctor) needs to agree on. + * + * base Base mainnet (eip155:8453) → https://blockrun.ai/api + * solana Solana mainnet → https://sol.blockrun.ai/api + * arc Circle's Arc (eip155:5042) → https://arc.blockrun.ai/api + * + * Base and Arc are both EVM and share ONE key: the same address holds USDC on + * each, and the 402's `network` decides which chain's USDC gets signed for. + * Arc's gateway serves the same routes, prices and $0.001 per-call fee as + * Base; it differs only in the chain it settles on. Solana has its own key. + * + * Leaf module on purpose — no imports — so auth.ts, proxy.ts and balance.ts + * can all depend on it without a cycle. + */ + +export type PaymentChain = "base" | "solana" | "arc"; + +/** Every selectable chain, in the order help text lists them. */ +export const PAYMENT_CHAINS: readonly PaymentChain[] = ["solana", "base", "arc"]; + +export function isPaymentChain(value: unknown): value is PaymentChain { + return typeof value === "string" && (PAYMENT_CHAINS as readonly string[]).includes(value); +} + +/** True for the chains paid from the EVM wallet key (Base, Arc). */ +export function isEvmPaymentChain(chain: PaymentChain): chain is "base" | "arc" { + return chain !== "solana"; +} + +/** Display name, for copy that names the chain a payment settles on. */ +export const PAYMENT_CHAIN_LABELS: Readonly> = { + base: "Base", + solana: "Solana", + arc: "Arc", +}; + +/** The x402 gateway each chain's payments are made against. */ +export const PAYMENT_CHAIN_GATEWAYS: Readonly> = { + base: "https://blockrun.ai/api", + solana: "https://sol.blockrun.ai/api", + arc: "https://arc.blockrun.ai/api", +}; + +/** Arc mainnet, as carried on x402 `selectedRequirements.network`. */ +export const CAIP2_ARC = "eip155:5042"; +/** Arc chain id. */ +export const ARC_CHAIN_ID = 5042; +/** + * USDC on Arc: the chain's native token exposed as an ERC-20 (6 decimals). + * Its EIP-712 domain is name "USDC", version "2" — not Base's "USD Coin". The + * exact-EVM signer takes name/version from the 402's `extra` and the chain id + * from its `network`, so no Arc-specific signing code is needed here. + */ +export const USDC_ARC = "0x3600000000000000000000000000000000000000" as const; + +/** + * Where a user can look up their address on each chain's explorer, or + * undefined where ClawRouter does not link one (Arc: no explorer it can rely on). + */ +export function explorerAddressUrl(chain: PaymentChain, address: string): string | undefined { + if (chain === "base") return `https://basescan.org/address/${address}`; + if (chain === "solana") return `https://solscan.io/account/${address}`; + return undefined; +} diff --git a/src/proxy.arc-payment.test.ts b/src/proxy.arc-payment.test.ts new file mode 100644 index 00000000..8d48c44a --- /dev/null +++ b/src/proxy.arc-payment.test.ts @@ -0,0 +1,271 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { verifyTypedData } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { + describePaymentNetwork, + stampsBuilderCode, + startProxy, + transformPaymentError, + walletGatewayFor, + type ProxyHandle, +} from "./proxy.js"; +import { + CAIP2_ARC, + CAIP2_BASE, + InMemorySpendControlStorage, + SpendControl, +} from "./spend-control.js"; +import { runPolicyCommand } from "./commands/policy.js"; +import { ARC_CHAIN_ID, USDC_ARC } from "./payment-chain.js"; + +/** + * Arc end to end through the real proxy payment path: the gateway answers 402 + * with `eip155:5042`, and the proxy must sign an EIP-3009 authorization over + * Arc's USDC domain (name "USDC", version "2", chainId 5042, 0x3600…0000) — a + * signature over Base's "USD Coin" domain would be refused by the facilitator + * after the proxy had reported a payment. + */ + +const PAY_TO = "0x634D274e67DafaF76C54153aa113906a606e1E0B"; +const AMOUNT = "2000"; + +function arcChallenge(): string { + return Buffer.from( + JSON.stringify({ + x402Version: 2, + resource: { url: "http://127.0.0.1/v1/chat/completions" }, + accepts: [ + { + scheme: "exact", + network: CAIP2_ARC, + amount: AMOUNT, + asset: USDC_ARC, + payTo: PAY_TO, + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2", assetTransferMethod: "eip3009" }, + }, + ], + }), + ).toString("base64"); +} + +type SignedPayment = { + x402Version: number; + accepted: { network: string; asset: string }; + payload: { + signature: `0x${string}`; + authorization: { + from: `0x${string}`; + to: `0x${string}`; + value: string; + validAfter: string; + validBefore: string; + nonce: `0x${string}`; + }; + }; + extensions?: Record; +}; + +describe("Arc payment chain through startProxy", () => { + const walletKey = generatePrivateKey(); + const walletAddress = privateKeyToAccount(walletKey).address; + let upstream: Server; + let proxy: ProxyHandle; + let control: SpendControl; + let payments: SignedPayment[] = []; + + beforeAll(async () => { + upstream = createServer((req: IncomingMessage, res: ServerResponse) => { + req.on("data", () => {}); + req.on("end", () => { + const header = req.headers["payment-signature"] || req.headers["x-payment"]; + if (!header) { + res.writeHead(402, { + "Content-Type": "application/json", + "PAYMENT-REQUIRED": arcChallenge(), + }); + res.end(JSON.stringify({ error: "Payment Required" })); + return; + } + payments.push(JSON.parse(Buffer.from(String(header), "base64").toString("utf8"))); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-arc", + object: "chat.completion", + model: "openai/gpt-4o-mini", + choices: [ + { index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + }), + ); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const port = (upstream.address() as AddressInfo).port; + + // Pin the policy surface too: Arc must be a network an operator can allowlist. + const storage = new InMemorySpendControlStorage(); + const applied = runPolicyCommand(["set", "allowedNetworks", CAIP2_ARC], { + openControl: () => new SpendControl({ storage }), + }); + expect(applied.isError).toBeFalsy(); + control = new SpendControl({ storage }); + + proxy = await startProxy({ + wallet: walletKey, + paymentChain: "arc", + apiBase: `http://127.0.0.1:${port}`, + port: 0, + skipBalanceCheck: true, + spendControl: control, + }); + }, 20_000); + + afterAll(async () => { + await proxy?.close(); + upstream?.closeAllConnections?.(); + await new Promise((r) => upstream?.close(() => r())); + }); + + beforeEach(() => { + payments = []; + }); + + it("signs an EIP-3009 authorization over Arc's USDC domain", async () => { + const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-4o-mini", + max_tokens: 5, + messages: [{ role: "user", content: "arc payment test" }], + stream: false, + }), + }); + expect(res.status).toBe(200); + await res.text(); + + expect(payments).toHaveLength(1); + const payment = payments[0]; + expect(payment.accepted.network).toBe(CAIP2_ARC); + const auth = payment.payload.authorization; + expect(auth.from.toLowerCase()).toBe(walletAddress.toLowerCase()); + expect(auth.to.toLowerCase()).toBe(PAY_TO.toLowerCase()); + expect(auth.value).toBe(AMOUNT); + + const message = { + from: auth.from, + to: auth.to, + value: BigInt(auth.value), + validAfter: BigInt(auth.validAfter), + validBefore: BigInt(auth.validBefore), + nonce: auth.nonce, + }; + const types = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + } as const; + const arcDomain = { + name: "USDC", + version: "2", + chainId: ARC_CHAIN_ID, + verifyingContract: USDC_ARC, + } as const; + await expect( + verifyTypedData({ + address: walletAddress, + domain: arcDomain, + types, + primaryType: "TransferWithAuthorization", + message, + signature: payment.payload.signature, + }), + ).resolves.toBe(true); + // And NOT over Base's domain on chain 5042 — the bug the TS SDK shipped once. + await expect( + verifyTypedData({ + address: walletAddress, + domain: { ...arcDomain, name: "USD Coin" }, + types, + primaryType: "TransferWithAuthorization", + message, + signature: payment.payload.signature, + }), + ).resolves.toBe(false); + }, 30_000); + + it("does not stamp the CDP builder code onto an Arc payment", async () => { + const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-4o-mini", + max_tokens: 5, + messages: [{ role: "user", content: "arc builder code test" }], + stream: false, + }), + }); + await res.text(); + expect(payments).toHaveLength(1); + expect(payments[0].extensions?.["builder-code"]).toBeUndefined(); + }, 30_000); + + it("reports arc as the payment chain on /health", async () => { + const health = (await (await fetch(`${proxy.baseUrl}/health`)).json()) as Record< + string, + unknown + >; + expect(health.paymentChain).toBe("arc"); + expect(health.wallet).toBe(walletAddress); + }); +}); + +describe("Arc chain helpers", () => { + it("routes each chain to its own gateway", () => { + expect(walletGatewayFor("arc", false)).toBe("https://arc.blockrun.ai/api"); + expect(walletGatewayFor("arc", true)).toBe("https://arc.blockrun.ai/api"); + expect(walletGatewayFor("base", true)).toBe("https://blockrun.ai/api"); + expect(walletGatewayFor("solana", true)).toBe("https://sol.blockrun.ai/api"); + // Solana with no Solana key falls back to Base, as before. + expect(walletGatewayFor("solana", false)).toBe("https://blockrun.ai/api"); + }); + + it("labels Arc payments as Arc, not Base", () => { + expect(describePaymentNetwork(CAIP2_ARC)).toBe("Arc (EVM)"); + expect(describePaymentNetwork(CAIP2_BASE)).toBe("Base (EVM)"); + expect(describePaymentNetwork("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d")).toBe( + "Solana", + ); + }); + + it("stamps the builder code on Base only, never on Arc or Solana", () => { + expect(stampsBuilderCode(CAIP2_BASE)).toBe(true); + expect(stampsBuilderCode(CAIP2_ARC)).toBe(false); + expect(stampsBuilderCode("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d")).toBe(false); + }); + + it("tells an Arc user to fund on Arc, not Base, when the balance is short", () => { + const body = JSON.stringify({ + error: "Payment verification failed", + code: "PAYMENT_INVALID", + debug: "insufficient funds", + payer: "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8", + }); + const arc = JSON.parse(transformPaymentError(body, "Arc")).error; + expect(arc.message).toBe("Insufficient Arc USDC balance."); + expect(arc.help).toMatch(/USDC on Arc/); + // Default unchanged for Base. + expect(JSON.parse(transformPaymentError(body)).error.help).toMatch(/USDC on Base/); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 45f84188..af8364af 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -104,6 +104,12 @@ import type { SolanaBalanceMonitor } from "./solana-balance.js"; /** Union type for chain- and auth-agnostic balance monitoring */ type AnyBalanceMonitor = BalanceMonitor | SolanaBalanceMonitor | ApiKeyBalanceMonitor; import { resolvePaymentChain } from "./auth.js"; +import { + CAIP2_ARC, + PAYMENT_CHAIN_GATEWAYS, + PAYMENT_CHAIN_LABELS, + type PaymentChain, +} from "./payment-chain.js"; import { BLOCKRUN_API_KEY_API, PORTAL_CREDITS_URL, @@ -166,8 +172,45 @@ import { } from "./response-store.js"; import { isSharePreset, transform } from "./share-formatters.js"; -const BLOCKRUN_API = "https://blockrun.ai/api"; -const BLOCKRUN_SOLANA_API = "https://sol.blockrun.ai/api"; +const BLOCKRUN_API = PAYMENT_CHAIN_GATEWAYS.base; +const BLOCKRUN_SOLANA_API = PAYMENT_CHAIN_GATEWAYS.solana; +const BLOCKRUN_ARC_API = PAYMENT_CHAIN_GATEWAYS.arc; + +/** + * Human label for an x402 `network`, for the per-payment log line. Base and + * Arc are both `eip155:*`, so the chain id has to be read, not the prefix. + */ +export function describePaymentNetwork(network: string): string { + if (network === CAIP2_ARC) return `${PAYMENT_CHAIN_LABELS.arc} (EVM)`; + if (network.startsWith("eip155")) return `${PAYMENT_CHAIN_LABELS.base} (EVM)`; + if (network.startsWith("solana")) return PAYMENT_CHAIN_LABELS.solana; + return network; +} + +/** + * Whether BlockRun's builder code is stamped onto a payment on this network. + * It is a CDP registration read only by the CDP facilitator: EVM networks + * other than Arc, whose payments Circle's facilitator settles. + */ +export function stampsBuilderCode(network: string): boolean { + return network.startsWith("eip155") && network !== CAIP2_ARC; +} + +/** + * The x402 gateway a wallet-rail proxy pays against. Solana without a Solana + * key falls back to Base — the only rail that proxy can sign for — and says so + * at startup; Arc needs nothing beyond the EVM key every wallet has. + */ +export function walletGatewayFor(chain: PaymentChain, hasSolanaKey: boolean): string { + if (chain === "solana") return hasSolanaKey ? BLOCKRUN_SOLANA_API : BLOCKRUN_API; + if (chain === "arc") return BLOCKRUN_ARC_API; + return BLOCKRUN_API; +} + +/** Which EVM chain's USDC the wallet-rail balance monitor reads. */ +function evmBalanceChain(chain: PaymentChain): "base" | "arc" { + return chain === "arc" ? "arc" : "base"; +} const DESKTOP_SERVICE_TOKEN_FILE = process.env.CLAWROUTER_DESKTOP_TOKEN_FILE ?? join(homedir(), ".clawrouter-desktop", "service-token"); @@ -432,8 +475,12 @@ async function readBodyWithTimeout( /** * Transform upstream payment errors into user-friendly messages. * Parses the raw x402 error and formats it nicely. + * + * `evmChain` names the EVM chain this proxy pays on. A 0x payer cannot say + * whether it was paying on Base or Arc (same key, same address), so the + * caller supplies it; it defaults to Base, the only EVM rail before Arc. */ -export function transformPaymentError(errorBody: string): string { +export function transformPaymentError(errorBody: string, evmChain = "Base"): string { try { // Try to parse the error JSON const parsed = JSON.parse(errorBody) as { @@ -478,7 +525,7 @@ export function transformPaymentError(errorBody: string): string { wallet: wallet, current_balance_usd: currentUSD, required_usd: requiredUSD, - help: `Fund wallet ${shortWallet} with USDC on Base, or use free model: /model free`, + help: `Fund wallet ${shortWallet} with USDC on ${evmChain}, or use free model: /model free`, }, }); } @@ -523,7 +570,7 @@ export function transformPaymentError(errorBody: string): string { const shortWallet = wallet.length > 12 ? `${wallet.slice(0, 6)}...${wallet.slice(-4)}` : wallet; // Detect chain from payer address format (0x = EVM, else Solana) - const chain = wallet.startsWith("0x") ? "Base" : "Solana"; + const chain = wallet.startsWith("0x") ? evmChain : "Solana"; if (debugLower.includes("insufficient")) { return JSON.stringify({ @@ -534,7 +581,7 @@ export function transformPaymentError(errorBody: string): string { help: chain === "Solana" ? `Fund wallet ${shortWallet} with USDC on Solana, or switch to Base: /wallet base` - : `Fund wallet ${shortWallet} with USDC on Base, or use free model: /model free`, + : `Fund wallet ${shortWallet} with USDC on ${chain}, or use free model: /model free`, }, }); } @@ -1500,7 +1547,7 @@ export type InsufficientFundsInfo = { */ export type WalletConfig = string | { key: string; solanaPrivateKeyBytes?: Uint8Array }; -export type PaymentChain = "base" | "solana"; +export type { PaymentChain }; /** * How this proxy pays BlockRun. @@ -1525,8 +1572,9 @@ export type ProxyOptions = { apiKey?: string; apiBase?: string; /** - * Payment chain: "base" or "solana". New installs persist "solana" at wallet - * generation; absent config resolves to "base" for pre-existing installs. + * Payment chain: "base", "solana" or "arc". New installs persist "solana" at + * wallet generation; absent config resolves to "base" for pre-existing + * installs. "arc" pays from the same EVM key as Base, on Circle's Arc. * Can also be set via CLAWROUTER_PAYMENT_CHAIN env var. */ paymentChain?: PaymentChain; @@ -2725,9 +2773,7 @@ export async function startProxy(options: ProxyOptions): Promise { options.apiBase ?? (authMode === "api-key" ? BLOCKRUN_API_KEY_API - : paymentChain === "solana" && solanaPrivateKeyBytes - ? BLOCKRUN_SOLANA_API - : BLOCKRUN_API); + : walletGatewayFor(paymentChain, solanaPrivateKeyBytes !== undefined)); // Validated once here rather than per request: an unusable account URL should // fail at startup, not on the first paid call. const apiBase = authMode === "api-key" ? normalizeApiKeyBase(requestedApiBase) : requestedApiBase; @@ -2749,6 +2795,9 @@ export async function startProxy(options: ProxyOptions): Promise { console.warn(`[ClawRouter] or run "npx @blockrun/clawrouter chain base" to switch to EVM.`); } else if (paymentChain === "solana") { console.log(`[ClawRouter] Payment chain: Solana (${BLOCKRUN_SOLANA_API})`); + } else if (paymentChain === "arc") { + // Same EVM key and address as Base — only the chain the USDC is on differs. + console.log(`[ClawRouter] Payment chain: Arc (${apiBase})`); } else { // Base said nothing, so the one rail that never announced itself was also // the default. Three rails, three gateways — each should say which. @@ -2816,7 +2865,7 @@ export async function startProxy(options: ProxyOptions): Promise { const { SolanaBalanceMonitor } = await import("./solana-balance.js"); balanceMonitor = new SolanaBalanceMonitor(reuseSolanaAddress); } else { - balanceMonitor = new BalanceMonitor(account.address); + balanceMonitor = new BalanceMonitor(account.address, evmBalanceChain(paymentChain)); } options.onReady?.(listenPort); @@ -2925,8 +2974,11 @@ export async function startProxy(options: ProxyOptions): Promise { // Gated to EVM (`eip155:*`): builder-code / ERC-8021 is Ethereum-only, so // stamping it onto Solana (`solana:*`) payloads would attach a field the SVM // facilitator never expects — a no-op at best, a settlement risk at worst. + // Arc (eip155:5042) is EVM but is settled by Circle's facilitator, not CDP's; + // the builder code is a CDP registration that only CDP reads, so the same + // reasoning keeps it off Arc payloads too. x402?.onAfterPaymentCreation(async (context) => { - if (!context.selectedRequirements.network.startsWith("eip155")) return; + if (!stampsBuilderCode(context.selectedRequirements.network)) return; const payload = context.paymentPayload as { extensions?: Record; }; @@ -2939,11 +2991,7 @@ export async function startProxy(options: ProxyOptions): Promise { const registerSigningRecorders = (client: PaymentHookClient) => { client.onAfterPaymentCreation(async (context) => { const network = context.selectedRequirements.network; - const chain = network.startsWith("eip155") - ? "Base (EVM)" - : network.startsWith("solana") - ? "Solana" - : network; + const chain = describePaymentNetwork(network); // Capture actual payment amount in USD (amount is in USDC micro units, 6 decimals) const amountMicros = parseInt(context.selectedRequirements.amount || "0", 10); const amountUsd = amountMicros / 1_000_000; @@ -3086,7 +3134,7 @@ export async function startProxy(options: ProxyOptions): Promise { const { SolanaBalanceMonitor } = await import("./solana-balance.js"); balanceMonitor = new SolanaBalanceMonitor(solanaAddress); } else { - balanceMonitor = new BalanceMonitor(account!.address); + balanceMonitor = new BalanceMonitor(account!.address, evmBalanceChain(paymentChain)); } // Build router options (100% local — no external API calls for routing) @@ -3099,6 +3147,9 @@ export async function startProxy(options: ProxyOptions): Promise { }; const catalog = createClawCatalog({ + // Arc's gateway carries Base's catalog (same routes, same prices); the + // snapshot is keyed by "base" and the live refresh reads arc.blockrun.ai + // through `apiBase`. network: paymentChain === "solana" && (apiKey || solanaPrivateKeyBytes) ? "solana" : "base", apiBase, apiKey, @@ -7370,7 +7421,12 @@ async function proxyRequest( const costCapRefusal = errStatus === 429 && rawErrBody.includes('"cost_cap_exceeded"'); // Transform payment errors into user-friendly messages - const transformedErr = transformPaymentError(rawErrBody); + const transformedErr = transformPaymentError( + rawErrBody, + balanceMonitor instanceof BalanceMonitor && balanceMonitor.getChain() === "arc" + ? PAYMENT_CHAIN_LABELS.arc + : PAYMENT_CHAIN_LABELS.base, + ); // Mark as error for usage logging (logs this failed attempt with cost) requestHadError = true; diff --git a/src/spend-control.ts b/src/spend-control.ts index 40291ec3..cb9c07a9 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -19,6 +19,7 @@ import * as path from "node:path"; import { homedir } from "node:os"; import type { x402Client } from "@x402/fetch"; import { readTextFileSync } from "./fs-read.js"; +import { CAIP2_ARC } from "./payment-chain.js"; const WALLET_DIR = path.join(homedir(), ".openclaw", "blockrun"); @@ -39,6 +40,8 @@ export type PolicyList = "allowedPayees" | "blockedPayees" | "allowedNetworks" | export const CAIP2_BASE = "eip155:8453"; /** Solana mainnet genesis, as carried on x402 `selectedRequirements.network`. */ export const CAIP2_SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; +/** Circle's Arc mainnet, as carried on x402 `selectedRequirements.network`. */ +export { CAIP2_ARC }; /** * Every network the proxy can pay on, as carried on x402 @@ -46,7 +49,7 @@ export const CAIP2_SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc1 * validate `allowedNetworks` entries: an entry outside this set can never * match a quote and would only block payments. */ -export const PAYABLE_NETWORKS: readonly string[] = [CAIP2_BASE, CAIP2_SOLANA_MAINNET]; +export const PAYABLE_NETWORKS: readonly string[] = [CAIP2_BASE, CAIP2_SOLANA_MAINNET, CAIP2_ARC]; export const POLICY_LISTS: readonly PolicyList[] = [ "allowedPayees", From 75e6b36d87bf2f72141baa65a5a580c1bd076c17 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 28 Oct 2026 08:36:25 -0500 Subject: [PATCH 2/4] fix(desktop): Arc in the Desktop payment-chain model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop hand-defined PaymentChain as "base" | "solana", so with arc persisted or reported by /health it fell back to Base (configuredPaymentChain), dropped the chain (paymentChainOrUndefined) and offered only Base/Solana — an Arc-paying proxy was shown with Base's balance and funding controls. - apps/desktop/electron/core/chains.ts: re-exports the chain type, list and labels from src/payment-chain.ts (the table the CLI/proxy use) plus the desktop helpers walletKindOf / coreWalletFileOf. types.ts, ipc-policy.ts, manager.ts and the renderer import it; no second hand-written union. - manager: arc parsed from the chain files and /health; Arc USDC read for the EVM address via arc.blockrun.ai/api/v1/balance; balance/restart state per chain; create/adopt "arc" acts on the shared EVM key; legacy arc migrates. - renderer: wallet cards, sidebar switch and deposit rows iterate the shared list (CSS grids sized for N chains); Arc labelled "same address as Base"; the Buy tab warns an Arc user that Coinbase delivers on Base. - test/test-solana-e2e.ts: /health type uses PaymentChain. - PAYMENT_CHAINS order is now Base, Solana, Arc (display order). - tests: apps/desktop/tests/arc-chain.test.ts. --- apps/desktop/README.md | 5 + apps/desktop/electron/core/chains.ts | 41 ++++ apps/desktop/electron/core/ipc-policy.ts | 3 +- apps/desktop/electron/core/manager.ts | 92 ++++++--- apps/desktop/electron/core/types.ts | 5 +- apps/desktop/src/App.tsx | 94 +++++++--- apps/desktop/src/api.ts | 4 +- apps/desktop/src/styles.css | 6 +- apps/desktop/tests/arc-chain.test.ts | 229 +++++++++++++++++++++++ src/payment-chain.ts | 8 +- test/test-solana-e2e.ts | 3 +- 11 files changed, 428 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/electron/core/chains.ts create mode 100644 apps/desktop/tests/arc-chain.test.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e5a0bb76..e3633e5b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -33,6 +33,11 @@ ClawRouter wallet under `~/.openclaw/blockrun/` is copied into the corresponding Core files only when those files do not already exist. The legacy files are retained for rollback; Desktop never overwrites an existing Core wallet. +Payment chains are Base, Solana and Circle's Arc — Desktop takes the list from +ClawRouter's own table (`src/payment-chain.ts`) rather than defining its own. +Arc pays from the same EVM key and address as Base (`~/.blockrun/.session`); +its USDC balance is read from `https://arc.blockrun.ai/api/v1/balance`. + ## One-click contract Every adapter follows the same transaction: diff --git a/apps/desktop/electron/core/chains.ts b/apps/desktop/electron/core/chains.ts new file mode 100644 index 00000000..a343c8b1 --- /dev/null +++ b/apps/desktop/electron/core/chains.ts @@ -0,0 +1,41 @@ +/** + * Desktop's view of the payment chains. The chain list, labels and type are + * NOT redefined here: they come from ClawRouter's own table + * (src/payment-chain.ts), the same one the CLI and proxy use, so a chain the + * proxy can pay on cannot be missing from Desktop. Imported by both the + * Electron main process and the renderer; keep it free of Node imports. + */ +import { + PAYMENT_CHAINS, + PAYMENT_CHAIN_LABELS, + isPaymentChain, + type PaymentChain, +} from "../../../../src/payment-chain.js"; + +export { PAYMENT_CHAINS, PAYMENT_CHAIN_LABELS, isPaymentChain }; +export type { PaymentChain }; + +/** + * Which Core wallet pays on a chain. Base and Arc are both EVM and share ONE + * key (~/.blockrun/.session) and address; Solana has its own + * (~/.blockrun/.solana-session). + */ +export type WalletKind = "evm" | "solana"; + +export function walletKindOf(chain: PaymentChain): WalletKind { + return chain === "solana" ? "solana" : "evm"; +} + +/** The Core wallet file a chain's key lives in. */ +export function coreWalletFileOf(chain: PaymentChain): ".session" | ".solana-session" { + return walletKindOf(chain) === "evm" ? ".session" : ".solana-session"; +} + +export function chainLabel(chain: PaymentChain): string { + return PAYMENT_CHAIN_LABELS[chain]; +} + +/** A chain from /health or a chain file; anything unrecognised is undefined, not Base. */ +export function paymentChainOrUndefined(value: unknown): PaymentChain | undefined { + return isPaymentChain(value) ? value : undefined; +} diff --git a/apps/desktop/electron/core/ipc-policy.ts b/apps/desktop/electron/core/ipc-policy.ts index c3b7019e..f8e204b7 100644 --- a/apps/desktop/electron/core/ipc-policy.ts +++ b/apps/desktop/electron/core/ipc-policy.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; +import { isPaymentChain } from "./chains.js"; import type { AgentId, InstallOptions, PaymentChain } from "./types.js"; const AGENT_IDS = new Set(["openclaw", "codex", "hermes", "dsh", "pi"]); @@ -39,7 +40,7 @@ export function parseInstallOptions(value: unknown): InstallOptions { } export function parsePaymentChain(value: unknown): PaymentChain { - if (value !== "base" && value !== "solana") throw new Error("Unsupported payment chain"); + if (!isPaymentChain(value)) throw new Error("Unsupported payment chain"); return value; } diff --git a/apps/desktop/electron/core/manager.ts b/apps/desktop/electron/core/manager.ts index ff6bdf96..37c4f3a6 100644 --- a/apps/desktop/electron/core/manager.ts +++ b/apps/desktop/electron/core/manager.ts @@ -13,6 +13,13 @@ import { DshAdapter } from "../adapters/dsh.js"; import { HermesAdapter } from "../adapters/hermes.js"; import { OpenClawAdapter } from "../adapters/openclaw.js"; import { PiAdapter } from "../adapters/pi.js"; +import { + chainLabel, + coreWalletFileOf, + isPaymentChain, + paymentChainOrUndefined, + walletKindOf, +} from "./chains.js"; import { commandExists, runCommand, withEmbeddedNode } from "./process.js"; import { CLAWROUTER_PACKAGE_VERSION, ensureNpmPackage, proxyHealth } from "./runtime.js"; import { ServiceSupervisor } from "./supervisor.js"; @@ -35,6 +42,10 @@ import type { const BASE_RPC_URL = "https://mainnet.base.org"; const BASE_USDC_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; const SOLANA_RPC_URL = "https://api.mainnet-beta.solana.com"; +// Arc USDC is read through the Arc gateway's free balance endpoint — the same +// read the proxy uses (src/balance.ts) — because the public Arc RPC is +// unreliable and Desktop ships no RPC key. Answers micro-USDC as a string. +const ARC_BALANCE_URL = "https://arc.blockrun.ai/api/v1/balance"; const SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; const BALANCE_CACHE_TTL_MS = 30_000; @@ -169,7 +180,7 @@ export class ClawRouterManager { message: (result.stderr || result.stdout).trim() || `Could not switch to ${chain}.`, }; } - const label = chain === "solana" ? "Solana" : "Base"; + const label = chainLabel(chain); // The proxy reads the chain once at startup, so apply the switch by // restarting the proxy Desktop launched instead of asking the user to // restart something they never started. A proxy Desktop does not own is @@ -265,7 +276,8 @@ export class ClawRouterManager { async createWallet(chain: PaymentChain): Promise { const coreDir = join(this.context.homeDir, ".blockrun"); - const name = chain === "base" ? ".session" : ".solana-session"; + // Arc creates (or finds) the same EVM key as Base: one file, one address. + const name = coreWalletFileOf(chain); try { const existing = await readText(join(coreDir, name)); if (existing) { @@ -279,7 +291,7 @@ export class ClawRouterManager { let value: string; let address: string; - if (chain === "base") { + if (walletKindOf(chain) === "evm") { value = `0x${Buffer.from(secp256k1.utils.randomSecretKey()).toString("hex")}`; address = evmAddressFromPrivateKey(value); } else { @@ -310,11 +322,11 @@ export class ClawRouterManager { async adoptLegacyWallet(chain: PaymentChain): Promise { const coreDir = join(this.context.homeDir, ".blockrun"); const legacyDir = join(this.context.homeDir, ".openclaw", "blockrun"); - const name = chain === "base" ? ".session" : ".solana-session"; + const name = coreWalletFileOf(chain); try { let value: string | undefined; let address: string | undefined; - if (chain === "base") { + if (walletKindOf(chain) === "evm") { value = await readPrivateKey(join(legacyDir, "wallet.key")); if (value) address = evmAddressFromPrivateKey(value); } else { @@ -385,6 +397,7 @@ export class ClawRouterManager { const legacyBalances = await this.fetchUsdcBalances( legacyWallets.base?.address, legacyWallets.solana?.address, + { withArc: false }, ); const legacyWalletDetails = withLegacyBalances(legacyWallets, legacyBalances); if (!reachable) { @@ -448,6 +461,11 @@ export class ClawRouterManager { activeBalances.base ?? (sameAddress(wallet ?? "", localWallets.base) ? localBalances.base : undefined) ?? (paymentChain === "base" && wallet === activeWallet ? reportedBalance : undefined), + // Arc is the same EVM address as Base, read on a different chain. + arc: + activeBalances.arc ?? + (sameAddress(wallet ?? "", localWallets.base) ? localBalances.arc : undefined) ?? + (paymentChain === "arc" && wallet === activeWallet ? reportedBalance : undefined), solana: activeBalances.solana ?? (solana === localWallets.solana ? localBalances.solana : undefined) ?? @@ -456,7 +474,8 @@ export class ClawRouterManager { const models: ModelInfo[] = (catalog.value?.data ?? []).map(toModelInfo); const walletRestartChains: PaymentChain[] = []; if (preferredWallet && activeWallet && !sameAddress(activeWallet, preferredWallet)) { - walletRestartChains.push("base"); + // One EVM key pays both Base and Arc, so a stale EVM key is stale on both. + walletRestartChains.push("base", "arc"); } if (localWallets.solana && activeSolana && activeSolana !== localWallets.solana) { walletRestartChains.push("solana"); @@ -502,30 +521,39 @@ export class ClawRouterManager { private async configuredPaymentChain(): Promise { const coreValue = await readText(join(this.context.homeDir, ".blockrun", ".chain")); - if (coreValue === "base" || coreValue === "solana") return coreValue; + if (isPaymentChain(coreValue)) return coreValue; const legacyValue = await readText( join(this.context.homeDir, ".openclaw", "blockrun", "payment-chain"), ); - return legacyValue === "solana" ? "solana" : "base"; + return isPaymentChain(legacyValue) ? legacyValue : "base"; } + /** + * USDC per chain. The EVM address is read on Base AND Arc (one key, two + * chains); `withArc: false` skips Arc for wallets that only ever pay Base + * (the legacy-wallet migration panel). + */ private async fetchUsdcBalances( - baseAddress: string | undefined, + evmAddress: string | undefined, solanaAddress: string | undefined, + { withArc = true }: { withArc?: boolean } = {}, ): Promise>> { - const [base, solana] = await Promise.all([ - baseAddress ? this.fetchCachedBalance("base", baseAddress) : Promise.resolve(undefined), + const [base, arc, solana] = await Promise.all([ + evmAddress ? this.fetchCachedBalance("base", evmAddress) : Promise.resolve(undefined), + evmAddress && withArc + ? this.fetchCachedBalance("arc", evmAddress) + : Promise.resolve(undefined), solanaAddress ? this.fetchCachedBalance("solana", solanaAddress) : Promise.resolve(undefined), ]); - return { base, solana }; + return { base, arc, solana }; } private async fetchCachedBalance( chain: PaymentChain, address: string, ): Promise { - const normalizedAddress = chain === "base" ? address.toLowerCase() : address; + const normalizedAddress = walletKindOf(chain) === "evm" ? address.toLowerCase() : address; const cacheKey = `${chain}:${normalizedAddress}`; const cached = this.balanceCache.get(cacheKey); const now = Date.now(); @@ -534,7 +562,9 @@ export class ClawRouterManager { const value = chain === "base" ? await fetchBaseUsdcBalance(address, this.context.fetch) - : await fetchSolanaUsdcBalance(address, this.context.fetch); + : chain === "arc" + ? await fetchArcUsdcBalance(address, this.context.fetch) + : await fetchSolanaUsdcBalance(address, this.context.fetch); if (value !== undefined) { this.balanceCache.set(cacheKey, { value, fetchedAt: now }); return value; @@ -564,7 +594,7 @@ export class ClawRouterManager { const secret = [...seed, ...ed25519PublicKey(seed)]; pending.push(writeFileIfMissing(coreDir, ".solana-session", JSON.stringify(secret) + "\n")); } - if (!coreChain && (legacyChain === "base" || legacyChain === "solana")) { + if (!coreChain && isPaymentChain(legacyChain)) { pending.push(writeFileIfMissing(coreDir, ".chain", legacyChain + "\n")); } await Promise.all(pending); @@ -719,10 +749,6 @@ function toModelInfo(model: Record): ModelInfo { }; } -function paymentChainOrUndefined(value: unknown): PaymentChain | undefined { - return value === "base" || value === "solana" ? value : undefined; -} - function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -746,6 +772,30 @@ async function fetchBaseUsdcBalance( return rpcHexNumber(token?.result, 1_000_000); } +/** + * Arc USDC (dollars) for an EVM address, via the Arc gateway. Undefined on any + * failure, like the Base and Solana readers: the caller keeps the last cached + * value rather than showing an outage as $0. + */ +async function fetchArcUsdcBalance( + address: string, + fetcher: typeof fetch, +): Promise { + if (!/^0x[0-9a-f]{40}$/i.test(address)) return undefined; + try { + const response = await fetcher(`${ARC_BALANCE_URL}?address=${encodeURIComponent(address)}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) return undefined; + const body = (await response.json()) as { balance?: unknown }; + if (typeof body.balance !== "string" || !/^\d+$/.test(body.balance)) return undefined; + return Number(BigInt(body.balance)) / 1_000_000; + } catch { + return undefined; + } +} + async function fetchSolanaUsdcBalance( address: string, fetcher: typeof fetch, @@ -837,10 +887,6 @@ async function atomicWritePrivateFile( } } -function chainLabel(chain: PaymentChain): string { - return chain === "base" ? "Base" : "Solana"; -} - function withLegacyBalances( wallets: Partial>, balances: Partial>, diff --git a/apps/desktop/electron/core/types.ts b/apps/desktop/electron/core/types.ts index 409e3fc4..3465756b 100644 --- a/apps/desktop/electron/core/types.ts +++ b/apps/desktop/electron/core/types.ts @@ -1,8 +1,11 @@ +import type { PaymentChain } from "./chains.js"; + export type AgentId = "openclaw" | "codex" | "hermes" | "dsh" | "pi"; export type ActivationMode = "immediate" | "restart-agent" | "restart-gateway"; -export type PaymentChain = "base" | "solana"; +/** Base | Solana | Arc — from ClawRouter's chain table (src/payment-chain.ts), not redefined here. */ +export type { PaymentChain }; export type AgentHealth = "not-installed" | "ready" | "needs-attention" | "checking"; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ed2bc0d5..80d753ac 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,6 +8,12 @@ import type { ModelInfo, PaymentChain, } from "../electron/core/types"; +import { + PAYMENT_CHAINS, + chainLabel, + paymentChainOrUndefined, + walletKindOf, +} from "../electron/core/chains"; import { api } from "./api"; import { AGENT_ICON_DATA } from "./agent-icons"; import BLOCKRUN_ICON from "./blockrun-icon.svg"; @@ -145,7 +151,7 @@ export function App() { async function adoptLegacyWallet(chain: PaymentChain, address: string) { const confirmed = window.confirm( - `Use ${shortAddress(address)} as your current ${chain === "base" ? "Base" : "Solana"} wallet? Your current Core wallet will be backed up and ClawRouter will need a restart.`, + `Use ${shortAddress(address)} as your current ${chainLabel(chain)} wallet? Your current Core wallet will be backed up and ClawRouter will need a restart.`, ); if (!confirmed) return; setWalletBusy(chain); @@ -292,6 +298,7 @@ export function App() { busy={fundingBusy} wallet={dashboard?.proxy.configuredWallet ?? dashboard?.proxy.wallet} solana={dashboard?.proxy.configuredSolana ?? dashboard?.proxy.solana} + chain={dashboard?.proxy.configuredChain} onAmount={setFundingAmount} onClose={() => !fundingBusy && setFundingOpen(false)} onContinue={() => void startOnramp()} @@ -323,7 +330,7 @@ function Overview({ () => collapseModelAliases(dashboard?.models ?? []).length, [dashboard?.models], ); - const chain = dashboard?.proxy.configuredChain === "solana" ? "Solana" : "Base"; + const chain = chainLabel(dashboard?.proxy.configuredChain ?? "base"); // A proxy authenticating with a BlockRun API key settles nothing on-chain, so // name the account instead of claiming a chain that is not there. const settlement = @@ -741,7 +748,7 @@ function WalletCenter({ → - {selected === "base" ? "Base" : "Solana"} + {chainLabel(selected)} default network → @@ -752,15 +759,17 @@ function WalletCenter({
- {(["base", "solana"] as PaymentChain[]).map((chain) => { - const address = - chain === "base" - ? (proxy?.configuredWallet ?? proxy?.wallet) - : (proxy?.configuredSolana ?? proxy?.solana); - const activeAddress = chain === "base" ? proxy?.activeWallet : proxy?.activeSolana; + {PAYMENT_CHAINS.map((chain) => { + // Base and Arc pay from the same EVM key and address. + const evm = walletKindOf(chain) === "evm"; + const address = evm + ? (proxy?.configuredWallet ?? proxy?.wallet) + : (proxy?.configuredSolana ?? proxy?.solana); + const activeAddress = evm ? proxy?.activeWallet : proxy?.activeSolana; const restart = proxy?.walletRestartChains?.includes(chain) ?? false; - const issue = proxy?.walletIssues?.[chain]; - const name = chain === "base" ? "Base" : "Solana"; + // Wallet issues are reported per key file; Arc shares Base's. + const issue = proxy?.walletIssues?.[evm ? "base" : "solana"]; + const name = chainLabel(chain); return (
{name} - USDC + {chain === "arc" ? "USDC · same address as Base" : "USDC"} {selected === chain && Default network} @@ -822,9 +831,7 @@ function WalletCenter({ disabled={walletBusy !== null || Boolean(issue)} onClick={() => onCreate(chain)} > - {walletBusy === chain - ? "Creating…" - : `Create ${chain === "base" ? "Base" : "Solana"} wallet`} + {walletBusy === chain ? "Creating…" : `Create ${name} wallet`} )} @@ -843,13 +850,15 @@ function WalletCenter({

+ {/* Legacy wallets are per KEY, not per chain: the old EVM key + (wallet.key) pays Base and Arc alike, so it is listed once. */} {(["base", "solana"] as PaymentChain[]).map((chain) => { const legacy = proxy.legacyWallets?.[chain]; if (!legacy) return null; return (
- {chain === "base" ? "Base" : "Solana"} + {chain === "base" ? "Base / Arc" : "Solana"} {shortAddress(legacy.address)} {formatWalletBalance(legacy.balance, false, dashboard === null)} @@ -870,8 +879,8 @@ function WalletCenter({
Network selection is not wallet selection

- Base / Solana chooses which network pays for requests. It does not switch between - multiple wallets on the same network. + Base / Solana / Arc chooses which network pays for requests. It does not switch between + multiple wallets on the same network. Base and Arc share one address.

@@ -990,6 +999,21 @@ function ChainLogo({ chain }: { chain: PaymentChain }) { // Gradient ids must be unique per instance: the Solana mark can render twice // at once (wallet card plus the deposit sheet), and duplicate ids are invalid. const gradient = `sol-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + if (chain === "arc") { + // Neutral mark (an arc over a roundel), not Circle's brand asset. + return ( + + ); + } if (chain === "base") { return ( {proxy?.chainRestartRequired || proxy?.walletRestartRequired ? "Restart" : "Live"}
- {(["base", "solana"] as PaymentChain[]).map((chain) => { + {PAYMENT_CHAINS.map((chain) => { const available = Boolean( - chain === "base" + walletKindOf(chain) === "evm" ? (proxy?.configuredWallet ?? proxy?.wallet) : (proxy?.configuredSolana ?? proxy?.solana), ); @@ -1178,7 +1203,7 @@ function WalletSummary({ title={available ? undefined : `Create a ${chain} wallet first`} onClick={() => onSwitch(chain)} > - {chain === "base" ? "Base" : "Solana"} + {chainLabel(chain)} {formatWalletBalance(proxy?.balances?.[chain], true)} ); @@ -1194,9 +1219,7 @@ function WalletSummary({ Add funds {proxy?.chainRestartRequired && ( - - Restart gateway to activate {selected === "solana" ? "Solana" : "Base"} - + Restart gateway to activate {chainLabel(selected)} )} {proxy?.walletRestartChains?.includes(selected) && ( Restart gateway to activate the shared wallet @@ -1210,6 +1233,7 @@ function FundingDialog({ busy, wallet, solana, + chain, onAmount, onClose, onContinue, @@ -1218,6 +1242,8 @@ function FundingDialog({ busy: boolean; wallet: string | undefined; solana: string | undefined; + /** The selected payment chain, so an Arc user is not told Base USDC funds Arc. */ + chain?: PaymentChain; onAmount(value: number): void; onClose(): void; onContinue(): void; @@ -1234,10 +1260,12 @@ function FundingDialog({ return () => window.removeEventListener("keydown", handleKey); }, [onClose]); const valid = Number.isFinite(amount) && amount >= 1 && amount <= 2_500; - const depositRows: { chain: PaymentChain; label: string; address?: string }[] = [ - { chain: "base", label: "Base", address: wallet }, - { chain: "solana", label: "Solana", address: solana }, - ]; + const depositRows: { chain: PaymentChain; label: string; address?: string }[] = + PAYMENT_CHAINS.map((rowChain) => ({ + chain: rowChain, + label: chainLabel(rowChain), + address: walletKindOf(rowChain) === "evm" ? wallet : solana, + })); return (
)} + {chain === "arc" && ( +

+ Coinbase delivers USDC on Base. Your payment chain is Arc, so calls pay from USDC on + Arc — deposit USDC on Arc to the same address, or switch to Base. +

+ )}

Coinbase shows the final quote, fees, and payment methods available in your region. The one-time checkout opens in your browser. diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 12ebbb96..159fed63 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -125,7 +125,7 @@ const demoDashboard: DashboardData = { paymentChain: "base", configuredChain: "base", balance: 18.42, - balances: { base: 18.42, solana: 7.08 }, + balances: { base: 18.42, solana: 7.08, arc: 3.5 }, }, stats: { requests: demoRequests, @@ -240,7 +240,7 @@ const fallbackApi: ClawRouterApi = { createWallet: async (chain) => ({ ok: true, chain, - address: chain === "base" ? "0x4d20…81b2" : "7WzH…9xKe", + address: chain === "solana" ? "7WzH…9xKe" : "0x4d20…81b2", restartRequired: false, message: `${chain} wallet created.`, }), diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index f8a3b00e..efe1da52 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -287,7 +287,9 @@ nav button.active > span { } .chain-switch { display: grid; - grid-template-columns: 1fr 1fr; + /* One column per payment chain, however many src/payment-chain.ts lists. */ + grid-auto-flow: column; + grid-auto-columns: 1fr; padding: 2px; border-radius: 7px; background: var(--surface-raised); @@ -1767,7 +1769,7 @@ main { } .wallet-network-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } .wallet-network-card { diff --git a/apps/desktop/tests/arc-chain.test.ts b/apps/desktop/tests/arc-chain.test.ts new file mode 100644 index 00000000..1ec56fb0 --- /dev/null +++ b/apps/desktop/tests/arc-chain.test.ts @@ -0,0 +1,229 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { createHmac } from "node:crypto"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + PAYMENT_CHAINS, + chainLabel, + coreWalletFileOf, + paymentChainOrUndefined, + walletKindOf, +} from "../electron/core/chains.js"; +import { parsePaymentChain } from "../electron/core/ipc-policy.js"; +import { ClawRouterManager } from "../electron/core/manager.js"; +import { CLAWROUTER_PACKAGE_VERSION } from "../electron/core/runtime.js"; +import { PAYMENT_CHAINS as CLAWROUTER_CHAINS } from "../../../src/payment-chain.js"; + +/** + * Arc (Circle, eip155:5042) in Desktop. Desktop used to hand-define + * PaymentChain as "base" | "solana", so a proxy paying on Arc was shown as + * Base — Base's balance, Base's funding controls — pointing the user at the + * wrong network. These pin that Desktop takes its chains from ClawRouter's + * table and treats Arc as the EVM wallet read on Arc. + */ + +const ARC_BALANCE = "https://arc.blockrun.ai/api/v1/balance"; +const EVM_KEY = `0x${"0".repeat(63)}1`; +const EVM_ADDRESS = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; // address of key 0x…01 + +function response(body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +async function authenticatedResponse( + homeDir: string, + init: RequestInit | undefined, + body: unknown, +): Promise { + const challenge = new Headers(init?.headers).get("x-clawrouter-challenge"); + if (!challenge) return response(body); + const token = ( + await readFile(join(homeDir, ".clawrouter-desktop", "service-token"), "utf8") + ).trim(); + return response(body, { + "X-ClawRouter-Proof": createHmac("sha256", token).update(challenge).digest("hex"), + }); +} + +async function homeWithEvmWallet(prefix: string, chain?: string): Promise { + const home = await mkdtemp(join(tmpdir(), prefix)); + await mkdir(join(home, ".blockrun"), { recursive: true }); + await writeFile(join(home, ".blockrun", ".session"), `${EVM_KEY}\n`, { mode: 0o600 }); + if (chain) await writeFile(join(home, ".blockrun", ".chain"), `${chain}\n`); + return home; +} + +/** Base USDC 12.5, Arc USDC 24.25; anything else is "proxy offline" unless `health` is given. */ +function chainFetcher(home: string, health?: Record, arcCalls: string[] = []) { + return (async (url: string | URL | Request, init?: RequestInit) => { + const path = String(url); + if (path === "https://mainnet.base.org") { + return response({ result: `0x${BigInt(12_500_000).toString(16)}` }); + } + if (path.startsWith(ARC_BALANCE)) { + arcCalls.push(path); + return response({ balance: "24253720", balanceUSD: "$24.25", network: "arc" }); + } + if (!health) throw new TypeError("proxy offline"); + if (path.endsWith("/admin/models")) return new Response("{}", { status: 404 }); + if (path.endsWith("/v1/models")) return response({ data: [] }); + if (path.includes("/stats")) return response({}); + return authenticatedResponse(home, init, health); + }) as typeof fetch; +} + +describe("Desktop payment chains come from ClawRouter's table", () => { + it("lists exactly the chains the proxy can pay on, Arc included", () => { + expect([...PAYMENT_CHAINS]).toEqual([...CLAWROUTER_CHAINS]); + expect(PAYMENT_CHAINS).toContain("arc"); + expect(chainLabel("arc")).toBe("Arc"); + }); + + it("accepts arc over IPC and still rejects anything undeclared", () => { + expect(parsePaymentChain("arc")).toBe("arc"); + expect(() => parsePaymentChain("eip155:5042")).toThrow("Unsupported payment chain"); + expect(() => parsePaymentChain("ARC")).toThrow("Unsupported payment chain"); + }); + + it("keeps arc from /health instead of discarding it", () => { + expect(paymentChainOrUndefined("arc")).toBe("arc"); + expect(paymentChainOrUndefined("ethereum")).toBeUndefined(); + }); + + it("pays Arc from the EVM (Base) key, never the Solana one", () => { + expect(walletKindOf("arc")).toBe("evm"); + expect(coreWalletFileOf("arc")).toBe(".session"); + expect(coreWalletFileOf("base")).toBe(".session"); + expect(coreWalletFileOf("solana")).toBe(".solana-session"); + }); +}); + +describe("ClawRouterManager on Arc", () => { + it("reads a persisted arc choice and shows the Arc balance, not Base's, while offline", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-offline-", "arc"); + const arcCalls: string[] = []; + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: chainFetcher(home, undefined, arcCalls), + }); + + const dashboard = await manager.dashboard(); + expect(dashboard.proxy).toMatchObject({ + reachable: false, + configuredChain: "arc", + balance: 24.25372, + balances: { base: 12.5, arc: 24.25372 }, + }); + expect(arcCalls[0]?.toLowerCase()).toBe(`${ARC_BALANCE}?address=${EVM_ADDRESS}`.toLowerCase()); + }); + + it("keeps a running proxy's arc chain and reports its Arc balance", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-live-", "arc"); + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: chainFetcher(home, { + status: "ok", + wallet: EVM_ADDRESS, + paymentChain: "arc", + gateway: "https://arc.blockrun.ai/api", + balance: "$24.25", + }), + }); + + const dashboard = await manager.dashboard(); + expect(dashboard.proxy.reachable).toBe(true); + expect(dashboard.proxy.paymentChain).toBe("arc"); + expect(dashboard.proxy.configuredChain).toBe("arc"); + expect(dashboard.proxy.chainRestartRequired).toBe(false); + expect(dashboard.proxy.balance).toBe(24.25372); + expect(dashboard.proxy.balances?.base).toBe(12.5); + }); + + it("flags a restart when Arc is saved but the running proxy still pays on Base", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-restart-", "arc"); + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: chainFetcher(home, { status: "ok", wallet: EVM_ADDRESS, paymentChain: "base" }), + }); + const dashboard = await manager.dashboard(); + expect(dashboard.proxy.chainRestartRequired).toBe(true); + }); + + it("switches to arc through the CLI and confirms Arc is active", async () => { + const home = await mkdtemp(join(tmpdir(), "clawrouter-arc-switch-")); + const runtime = join(home, ".clawrouter-desktop", "runtime", "node_modules"); + await mkdir(dirname(join(runtime, ".bin", "clawrouter")), { recursive: true }); + await mkdir(join(runtime, "@blockrun", "clawrouter"), { recursive: true }); + await writeFile(join(runtime, ".bin", "clawrouter"), "#!/bin/sh\n", { mode: 0o755 }); + await writeFile( + join(runtime, "@blockrun", "clawrouter", "package.json"), + JSON.stringify({ version: CLAWROUTER_PACKAGE_VERSION }), + ); + const commands: string[][] = []; + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async (_command, args) => { + commands.push(args); + return { code: 0, stdout: "", stderr: "" }; + }, + fetch: chainFetcher(home, { status: "ok", wallet: EVM_ADDRESS, paymentChain: "arc" }), + }); + manager.supervisor.restartProxy = async () => true; + + const result = await manager.switchPaymentChain("arc"); + expect(result).toMatchObject({ ok: true, chain: "arc", restartRequired: false }); + expect(result.message).toContain("Arc is active"); + expect(commands.some((args) => args.join(" ").endsWith("chain arc"))).toBe(true); + }); + + it("creating an Arc wallet creates the shared EVM key, not a Solana one", async () => { + const home = await mkdtemp(join(tmpdir(), "clawrouter-arc-create-")); + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: chainFetcher(home), + }); + const created = await manager.createWallet("arc"); + expect(created.ok).toBe(true); + expect(created.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(existsSync(join(home, ".blockrun", ".session"))).toBe(true); + expect(existsSync(join(home, ".blockrun", ".solana-session"))).toBe(false); + // And a Base wallet now "already exists": it is the same file. + expect((await manager.createWallet("base")).ok).toBe(false); + }); + + it("migrates a legacy arc chain selection into BlockRun Core", async () => { + const home = await mkdtemp(join(tmpdir(), "clawrouter-arc-migrate-")); + await mkdir(join(home, ".openclaw", "blockrun"), { recursive: true }); + await writeFile(join(home, ".openclaw", "blockrun", "payment-chain"), "arc\n"); + const manager = new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: chainFetcher(home), + }); + const dashboard = await manager.dashboard(); + expect(dashboard.proxy.configuredChain).toBe("arc"); + expect((await readFile(join(home, ".blockrun", ".chain"), "utf8")).trim()).toBe("arc"); + }); +}); diff --git a/src/payment-chain.ts b/src/payment-chain.ts index b1bd20d6..87538bfe 100644 --- a/src/payment-chain.ts +++ b/src/payment-chain.ts @@ -17,8 +17,12 @@ export type PaymentChain = "base" | "solana" | "arc"; -/** Every selectable chain, in the order help text lists them. */ -export const PAYMENT_CHAINS: readonly PaymentChain[] = ["solana", "base", "arc"]; +/** + * Every selectable chain, in display order (Desktop's chain selector renders + * this list as-is). Also imported by apps/desktop, so adding a chain here is + * what puts it in front of a Desktop user. + */ +export const PAYMENT_CHAINS: readonly PaymentChain[] = ["base", "solana", "arc"]; export function isPaymentChain(value: unknown): value is PaymentChain { return typeof value === "string" && (PAYMENT_CHAINS as readonly string[]).includes(value); diff --git a/test/test-solana-e2e.ts b/test/test-solana-e2e.ts index 9cc8a59c..9e7954fe 100644 --- a/test/test-solana-e2e.ts +++ b/test/test-solana-e2e.ts @@ -15,6 +15,7 @@ */ import { startProxy } from "../src/proxy.js"; +import type { PaymentChain } from "../src/payment-chain.js"; import { generateWalletMnemonic, deriveEvmKey, deriveSolanaKeyBytes } from "../src/wallet.js"; let passed = 0; @@ -83,7 +84,7 @@ async function run() { status: string; wallet: string; solana?: string; - paymentChain?: "base" | "solana"; + paymentChain?: PaymentChain; }; assert(healthData.status === "ok", `Health status: ${healthData.status}`); assert(typeof healthData.wallet === "string", `Health reports EVM wallet: ${healthData.wallet}`); From 36a5c7058b5cf78934143debfb8706d30d61b68b Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 28 Oct 2026 08:43:44 -0500 Subject: [PATCH 3/4] fix(arc): doctor funding guidance per chain; Desktop never waits on Arc - doctor: the empty-wallet message is now emptyWalletGuidance(), derived from the active payment chain. On Arc it says to send USDC on Arc to the same EVM address and drops the Base funding line and the bridge.base.org link; Base and Solana output is unchanged. - Desktop manager: the Arc balance read is awaited only when Arc is the configured or running chain. Otherwise it returns the cached (or no) value at once and refreshes in the background, one read in flight per address, so a slow or down arc.blockrun.ai no longer holds every Base / Solana / API-key dashboard refresh for its 10s timeout. - Tests: src/doctor.arc-guidance.test.ts; Desktop tests that a hung Arc gateway does not block Base or Solana dashboards, that reads do not stack, that the background read fills the next refresh, and that an API-key dashboard makes no Arc call. --- apps/desktop/electron/core/manager.ts | 67 +++++++++++++++++---- apps/desktop/tests/arc-chain.test.ts | 83 +++++++++++++++++++++++++++ src/doctor.arc-guidance.test.ts | 40 +++++++++++++ src/doctor.ts | 41 ++++++++++--- 4 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 src/doctor.arc-guidance.test.ts diff --git a/apps/desktop/electron/core/manager.ts b/apps/desktop/electron/core/manager.ts index 37c4f3a6..ec9dea46 100644 --- a/apps/desktop/electron/core/manager.ts +++ b/apps/desktop/electron/core/manager.ts @@ -55,6 +55,8 @@ export class ClawRouterManager { private readonly transaction: ConfigurationTransaction; private readonly adapters: Map; private readonly balanceCache = new Map(); + /** Background balance reads in flight, keyed like balanceCache, so a refresh never stacks them. */ + private readonly balanceRefreshes = new Map>(); constructor(overrides: Partial = {}) { const homeDir = overrides.homeDir ?? homedir(); @@ -383,13 +385,19 @@ export class ClawRouterManager { const root = this.context.proxyBaseUrl.replace(/\/v1\/?$/, ""); const localWalletsPromise = this.localWallets(); const legacyWalletsPromise = localWalletsPromise.then((local) => this.legacyWallets(local)); - const localBalancesPromise = localWalletsPromise.then(({ base, solana }) => - this.fetchUsdcBalances(base, solana), + const configuredChainPromise = this.configuredPaymentChain(); + // Arc is awaited only when it is the chain being paid on. Otherwise its + // balance is shown from cache and refreshed in the background, so a slow or + // unreachable arc.blockrun.ai never holds up a Base, Solana or API-key + // user's dashboard (which refreshes every 15s) for its 10s timeout. + const localBalancesPromise = Promise.all([localWalletsPromise, configuredChainPromise]).then( + ([{ base, solana }, chain]) => + this.fetchUsdcBalances(base, solana, { arc: chain === "arc" ? "await" : "background" }), ); const [reachable, configuredChain, localWallets, legacyWallets, localBalances] = await Promise.all([ proxyHealth(this.context), - this.configuredPaymentChain(), + configuredChainPromise, localWalletsPromise, legacyWalletsPromise, localBalancesPromise, @@ -397,7 +405,7 @@ export class ClawRouterManager { const legacyBalances = await this.fetchUsdcBalances( legacyWallets.base?.address, legacyWallets.solana?.address, - { withArc: false }, + { arc: "skip" }, ); const legacyWalletDetails = withLegacyBalances(legacyWallets, legacyBalances); if (!reachable) { @@ -455,6 +463,7 @@ export class ClawRouterManager { const activeBalances = await this.fetchUsdcBalances( wallet && !sameAddress(wallet, localWallets.base) ? wallet : undefined, solana && solana !== localWallets.solana ? solana : undefined, + { arc: paymentChain === "arc" || configuredChain === "arc" ? "await" : "background" }, ); const balances: Partial> = { base: @@ -531,30 +540,60 @@ export class ClawRouterManager { /** * USDC per chain. The EVM address is read on Base AND Arc (one key, two - * chains); `withArc: false` skips Arc for wallets that only ever pay Base - * (the legacy-wallet migration panel). + * chains). `arc` decides how the Arc read joins the response: + * "await" — Arc is the active chain; wait for it like Base/Solana. + * "background" — return the cached Arc value (or none) at once and refresh + * it off the response path; the next refresh shows it. + * "skip" — never read Arc (legacy wallets only ever paid Base). */ private async fetchUsdcBalances( evmAddress: string | undefined, solanaAddress: string | undefined, - { withArc = true }: { withArc?: boolean } = {}, + { arc: arcMode }: { arc: "await" | "background" | "skip" }, ): Promise>> { const [base, arc, solana] = await Promise.all([ evmAddress ? this.fetchCachedBalance("base", evmAddress) : Promise.resolve(undefined), - evmAddress && withArc - ? this.fetchCachedBalance("arc", evmAddress) - : Promise.resolve(undefined), + !evmAddress || arcMode === "skip" + ? Promise.resolve(undefined) + : arcMode === "await" + ? this.fetchCachedBalance("arc", evmAddress) + : Promise.resolve(this.cachedBalanceRefreshingInBackground("arc", evmAddress)), solanaAddress ? this.fetchCachedBalance("solana", solanaAddress) : Promise.resolve(undefined), ]); return { base, arc, solana }; } + /** + * The last known balance (fresh or stale), without waiting. A stale or + * missing entry starts one background read that fills the cache; its + * failure is swallowed, exactly like a failed foreground read. + */ + private cachedBalanceRefreshingInBackground( + chain: PaymentChain, + address: string, + ): number | undefined { + const cacheKey = balanceCacheKey(chain, address); + const cached = this.balanceCache.get(cacheKey); + const fresh = cached && Date.now() - cached.fetchedAt < BALANCE_CACHE_TTL_MS; + if (!fresh && !this.balanceRefreshes.has(cacheKey)) { + const refresh = this.fetchCachedBalance(chain, address) + .catch(() => undefined) + .finally(() => this.balanceRefreshes.delete(cacheKey)); + this.balanceRefreshes.set(cacheKey, refresh); + } + return cached?.value; + } + + /** Test seam: settles once every background balance read has. */ + async settleBackgroundBalanceReads(): Promise { + await Promise.all([...this.balanceRefreshes.values()]); + } + private async fetchCachedBalance( chain: PaymentChain, address: string, ): Promise { - const normalizedAddress = walletKindOf(chain) === "evm" ? address.toLowerCase() : address; - const cacheKey = `${chain}:${normalizedAddress}`; + const cacheKey = balanceCacheKey(chain, address); const cached = this.balanceCache.get(cacheKey); const now = Date.now(); if (cached && now - cached.fetchedAt < BALANCE_CACHE_TTL_MS) return cached.value; @@ -772,6 +811,10 @@ async function fetchBaseUsdcBalance( return rpcHexNumber(token?.result, 1_000_000); } +function balanceCacheKey(chain: PaymentChain, address: string): string { + return `${chain}:${walletKindOf(chain) === "evm" ? address.toLowerCase() : address}`; +} + /** * Arc USDC (dollars) for an EVM address, via the Arc gateway. Undefined on any * failure, like the Base and Solana readers: the caller keeps the last cached diff --git a/apps/desktop/tests/arc-chain.test.ts b/apps/desktop/tests/arc-chain.test.ts index 1ec56fb0..29958b61 100644 --- a/apps/desktop/tests/arc-chain.test.ts +++ b/apps/desktop/tests/arc-chain.test.ts @@ -227,3 +227,86 @@ describe("ClawRouterManager on Arc", () => { expect((await readFile(join(home, ".blockrun", ".chain"), "utf8")).trim()).toBe("arc"); }); }); + +describe("Arc balance never holds up a non-Arc dashboard", () => { + /** Base answers at once; the Arc gateway hangs until `release` is called. */ + function hangingArcFetcher(home: string, health: Record) { + let release: () => void = () => {}; + const gate = new Promise((resolve) => (release = resolve)); + const arcCalls: string[] = []; + const fetcher = (async (url: string | URL | Request, init?: RequestInit) => { + const path = String(url); + if (path.startsWith(ARC_BALANCE)) { + arcCalls.push(path); + await gate; + return response({ balance: "24253720", network: "arc" }); + } + return chainFetcher(home, health)(url, init); + }) as typeof fetch; + return { fetcher, arcCalls, release: () => release() }; + } + + function managerFor(home: string, fetcher: typeof fetch) { + return new ClawRouterManager({ + homeDir: home, + stateDir: join(home, ".clawrouter-desktop"), + commandExists: async () => false, + runCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + fetch: fetcher, + }); + } + + it("answers a Base user's dashboard while arc.blockrun.ai is hung, then fills Arc in", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-hang-", "base"); + const arc = hangingArcFetcher(home, { + status: "ok", + wallet: EVM_ADDRESS, + paymentChain: "base", + }); + const manager = managerFor(home, arc.fetcher); + + const first = await manager.dashboard(); // would never settle if Arc were awaited + expect(first.proxy.balance).toBe(12.5); + expect(first.proxy.balances?.base).toBe(12.5); + expect(first.proxy.balances?.arc).toBeUndefined(); + + // A second refresh while the first Arc read is still out does not stack another. + await manager.dashboard(); + expect(arc.arcCalls).toHaveLength(1); + + arc.release(); + await manager.settleBackgroundBalanceReads(); + const later = await manager.dashboard(); + expect(later.proxy.balances?.arc).toBe(24.25372); + expect(later.proxy.balance).toBe(12.5); // still Base's: Base is the active chain + }); + + it("does not hold an offline Solana user's dashboard on Arc either", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-hang-sol-", "solana"); + const arc = hangingArcFetcher(home, { + status: "ok", + wallet: EVM_ADDRESS, + paymentChain: "solana", + }); + const dashboard = await managerFor(home, arc.fetcher).dashboard(); + expect(dashboard.proxy.configuredChain).toBe("solana"); + expect(dashboard.proxy.balances?.arc).toBeUndefined(); + arc.release(); + }); + + it("an API-key proxy's dashboard never calls the Arc gateway at all", async () => { + const home = await mkdtemp(join(tmpdir(), "clawrouter-arc-apikey-")); + const arc = hangingArcFetcher(home, { + status: "ok", + authMode: "api-key", + // Desktop's identity check (verifyClawRouter) requires a string `wallet`. + wallet: "", + apiKey: "brk_live_…abcd", + gateway: "https://api.blockrun.ai", + }); + const dashboard = await managerFor(home, arc.fetcher).dashboard(); + expect(dashboard.proxy, JSON.stringify(dashboard.proxy)).toMatchObject({ authMode: "api-key" }); + expect(arc.arcCalls).toHaveLength(0); // no EVM wallet on disk, nothing to read + arc.release(); + }); +}); diff --git a/src/doctor.arc-guidance.test.ts b/src/doctor.arc-guidance.test.ts new file mode 100644 index 00000000..d8e2f61e --- /dev/null +++ b/src/doctor.arc-guidance.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { emptyWalletGuidance } from "./doctor.js"; + +const EVM = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; +const SOL = "7Xkr1111111111111111111111111111111111111xyz"; + +describe("doctor empty-wallet guidance follows the active chain", () => { + it("on Arc: fund USDC on Arc at the same address, no Base bridge", () => { + const text = emptyWalletGuidance({ + paymentChain: "arc", + address: EVM, + solanaAddress: SOL, + }).join("\n"); + expect(text).toContain(`USDC on Arc (same address as Base): ${EVM}`); + expect(text).not.toMatch(/bridge\.base\.org/); + expect(text).not.toMatch(/USDC on Base:/); + expect(text).not.toContain(SOL); + }); + + it("on Base: unchanged — Base funding and the Base bridge", () => { + const text = emptyWalletGuidance({ + paymentChain: "base", + address: EVM, + solanaAddress: null, + }).join("\n"); + expect(text).toContain(`Fund your EVM wallet with USDC on Base: ${EVM}`); + expect(text).toContain("https://bridge.base.org"); + expect(text).not.toMatch(/on Arc/); + }); + + it("on Solana: still lists the Solana address", () => { + const text = emptyWalletGuidance({ + paymentChain: "solana", + address: EVM, + solanaAddress: SOL, + }).join("\n"); + expect(text).toContain(`Fund your Solana wallet with USDC: ${SOL}`); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts index f3f2ac4e..ee9797a9 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -48,7 +48,7 @@ interface SystemInfo { memoryTotal: string; } -interface WalletInfo { +export interface WalletInfo { exists: boolean; valid: boolean; address: string | null; @@ -533,6 +533,37 @@ export function createDoctorX402Client(opts: { return x402; } +/** + * What doctor prints when the active chain's balance is empty and it cannot + * pay for its own analysis. Derived from the active payment chain: on Arc the + * empty balance is Arc USDC, so pointing the user at Base (or the Base bridge) + * would send their money to a chain this install is not paying on. + */ +export function emptyWalletGuidance( + wallet: Pick, +): string[] { + const lines = ["", "💳 Wallet is empty - cannot call AI for analysis."]; + if (wallet.paymentChain === "arc") { + lines.push( + ` Fund your EVM wallet with USDC on Arc (same address as Base): ${wallet.address}`, + " Send USDC on the Arc network only — USDC on Base does not fund Arc payments.", + " Or switch chains: npx @blockrun/clawrouter chain base", + "", + ); + return lines; + } + lines.push(` Fund your EVM wallet with USDC on Base: ${wallet.address}`); + if (wallet.solanaAddress) { + lines.push(` Fund your Solana wallet with USDC: ${wallet.solanaAddress}`); + } + lines.push( + " Get USDC: https://www.coinbase.com/price/usd-coin", + " Bridge to Base: https://bridge.base.org", + "", + ); + return lines; +} + // Send to AI for analysis async function analyzeWithAI( diagnostics: DiagnosticResult, @@ -550,13 +581,7 @@ async function analyzeWithAI( // Check if wallet has funds if (diagnostics.wallet.isEmpty) { - console.log("\n💳 Wallet is empty - cannot call AI for analysis."); - console.log(` Fund your EVM wallet with USDC on Base: ${diagnostics.wallet.address}`); - if (diagnostics.wallet.solanaAddress) { - console.log(` Fund your Solana wallet with USDC: ${diagnostics.wallet.solanaAddress}`); - } - console.log(" Get USDC: https://www.coinbase.com/price/usd-coin"); - console.log(" Bridge to Base: https://bridge.base.org\n"); + for (const line of emptyWalletGuidance(diagnostics.wallet)) console.log(line); return; } From 2fee8a13e824cf52718869e978185ca56f1918b2 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 28 Oct 2026 09:11:42 -0500 Subject: [PATCH 4/4] fix(arc): no Arc read on the API-key path; doctor hints name CLAWROUTER_PAYMENT_CHAIN - Desktop dashboard: Base/Solana are still read alongside the health probe, but the Arc read is added only after the proxy is known not to be on an API key. An API-key user with a saved EVM wallet and `arc` chain no longer waits on (or starts) an Arc gateway read whose result the response drops. - doctor: every chain-switch hint goes through chainSwitchInstruction(). While CLAWROUTER_PAYMENT_CHAIN is set it overrides the saved chain, so the hint says to set it to the wanted chain or unset it before switching. - Tests: API-key + saved arc + never-answering Arc gateway returns with no Arc call; chainSwitchInstruction with the env var unset/empty/set, and the Arc empty-wallet guidance under CLAWROUTER_PAYMENT_CHAIN=arc. --- apps/desktop/electron/core/manager.ts | 37 ++++++++++++++++++++------- apps/desktop/tests/arc-chain.test.ts | 17 ++++++++++++ src/doctor.arc-guidance.test.ts | 35 ++++++++++++++++++++++++- src/doctor.ts | 29 ++++++++++++++++++--- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/desktop/electron/core/manager.ts b/apps/desktop/electron/core/manager.ts index ec9dea46..8e517b7c 100644 --- a/apps/desktop/electron/core/manager.ts +++ b/apps/desktop/electron/core/manager.ts @@ -386,15 +386,16 @@ export class ClawRouterManager { const localWalletsPromise = this.localWallets(); const legacyWalletsPromise = localWalletsPromise.then((local) => this.legacyWallets(local)); const configuredChainPromise = this.configuredPaymentChain(); - // Arc is awaited only when it is the chain being paid on. Otherwise its - // balance is shown from cache and refreshed in the background, so a slow or - // unreachable arc.blockrun.ai never holds up a Base, Solana or API-key - // user's dashboard (which refreshes every 15s) for its 10s timeout. - const localBalancesPromise = Promise.all([localWalletsPromise, configuredChainPromise]).then( - ([{ base, solana }, chain]) => - this.fetchUsdcBalances(base, solana, { arc: chain === "arc" ? "await" : "background" }), + // Base and Solana are read alongside the health probe. Arc is NOT: it is + // added below only once the proxy is known not to be on an API key (that + // response discards every balance), and awaited only when Arc is the chain + // being paid on — otherwise shown from cache and refreshed in the + // background — so a slow or unreachable arc.blockrun.ai never holds up a + // Base, Solana or API-key dashboard (refreshed every 15s) for its 10s timeout. + const localBalancesPromise = localWalletsPromise.then(({ base, solana }) => + this.fetchUsdcBalances(base, solana, { arc: "skip" }), ); - const [reachable, configuredChain, localWallets, legacyWallets, localBalances] = + const [reachable, configuredChain, localWallets, legacyWallets, localBalancesWithoutArc] = await Promise.all([ proxyHealth(this.context), configuredChainPromise, @@ -408,7 +409,12 @@ export class ClawRouterManager { { arc: "skip" }, ); const legacyWalletDetails = withLegacyBalances(legacyWallets, legacyBalances); + const withLocalArc = async (arcActive: boolean) => ({ + ...localBalancesWithoutArc, + arc: await this.arcBalanceFor(localWallets.base, arcActive ? "await" : "background"), + }); if (!reachable) { + const localBalances = await withLocalArc(configuredChain === "arc"); return { proxy: { reachable: false, @@ -460,10 +466,12 @@ export class ClawRouterManager { const solana = activeSolana ?? localWallets.solana; const paymentChain = paymentChainOrUndefined(health.value?.paymentChain); const reportedBalance = currencyNumberOrUndefined(health.value?.balance); + const arcActive = paymentChain === "arc" || configuredChain === "arc"; + const localBalances = await withLocalArc(arcActive); const activeBalances = await this.fetchUsdcBalances( wallet && !sameAddress(wallet, localWallets.base) ? wallet : undefined, solana && solana !== localWallets.solana ? solana : undefined, - { arc: paymentChain === "arc" || configuredChain === "arc" ? "await" : "background" }, + { arc: arcActive ? "await" : "background" }, ); const balances: Partial> = { base: @@ -563,6 +571,17 @@ export class ClawRouterManager { return { base, arc, solana }; } + /** Arc USDC for the EVM address: awaited, or from cache with a background refresh. */ + private async arcBalanceFor( + address: string | undefined, + mode: "await" | "background", + ): Promise { + if (!address) return undefined; + return mode === "await" + ? this.fetchCachedBalance("arc", address) + : this.cachedBalanceRefreshingInBackground("arc", address); + } + /** * The last known balance (fresh or stale), without waiting. A stale or * missing entry starts one background read that fills the cache; its diff --git a/apps/desktop/tests/arc-chain.test.ts b/apps/desktop/tests/arc-chain.test.ts index 29958b61..98a6559d 100644 --- a/apps/desktop/tests/arc-chain.test.ts +++ b/apps/desktop/tests/arc-chain.test.ts @@ -309,4 +309,21 @@ describe("Arc balance never holds up a non-Arc dashboard", () => { expect(arc.arcCalls).toHaveLength(0); // no EVM wallet on disk, nothing to read arc.release(); }); + + it("an API-key proxy with a saved EVM wallet and arc chain never starts an Arc read", async () => { + const home = await homeWithEvmWallet("clawrouter-arc-apikey-wallet-", "arc"); + const arc = hangingArcFetcher(home, { + status: "ok", + authMode: "api-key", + wallet: "", + apiKey: "brk_live_…abcd", + gateway: "https://api.blockrun.ai", + }); + const manager = managerFor(home, arc.fetcher); + const dashboard = await manager.dashboard(); // would hang if Arc were awaited + expect(dashboard.proxy).toMatchObject({ reachable: true, authMode: "api-key" }); + await manager.settleBackgroundBalanceReads(); + expect(arc.arcCalls).toHaveLength(0); // not awaited, not started in the background either + arc.release(); + }); }); diff --git a/src/doctor.arc-guidance.test.ts b/src/doctor.arc-guidance.test.ts index d8e2f61e..e776512c 100644 --- a/src/doctor.arc-guidance.test.ts +++ b/src/doctor.arc-guidance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { emptyWalletGuidance } from "./doctor.js"; +import { chainSwitchInstruction, emptyWalletGuidance } from "./doctor.js"; const EVM = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; const SOL = "7Xkr1111111111111111111111111111111111111xyz"; @@ -38,3 +38,36 @@ describe("doctor empty-wallet guidance follows the active chain", () => { expect(text).toContain(`Fund your Solana wallet with USDC: ${SOL}`); }); }); + +describe("doctor chain-switch hints respect CLAWROUTER_PAYMENT_CHAIN", () => { + it("gives the plain command when the env var is unset or empty", () => { + expect(chainSwitchInstruction("base", "run chain base", undefined)).toBe("run chain base"); + expect(chainSwitchInstruction("base", "run chain base", " ")).toBe("run chain base"); + }); + + it("names the env var when it is set, since a saved chain would not take effect", () => { + const hint = chainSwitchInstruction("solana", "run /wallet solana", "arc"); + expect(hint).toContain("set CLAWROUTER_PAYMENT_CHAIN=solana"); + expect(hint).toContain('"arc"'); + expect(hint).toContain("unset CLAWROUTER_PAYMENT_CHAIN and run /wallet solana"); + }); + + it("the Arc empty-wallet guidance uses it", () => { + const saved = process.env.CLAWROUTER_PAYMENT_CHAIN; + process.env.CLAWROUTER_PAYMENT_CHAIN = "arc"; + try { + const text = emptyWalletGuidance({ + paymentChain: "arc", + address: EVM, + solanaAddress: null, + }).join("\n"); + expect(text).toContain("set CLAWROUTER_PAYMENT_CHAIN=base"); + expect(text).toContain( + "unset CLAWROUTER_PAYMENT_CHAIN and npx @blockrun/clawrouter chain base", + ); + } finally { + if (saved === undefined) delete process.env.CLAWROUTER_PAYMENT_CHAIN; + else process.env.CLAWROUTER_PAYMENT_CHAIN = saved; + } + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts index ee9797a9..a8dbdfce 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -359,7 +359,9 @@ function identifyIssues(result: DiagnosticResult): string[] { const chain = PAYMENT_CHAIN_LABELS[result.wallet.paymentChain]; issues.push(`Wallet is empty - need to fund with USDC on ${chain}`); if (result.wallet.paymentChain === "base" && result.wallet.solanaAddress) { - issues.push("Tip: if you funded Solana, run /wallet solana to switch chains"); + issues.push( + `Tip: if you funded Solana, ${chainSwitchInstruction("solana", "run /wallet solana")} to switch chains`, + ); } } else if (result.wallet.isLow) { issues.push("Wallet balance is low (< $1.00)"); @@ -444,7 +446,9 @@ function printDiagnostics(result: DiagnosticResult): void { ` ${red(`Balance: $0.00 - NEED TO FUND WITH USDC ON ${chainLabel.toUpperCase()}!`)}`, ); if (result.wallet.paymentChain === "base" && result.wallet.solanaAddress) { - console.log(` ${yellow(`Tip: funded Solana instead? Run /wallet solana to switch`)}`); + console.log( + ` ${yellow(`Tip: funded Solana instead? ${chainSwitchInstruction("solana", "run /wallet solana")} to switch`)}`, + ); } } else if (result.wallet.isLow) { console.log(` ${yellow(`Balance: ${result.wallet.balance} (low)`)}`); @@ -533,6 +537,25 @@ export function createDoctorX402Client(opts: { return x402; } +/** + * How to switch to `target`, for every chain-switch hint doctor prints. + * CLAWROUTER_PAYMENT_CHAIN overrides the saved chain (resolvePaymentChain), so + * while it is set, `chain base` / `/wallet solana` saves a choice that never + * takes effect — the hint has to name the variable instead. + */ +export function chainSwitchInstruction( + target: PaymentChain, + command: string, + envValue: string | undefined = process["env"].CLAWROUTER_PAYMENT_CHAIN, +): string { + const env = envValue?.trim(); + if (!env) return command; + return ( + `set CLAWROUTER_PAYMENT_CHAIN=${target} (it is "${env}" and overrides the saved chain), ` + + `or unset CLAWROUTER_PAYMENT_CHAIN and ${command}` + ); +} + /** * What doctor prints when the active chain's balance is empty and it cannot * pay for its own analysis. Derived from the active payment chain: on Arc the @@ -547,7 +570,7 @@ export function emptyWalletGuidance( lines.push( ` Fund your EVM wallet with USDC on Arc (same address as Base): ${wallet.address}`, " Send USDC on the Arc network only — USDC on Base does not fund Arc payments.", - " Or switch chains: npx @blockrun/clawrouter chain base", + ` Or switch chains: ${chainSwitchInstruction("base", "npx @blockrun/clawrouter chain base")}`, "", ); return lines;