diff --git a/lib/commands/fund.mjs b/lib/commands/fund.mjs index 2f443c5..4ceadcc 100644 --- a/lib/commands/fund.mjs +++ b/lib/commands/fund.mjs @@ -23,7 +23,6 @@ import { join } from "node:path"; import { sh } from "../sh.mjs"; import { fmt, prompt, promptYesNo, stdinIsInteractive } from "../ui.mjs"; -import { safeHttpUrl } from "../url-safety.mjs"; import { findSkill, skillInstallLines } from "../skill.mjs"; import { getAgentAddress, @@ -37,7 +36,6 @@ import { TARGET_USDC_CHAINS } from "../circle.mjs"; import { invalidateCircleCache } from "../circle-cache.mjs"; -import { readConfig, configPath } from "../config.mjs"; import { sessionSpendLine } from "./budget.mjs"; import { frozenMoneyMove } from "./freeze.mjs"; import { FUND_QR_CHAINS, usdcTransferUri, writeFundQr } from "../qr.mjs"; @@ -68,7 +66,7 @@ the unified balance spendable from any supported chain). Options: --amount USDC to deposit (default prompt: 2; min 0.5 — Circle - CLI's Gateway deposit floor; Arc raw-key deposits exempt) + CLI's Gateway deposit floor) --chain Source chain (default prompt: base) --method direct | eco (default: direct; both are gasless — eco is Eco Fast Deposits from Base: faster credit, small fixed @@ -161,12 +159,9 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) { } const amount = Number(amountArg); - // Arc deposits bypass the Circle CLI (raw-key path), so only the - // positive-number rule applies there; every other chain inherits the CLI's - // 0.5 USDC floor — checked here so it fails before prompts and plans, not - // as a raw CLI error mid-flow. - const isArc = chainArg.toLowerCase() === "arc"; - const amountError = depositAmountError(amount, { isArc }); + // Every chain inherits the Circle CLI's 0.5 USDC floor — checked here so it + // fails before prompts and plans, not as a raw CLI error mid-flow. + const amountError = depositAmountError(amount); if (amountError) { console.error(fmt.error(amountError)); return 1; @@ -182,37 +177,19 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) { return 1; } - // Arc mainnet can't use the Circle agent wallet (the Circle CLI only knows - // ARC-TESTNET), so the skill deposits to Arc with a raw EOA key + a private - // RPC. Resolve those here (shell env wins, then the selat config .env) and - // pass them through to setup.mjs. - let depositEnv; - if (isArc) { - const res = resolveArcDepositEnv({ method: method.value, config: await readConfig() }); - if (!res.ok) { - console.error(fmt.error(res.error)); - if (res.missing) { - console.error(fmt.dim("Arc mainnet can't use the Circle agent wallet — deposits use a raw EOA key + your private Arc RPC.")); - console.error(fmt.dim(`Set them in your shell or ${configPath()}.`)); - } - return 1; - } - depositEnv = res.env; - } - // Resolve the payer wallet once: the spending-policy line, the empty-wallet // check, the pre-deposit baseline, and the post-deposit unified-balance - // read all key off it. Arc deposits sign with a raw EOA instead, so the - // agent address is only used there for the Gateway (unified balance) reads. + // read all key off it. Every chain — Arc included since Circle CLI 1.1.1 — + // deposits from this agent wallet; there is no local-key path. const walletAddr = await getAgentAddress().catch(() => null); // Empty-wallet branch: a Gateway deposit needs on-chain USDC to move. If // the wallet doesn't hold enough on the deposit chain, the skill's deposit // would only fail later with an opaque error — offer the Circle CLI's QR // funding flow (EIP-681; the user pays from an external wallet, nothing is - // signed here) instead. Skipped on Arc (raw-EOA path, no Circle CLI chain - // code) and when the balance can't be read (never block on a read failure). - if (!isArc && walletAddr) { + // signed here) instead. Skipped when the balance can't be read (never block + // on a read failure). + if (walletAddr) { const chainCode = circleChainCode(chainArg); const onchain = chainCode ? await walletUsdcBalance(walletAddr, chainCode) : null; if (chainCode && onchain == null) { @@ -363,7 +340,7 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) { "--amount", String(amount), "--confirm", phrase ], - { inherit: true, ...(depositEnv ? { env: depositEnv } : {}) } + { inherit: true } )).code; if (depositCode !== 0) return depositCode; @@ -467,15 +444,14 @@ function parseMethod(method) { /** * Circle CLI ≥1.0.0 refuses `gateway deposit` below 0.5 USDC (any method, * any chain). Mirrored here so the refusal happens before the confirm flow - * with SELAT wording. Arc raw-key deposits don't go through the CLI and are - * exempt. Pure; returns an error string or null. + * with SELAT wording. Pure; returns an error string or null. */ export const MIN_GATEWAY_DEPOSIT_USDC = 0.5; -export function depositAmountError(amount, { isArc = false } = {}) { +export function depositAmountError(amount) { if (!Number.isFinite(amount) || amount <= 0) { return "--amount must be a positive number"; } - if (!isArc && amount < MIN_GATEWAY_DEPOSIT_USDC) { + if (amount < MIN_GATEWAY_DEPOSIT_USDC) { return ( `Circle CLI enforces a ${MIN_GATEWAY_DEPOSIT_USDC} USDC minimum for Gateway deposits — ` + `${amount} USDC is below the floor. Re-run with --amount ${MIN_GATEWAY_DEPOSIT_USDC} or more.` @@ -537,10 +513,11 @@ async function ecoPolygonPolicyNudge(walletAddr) { /** * Map a selat chain key ("base", "polygon", …) to the Circle CLI --chain code * ("BASE", "MATIC", …). Extends TARGET_USDC_CHAINS with the other Gateway EVM - * mainnets the Circle CLI knows. Returns null for chains the Circle CLI can't - * address (e.g. Arc mainnet), so callers skip Circle-CLI-only branches. + * mainnets the Circle CLI knows (Arc mainnet since CLI 1.1.1). Returns null + * for chains the Circle CLI can't address, so callers skip Circle-CLI-only + * branches. */ -const EXTRA_CHAIN_CODES = { ethereum: "ETH", avalanche: "AVAX", unichain: "UNI" }; +const EXTRA_CHAIN_CODES = { ethereum: "ETH", avalanche: "AVAX", unichain: "UNI", arc: "ARC" }; export function circleChainCode(chainKey, { chains = TARGET_USDC_CHAINS } = {}) { const key = String(chainKey ?? "").trim().toLowerCase(); if (!key) return null; @@ -797,41 +774,6 @@ export function fundingDetailLines({ address, chainKey, shortfall, uri, chains = return lines; } -/** - * Resolve the env a `--chain arc` deposit needs to pass through to setup.mjs. - * - * Arc mainnet can't use the Circle agent wallet, so the skill deposits with a - * raw EOA key + a private RPC: SELAT_PRIVATE_KEY and ARC_RPC_URL. Shell env - * wins over the selat config .env. Eco (fast deposits) isn't supported on Arc. - * - * Returns `{ ok: true, env }` or `{ ok: false, error, missing? }`. `missing` - * is only set when the failure is unset credentials (so the caller can print - * the how-to-fix hint); a rejected method has no `missing`. - */ -export function resolveArcDepositEnv({ method, config = {}, env = process.env } = {}) { - if (method === "eco") { - return { ok: false, error: "--method eco is not supported on Arc; use the default (direct)." }; - } - const privateKey = env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY; - const rpcUrl = env.ARC_RPC_URL || config.ARC_RPC_URL; - const missing = []; - if (!privateKey) missing.push("SELAT_PRIVATE_KEY"); - if (!rpcUrl) missing.push("ARC_RPC_URL"); - if (missing.length) { - return { ok: false, error: `Arc deposits need ${missing.join(" and ")}.`, missing }; - } - // The raw key signs a real transfer against whatever this RPC says the chain - // state is, so a plaintext or non-http(s) endpoint is refused: an attacker on - // the path could feed the deposit a forged nonce/receipt or observe it. - if (!safeHttpUrl(rpcUrl)) { - return { - ok: false, - error: `ARC_RPC_URL "${rpcUrl}" must be an https:// URL (http:// is allowed only for localhost).` - }; - } - return { ok: true, env: { SELAT_PRIVATE_KEY: privateKey, ARC_RPC_URL: rpcUrl } }; -} - /** * Resolve the onramp destination wallet: --address <0x…> when given (any agent * wallet — live Hermes feedback: users fund wallet #1 while wallet #3 is the diff --git a/lib/url-safety.mjs b/lib/url-safety.mjs index d78df47..e7ed98f 100644 --- a/lib/url-safety.mjs +++ b/lib/url-safety.mjs @@ -1,7 +1,6 @@ /** * Shared URL safety checks for every destination this CLI can be pointed at: - * skill step urls, catalog serviceUrls, exec hints, the SELAT Router, and the - * Arc RPC endpoint used for raw-key deposits. + * skill step urls, catalog serviceUrls, exec hints, and the SELAT Router. * * All of those carry money or a signature, so the destination must be a * parseable https:// URL (http:// only for loopback, for local development). diff --git a/test/fund-arc.test.mjs b/test/fund-arc.test.mjs deleted file mode 100644 index f2e5944..0000000 --- a/test/fund-arc.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { resolveArcDepositEnv } from "../lib/commands/fund.mjs"; - -// Arc mainnet can't use the Circle agent wallet, so `selat fund --chain arc` -// deposits with a raw EOA key + a private RPC. These pin that the env those -// deposits sign with (SELAT_PRIVATE_KEY / ARC_RPC_URL) is resolved with shell -// env winning over the selat config .env, that eco is rejected, and that a -// missing credential fails loudly rather than silently falling back to the -// agent wallet. See the Arc raw-key deposit path (agent-payments). - -const KEY = "0x" + "ab".repeat(32); -const RPC = "https://example.arc-mainnet.invalid/token"; - -test("resolves from shell env", () => { - const res = resolveArcDepositEnv({ - method: "direct", - config: {}, - env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, - }); - assert.deepEqual(res, { ok: true, env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC } }); -}); - -test("falls back to the selat config when shell env is unset", () => { - const res = resolveArcDepositEnv({ - method: "direct", - config: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, - env: {}, - }); - assert.ok(res.ok); - assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }); -}); - -test("shell env wins over the config .env", () => { - const res = resolveArcDepositEnv({ - method: "direct", - config: { SELAT_PRIVATE_KEY: "0xconfig", ARC_RPC_URL: "https://config.invalid" }, - env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, - }); - assert.ok(res.ok); - assert.equal(res.env.SELAT_PRIVATE_KEY, KEY); - assert.equal(res.env.ARC_RPC_URL, RPC); -}); - -test("rejects eco (gasless) on Arc before checking credentials", () => { - const res = resolveArcDepositEnv({ - method: "eco", - config: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, - env: {}, - }); - assert.equal(res.ok, false); - assert.match(res.error, /eco is not supported on Arc/); - assert.equal(res.missing, undefined); -}); - -test("fails loudly when both credentials are missing", () => { - const res = resolveArcDepositEnv({ method: "direct", config: {}, env: {} }); - assert.equal(res.ok, false); - assert.deepEqual(res.missing, ["SELAT_PRIVATE_KEY", "ARC_RPC_URL"]); - assert.match(res.error, /SELAT_PRIVATE_KEY and ARC_RPC_URL/); -}); - -test("names just the one missing credential", () => { - const res = resolveArcDepositEnv({ - method: "direct", - config: {}, - env: { SELAT_PRIVATE_KEY: KEY }, - }); - assert.equal(res.ok, false); - assert.deepEqual(res.missing, ["ARC_RPC_URL"]); -}); - -test("a valueless --method is an error, not a silent direct deposit", async () => { - // `selat fund --amount 5 --yes --method` (value forgotten) used to default - // to "direct" — a gas-requiring deposit — with --yes skipping the one - // confirm screen that would have caught it. - const { fund } = await import("../lib/commands/fund.mjs"); - const code = await fund(["--amount", "5", "--yes", "--method"]); - assert.equal(code, 1); -}); diff --git a/test/fund-method-flag.test.mjs b/test/fund-method-flag.test.mjs new file mode 100644 index 0000000..5857345 --- /dev/null +++ b/test/fund-method-flag.test.mjs @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("a valueless --method is an error, not a silent direct deposit", async () => { + // `selat fund --amount 5 --yes --method` (value forgotten) used to default + // to "direct" — a gas-requiring deposit — with --yes skipping the one + // confirm screen that would have caught it. + const { fund } = await import("../lib/commands/fund.mjs"); + const code = await fund(["--amount", "5", "--yes", "--method"]); + assert.equal(code, 1); +}); diff --git a/test/fund-min-amount.test.mjs b/test/fund-min-amount.test.mjs index a922d5f..4feb267 100644 --- a/test/fund-min-amount.test.mjs +++ b/test/fund-min-amount.test.mjs @@ -30,9 +30,7 @@ test("depositAmountError keeps the positive-number rule first", () => { assert.equal(depositAmountError(NaN), "--amount must be a positive number"); }); -test("Arc raw-key deposits are exempt from the CLI floor but not the positive rule", () => { - // Arc bypasses the Circle CLI entirely (raw EOA key + private RPC), so the - // CLI's floor must not block it. - assert.equal(depositAmountError(0.25, { isArc: true }), null); - assert.equal(depositAmountError(0, { isArc: true }), "--amount must be a positive number"); +test("no chain is exempt from the CLI floor (Arc deposits go through the CLI too)", () => { + assert.match(depositAmountError(0.25), /0\.5 USDC minimum/); + assert.equal(depositAmountError(0), "--amount must be a positive number"); }); diff --git a/test/fund-unified-balance.test.mjs b/test/fund-unified-balance.test.mjs index 823b34e..4427c17 100644 --- a/test/fund-unified-balance.test.mjs +++ b/test/fund-unified-balance.test.mjs @@ -62,8 +62,9 @@ test("chain codes are case/whitespace tolerant", () => { }); test("returns null for chains the Circle CLI can't address", () => { - // Arc mainnet deposits use the raw-EOA path; the QR branch must not fire. - assert.equal(circleChainCode("arc"), null); + // Arc mainnet is ARC since Circle CLI 1.1.1 — the QR / balance branches + // fire there like on any other chain. Solana is still not a Circle chain. + assert.equal(circleChainCode("arc"), "ARC"); assert.equal(circleChainCode("solana"), null); assert.equal(circleChainCode(null), null); assert.equal(circleChainCode(""), null); diff --git a/test/per-chain-policy.test.mjs b/test/per-chain-policy.test.mjs index a5aa24d..1dd1323 100644 --- a/test/per-chain-policy.test.mjs +++ b/test/per-chain-policy.test.mjs @@ -152,11 +152,12 @@ test("fund's plan line shows the policy governing the DEPOSIT chain", () => { policyPlanLine({ chainKey: "base", policy: CUSTOM_ROW }), "on base: capped at $5/tx · $50/day · $200/wk · $500/mo (your custom caps)" ); - // Arc has no Circle chain code → the read degrades to unknown, named as such - assert.equal(circleChainCode("arc"), null); + // A chain whose policy can't be read degrades to unknown, named as such + // (Solana has no Circle chain code; Arc does since CLI 1.1.1). + assert.equal(circleChainCode("solana"), null); assert.equal( - policyPlanLine({ chainKey: "arc", policy: { readable: false, chain: null } }), - "on arc: unknown (could not read this chain's policy)" + policyPlanLine({ chainKey: "solana", policy: { readable: false, chain: null } }), + "on solana: unknown (could not read this chain's policy)" ); }); diff --git a/test/url-safety.test.mjs b/test/url-safety.test.mjs index 92e5c76..8ffaee6 100644 --- a/test/url-safety.test.mjs +++ b/test/url-safety.test.mjs @@ -3,7 +3,6 @@ import assert from "node:assert/strict"; import { assertSafeHttpUrl, isLoopbackHost, safeHttpUrl } from "../lib/url-safety.mjs"; import { probeArgvForPlan } from "../lib/compare.mjs"; -import { resolveArcDepositEnv } from "../lib/commands/fund.mjs"; import { parseSelatPayHint } from "../lib/commands/run.mjs"; test("safeHttpUrl accepts https and loopback http only", () => { @@ -62,18 +61,3 @@ test("parseSelatPayHint rejects a hint whose payment url is not https", () => { assert.equal(parseSelatPayHint(hint("https://api.example.com/paid")).ok, true); assert.equal(parseSelatPayHint(hint("http://localhost:4000/paid")).ok, true); }); - -test("resolveArcDepositEnv rejects a plaintext ARC_RPC_URL", () => { - const env = { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "http://rpc.evil.example" }; - const res = resolveArcDepositEnv({ method: "direct", env }); - assert.equal(res.ok, false); - assert.match(res.error, /must be an https:\/\/ URL/); - assert.equal(res.missing, undefined); -}); - -test("resolveArcDepositEnv accepts an https ARC_RPC_URL", () => { - const env = { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }; - const res = resolveArcDepositEnv({ method: "direct", env }); - assert.equal(res.ok, true); - assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }); -});