Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 17 additions & 75 deletions lib/commands/fund.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -68,7 +66,7 @@ the unified balance spendable from any supported chain).

Options:
--amount <usd> 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 <name> Source chain (default prompt: base)
--method <m> direct | eco (default: direct; both are gasless — eco is
Eco Fast Deposits from Base: faster credit, small fixed
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.`
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions lib/url-safety.mjs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
81 changes: 0 additions & 81 deletions test/fund-arc.test.mjs

This file was deleted.

11 changes: 11 additions & 0 deletions test/fund-method-flag.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 3 additions & 5 deletions test/fund-min-amount.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
5 changes: 3 additions & 2 deletions test/fund-unified-balance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions test/per-chain-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
);
});

Expand Down
16 changes: 0 additions & 16 deletions test/url-safety.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" });
});
Loading