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
6 changes: 4 additions & 2 deletions bin/selat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { skill } from "../lib/commands/skill.mjs";
import { refund } from "../lib/commands/refund.mjs";
import { fmt } from "../lib/ui.mjs";
import { ensureHarnessPath } from "../lib/host.mjs";
import { serializeError } from "../lib/redact.mjs";

const USAGE = `${fmt.bold("selat")} — agent payment setup helper

Expand Down Expand Up @@ -130,7 +131,8 @@ main(process.argv)
// flush both streams and exit when the event loop drains.
.then((code) => { process.exitCode = code ?? 0; })
.catch((err) => {
console.error(fmt.error(`fatal: ${err?.message ?? err}`));
if (process.env.SELAT_DEBUG === "1" && err?.stack) console.error(err.stack);
const safe = serializeError(err);
console.error(fmt.error(`fatal: ${safe.message}`));
if (process.env.SELAT_DEBUG === "1" && safe.stack) console.error(safe.stack);
process.exitCode = 1;
});
68 changes: 68 additions & 0 deletions lib/arc-fund-signer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Arc fund signer — in-process secret load, key-blind wire type.
*
* See-boundary (CTO+Security, SELAT-AI/selat-cli#189): this module MAY see
* the raw key in memory for the sign call. The return value is the ship
* contract: `{ signature, address }` or `{ signature, fingerprint }`. No
* `key` / `privateKey` / `mnemonic` field. The key must not be threaded
* into the router, quotes, claims, receipts, or CLI dumps.
*/

import { createHash } from "node:crypto";
import { serializeError, withoutKeyFields } from "./redact.mjs";

/** 0x-prefixed 32-byte secp256k1 private key. */
export const ARC_PRIVATE_KEY_HEX = /^0x[0-9a-fA-F]{64}$/;

/**
* Identity surface when an address is not yet derived: SHA-256 of the
* secret, first 6 + last 4 hex chars. Not reversible to the key, and not
* the key itself.
*/
export function maskedFingerprint(secret) {
const hex = createHash("sha256").update(String(secret ?? ""), "utf8").digest("hex");
return `${hex.slice(0, 6)}…${hex.slice(-4)}`;
}

/**
* The only wire shape this signer is allowed to return. Extra fields —
* especially `key` / `privateKey` / `mnemonic` — are dropped, not copied.
*/
export function toArcFundSignerWire(result = {}) {
const cleaned = withoutKeyFields(result ?? {});
const out = {};
if (cleaned.signature != null) out.signature = cleaned.signature;
if (cleaned.address != null) out.address = cleaned.address;
else if (cleaned.fingerprint != null) out.fingerprint = cleaned.fingerprint;
return out;
}

/**
* Sign `digest` in-process. `privateKey` is an argument (secret-load path)
* and is never copied onto the returned object. `sign` is injectable so
* tests pin the wire shape without a live chain or viem.
*
* Returns `{ signature, address }` when `address` is provided, otherwise
* `{ signature, fingerprint }`.
*/
export async function signArcFund({ digest, privateKey, address, sign } = {}) {
const secrets = typeof privateKey === "string" && privateKey.length >= 8 ? [privateKey] : [];
if (typeof sign !== "function") {
throw new Error("Arc fund signer requires an in-process sign function");
}
let signature;
try {
signature = await sign(digest, privateKey);
} catch (err) {
const safe = serializeError(err, { secrets });
const wrapped = new Error(safe.message);
wrapped.name = safe.name;
if (safe.code != null) wrapped.code = safe.code;
throw wrapped;
}
return toArcFundSignerWire({
signature,
address: address || undefined,
fingerprint: address ? undefined : maskedFingerprint(privateKey)
});
}
96 changes: 77 additions & 19 deletions lib/commands/fund.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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 { maskedFingerprint, ARC_PRIVATE_KEY_HEX } from "../arc-fund-signer.mjs";
import { redactText } from "../redact.mjs";
import {
getAgentAddress,
authStatus,
Expand Down Expand Up @@ -91,7 +93,7 @@ asks you to confirm before depositing. (--onramp is the exception: it only
mints a browser link; the purchase happens in Circle's widget.)
\`selat freeze\` is a local kill switch: this command refuses while frozen.`;

