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/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..8e517b7c 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; @@ -44,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(); @@ -169,7 +182,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 +278,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 +293,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 +324,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 { @@ -371,13 +385,20 @@ export class ClawRouterManager { const root = this.context.proxyBaseUrl.replace(/\/v1\/?$/, ""); const localWalletsPromise = this.localWallets(); const legacyWalletsPromise = localWalletsPromise.then((local) => this.legacyWallets(local)); + const configuredChainPromise = this.configuredPaymentChain(); + // 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), + this.fetchUsdcBalances(base, solana, { arc: "skip" }), ); - const [reachable, configuredChain, localWallets, legacyWallets, localBalances] = + const [reachable, configuredChain, localWallets, legacyWallets, localBalancesWithoutArc] = await Promise.all([ proxyHealth(this.context), - this.configuredPaymentChain(), + configuredChainPromise, localWalletsPromise, legacyWalletsPromise, localBalancesPromise, @@ -385,9 +406,15 @@ export class ClawRouterManager { const legacyBalances = await this.fetchUsdcBalances( legacyWallets.base?.address, legacyWallets.solana?.address, + { 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, @@ -439,15 +466,23 @@ 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: arcActive ? "await" : "background" }, ); const balances: Partial> = { base: 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 +491,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,31 +538,81 @@ 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). `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( - baseAddress: string | undefined, + evmAddress: string | undefined, solanaAddress: string | undefined, + { arc: arcMode }: { arc: "await" | "background" | "skip" }, ): 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 || 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, solana }; + 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 + * 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 = chain === "base" ? 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; @@ -534,7 +620,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 +652,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 +807,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 +830,34 @@ 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 + * 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 +949,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..98a6559d --- /dev/null +++ b/apps/desktop/tests/arc-chain.test.ts @@ -0,0 +1,329 @@ +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"); + }); +}); + +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(); + }); + + 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/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.arc-guidance.test.ts b/src/doctor.arc-guidance.test.ts new file mode 100644 index 00000000..e776512c --- /dev/null +++ b/src/doctor.arc-guidance.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { chainSwitchInstruction, 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}`); + }); +}); + +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 10cf8e1b..a8dbdfce 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, @@ -43,7 +48,7 @@ interface SystemInfo { memoryTotal: string; } -interface WalletInfo { +export interface WalletInfo { exists: boolean; valid: boolean; address: string | null; @@ -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,10 +356,12 @@ 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"); + 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)"); @@ -427,14 +439,16 @@ 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( ` ${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)`)}`); @@ -523,6 +537,56 @@ 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 + * 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: ${chainSwitchInstruction("base", "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, @@ -540,13 +604,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; } @@ -581,10 +639,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..87538bfe --- /dev/null +++ b/src/payment-chain.ts @@ -0,0 +1,70 @@ +/** + * 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 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); +} + +/** 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", 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}`);