export async function fund(args, { interactive = stdinIsInteractive() } = {}) {
export async function fund(args, { interactive = stdinIsInteractive(), run = sh, stdout = process.stdout, stderr = process.stderr } = {}) {
// Help is inert: no skill lookup, no prompts, no deposit flow. Previously
// `fund --help` fell through and started prompting toward a real deposit —
// the defect class #101 fixed for setup-policy, one command over.
Expand Down Expand Up @@ -179,17 +181,22 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) {
// RPC. Resolve those here (shell env wins, then the selat config .env) and
// pass them through to setup.mjs.
let depositEnv;
let arcSecrets = [];
if (isArc) {
const res = resolveArcDepositEnv({ method: method.value, config: await readConfig() });
if (!res.ok) {
console.error(fmt.error(res.error));
console.error(fmt.error(redactText(res.error, arcSecrets)));
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;
// Child overlay carries the key for the in-process sign path in
// setup.mjs. Never dump this object — identity on the resolve result is
// the fingerprint. Arc fund key does not enter the router.
depositEnv = arcDepositSpawnEnv(res);
if (depositEnv?.SELAT_PRIVATE_KEY) arcSecrets = [depositEnv.SELAT_PRIVATE_KEY];
}

// Resolve the payer wallet once: the spending-policy line, the empty-wallet
Expand Down Expand Up @@ -346,17 +353,28 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) {
// deposit raises the balance, so unlike a payment it cannot be accounted for
// conservatively — only a fresh read is safe.
invalidateCircleCache(GATEWAY_BALANCE_KEY_PREFIX);
const depositCode = (await sh(
// Arc: capture + redact child streams so a skill that echoes the key
// cannot leak it onto CLI stdout/stderr. Other chains inherit so Circle
// signing prompts still reach the terminal.
const depositArgs = [
join(skill.path, "scripts", "setup.mjs"),
skillCommand,
"--chain", chainArg,
"--amount", String(amount),
"--confirm", phrase
];
const depositResult = await run(
"node",
[
join(skill.path, "scripts", "setup.mjs"),
skillCommand,
"--chain", chainArg,
"--amount", String(amount),
"--confirm", phrase
],
{ inherit: true, ...(depositEnv ? { env: depositEnv } : {}) }
)).code;
depositArgs,
isArc
? { inherit: false, env: depositEnv }
: { inherit: true, ...(depositEnv ? { env: depositEnv } : {}) }
);
if (isArc) {
reprintRedacted(depositResult.stdout, stdout, arcSecrets);
reprintRedacted(depositResult.stderr, stderr, arcSecrets);
}
const depositCode = depositResult.code;
if (depositCode !== 0) return depositCode;

if (!walletAddr) {
Expand Down Expand Up @@ -796,22 +814,36 @@ export function fundingDetailLines({ address, chainKey, shortfall, uri, chains =
* 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`.
* The returned object is a key-blind wire type: `{ ok, env: { ARC_RPC_URL },
* fingerprint }`. The raw key is held in an in-process WeakMap and applied
* to the child env only via `arcDepositSpawnEnv` — JSON.stringify / env dumps
* of this result never include it. Identity is the masked fingerprint.
*
* Returns `{ ok: true, env, fingerprint }` 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`.
*/
const arcFundKeys = new WeakMap();

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 privateKey = String(env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY || "").trim();
const rpcUrl = String(env.ARC_RPC_URL || config.ARC_RPC_URL || "").trim();
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 };
}
if (!ARC_PRIVATE_KEY_HEX.test(privateKey)) {
// Do not echo the value — a malformed key still must stay off logs.
return {
ok: false,
error: "SELAT_PRIVATE_KEY must be a 0x-prefixed 32-byte hex key."
};
}
// 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.
Expand All @@ -821,7 +853,33 @@ export function resolveArcDepositEnv({ method, config = {}, env = process.env }
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 } };
const result = {
ok: true,
env: { ARC_RPC_URL: rpcUrl },
fingerprint: maskedFingerprint(privateKey)
};
arcFundKeys.set(result, privateKey);
return result;
}

/**
* Child-process env overlay for an Arc deposit. In-process only — the key
* is here so setup.mjs can sign, and must not be JSON.stringified, logged,
* or forwarded to the router. Returns null when `resolved` is not an ok
* `resolveArcDepositEnv` result.
*/
export function arcDepositSpawnEnv(resolved) {
if (!resolved?.ok) return null;
const privateKey = arcFundKeys.get(resolved);
const rpcUrl = resolved.env?.ARC_RPC_URL;
if (!privateKey || !rpcUrl) return null;
return { SELAT_PRIVATE_KEY: privateKey, ARC_RPC_URL: rpcUrl };
}

/** Reprint a captured child stream with known secrets stripped. */
export function reprintRedacted(text, stream = process.stdout, secrets = []) {
if (!text) return;
stream.write(redactText(text, secrets));
}

/**
Expand Down
Loading