diff --git a/.env.example b/.env.example index 8bc956a..e2a6b8c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ PIMLICO_SPONSORSHIP_POLICY_ID= # dry-run -> never broadcast, always simulate (good for dev) # native -> you-pay-gas even if a sponsorship policy is set # sponsored -> use the key's default policy (no explicit id) +# Any other value stops startup instead of guessing (a typo of dry-run must not broadcast). # WDK_GAS_MODE= # ─── Local AI brain (QVAC) ─────────────────────────────────────────────────── diff --git a/src/cli.mjs b/src/cli.mjs index 238bd1b..171143f 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -529,6 +529,13 @@ async function main() { // inherits fd 0 when it spawns; by then the input must already be ours. if (SCRIPTED) scriptLines = await readScriptLines(); + // An unrecognised gas mode stops the agent before anything is shown or initialised: the + // operator asked for a mode, and none of the three is a safe guess at what they meant. + if (config.gasModeError) { + println(c.red(` gas mode: ${config.gasModeError}`) + "\n"); + process.exit(1); + } + banner(); // A malformed policy must stop the agent before any send, never be ignored. diff --git a/src/config.mjs b/src/config.mjs index b464dd0..7936175 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -58,7 +58,16 @@ if (process.env.RESERVOIR_API_URL) chain.reservoirUrl = process.env.RESERVOIR_AP const pimlicoKey = process.env.PIMLICO_API_KEY || ""; const sponsorshipPolicyId = process.env.PIMLICO_SPONSORSHIP_POLICY_ID || ""; -const gasOverride = (process.env.WDK_GAS_MODE || "").toLowerCase(); +// WDK_GAS_MODE takes the three values .env.example documents. Anything else is refused at +// startup instead of read as "sponsored": with a Pimlico key that fallthrough broadcast, so a +// typo of dry-run was the thing that turned broadcasting on. +const GAS_MODES = ["dry-run", "native", "sponsored"]; +const gasOverride = (process.env.WDK_GAS_MODE || "").trim().toLowerCase(); +const gasModeError = + gasOverride && !GAS_MODES.includes(gasOverride) + ? `WDK_GAS_MODE=${JSON.stringify(process.env.WDK_GAS_MODE)} is not a gas mode: ` + + `use dry-run, native or sponsored, or leave it empty to auto-detect` + : null; // NFT reads (get_nfts) go through Reservoir's indexed API, not raw eth_getLogs: // Monad prunes historical state, so Transfer-event scanning is unreliable. The host @@ -72,7 +81,9 @@ const reservoirUrl = chain.reservoirUrl; // sponsored -> gasless via Pimlico paymaster (agent pays 0) // native -> you-pay-gas in MON (still needs a bundler = Pimlico key) let gasMode; -if (gasOverride === "dry-run" || !pimlicoKey) gasMode = "dry-run"; +// A refused value resolves to dry-run as well, so an entry point that never looks at +// gasModeError still cannot broadcast on it. +if (gasModeError || gasOverride === "dry-run" || !pimlicoKey) gasMode = "dry-run"; else if (gasOverride === "native") gasMode = "native"; else gasMode = "sponsored"; @@ -151,6 +162,8 @@ export const config = { // Convenience flag for guardrails: mainnet moves real funds. isMainnet: network === "mainnet", gasMode, + // Set when WDK_GAS_MODE holds an unrecognised value; the CLI refuses to start on it. + gasModeError, slippagePercent, sponsorshipPolicyId, // ERC-4337 needs a bundler+paymaster. For a LOCAL CLI the Pimlico key stays on diff --git a/test/gas-mode.test.mjs b/test/gas-mode.test.mjs new file mode 100644 index 0000000..0b67af8 --- /dev/null +++ b/test/gas-mode.test.mjs @@ -0,0 +1,111 @@ +/** + * WDK_GAS_MODE accepts the three values .env.example documents and refuses anything else. + * + * config.mjs reads the environment at import, so each case loads it in a child process, as + * reservoir-network.test.mjs does. The CLI case runs the built dist/cli.mjs with the @qvac/sdk + * shim, as nfts-command.test.mjs does. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const CONFIG_URL = pathToFileURL(fileURLToPath(new URL("../src/config.mjs", import.meta.url))).href; + +/** gasMode and gasModeError for one environment, read in a fresh process. */ +function load(env) { + const script = `import(${JSON.stringify(CONFIG_URL)}).then(({ config }) => + console.log(JSON.stringify({ gasMode: config.gasMode, gasModeError: config.gasModeError })))`; + const base = { ...process.env }; + delete base.WDK_GAS_MODE; + delete base.PIMLICO_API_KEY; + const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], { + env: { ...base, MONAD_NETWORK: "testnet", ...env }, + encoding: "utf8", + }); + return JSON.parse(out.trim().split("\n").pop()); +} + +test("a typo of a gas mode is refused, never read as sponsored", () => { + // On main each of these resolved to "sponsored" with a key set, which broadcasts. + for (const value of ["dryrun", "dry_run", "simulate", "dry run", "gasless"]) { + const r = load({ PIMLICO_API_KEY: "x", WDK_GAS_MODE: value }); + assert.ok(r.gasModeError, `${JSON.stringify(value)} must be refused`); + assert.match(r.gasModeError, new RegExp(JSON.stringify(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(r.gasModeError, /dry-run, native or sponsored/); + assert.equal(r.gasMode, "dry-run", "a refused value must not be able to broadcast"); + } +}); + +test("a refused value is refused without a key too", () => { + const r = load({ WDK_GAS_MODE: "dryrun" }); + assert.ok(r.gasModeError); + assert.equal(r.gasMode, "dry-run"); +}); + +test("the documented values resolve as documented, in any case and with padding", () => { + const cases = [ + ["dry-run", "dry-run"], ["DRY-RUN", "dry-run"], [" dry-run ", "dry-run"], + ["native", "native"], ["Native", "native"], + ["sponsored", "sponsored"], ["SPONSORED ", "sponsored"], + ]; + for (const [value, mode] of cases) { + const r = load({ PIMLICO_API_KEY: "x", WDK_GAS_MODE: value }); + assert.ok(!r.gasModeError, `${JSON.stringify(value)} is documented`); + assert.equal(r.gasMode, mode, `${JSON.stringify(value)}`); + } +}); + +test("an empty value keeps the auto-detection, with and without a key", () => { + // Behaviour that must not change, so it holds on main as well. + const cases = [ + [{ PIMLICO_API_KEY: "x" }, "sponsored"], + [{ PIMLICO_API_KEY: "x", WDK_GAS_MODE: "" }, "sponsored"], + [{}, "dry-run"], + // Without a key every documented value still simulates, as before. + [{ WDK_GAS_MODE: "sponsored" }, "dry-run"], + ]; + for (const [env, mode] of cases) { + const r = load(env); + assert.ok(!r.gasModeError, JSON.stringify(env)); + assert.equal(r.gasMode, mode, JSON.stringify(env)); + } +}); + +test("the CLI stops before the banner or the wallet on every refused gas mode", () => { + const distCli = fileURLToPath(new URL("../dist/cli.mjs", import.meta.url)); + assert.ok(existsSync(distCli), "dist/cli.mjs missing — run npm run build first (CI builds before testing)"); + const shim = pathToFileURL(fileURLToPath(new URL("./helpers/shim-register.mjs", import.meta.url))).href; + const dir = mkdtempSync(join(tmpdir(), "nad-gas-")); + const policy = join(dir, "policy.json"); + writeFileSync(policy, "{}"); + const callLog = join(dir, "calls.log"); + const env = { + ...process.env, + WDK_SEED: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + QVAC_MODEL_PATH: "shim-model.gguf", + MONAD_NETWORK: "testnet", + NAD_STATE_PATH: join(dir, "state.json"), + NAD_POLICY: policy, + NAD_MCP_CONFIG: join(dir, "no-mcp.json"), + NAD_SHIM_CALL_LOG: callLog, + NO_COLOR: "1", + PIMLICO_API_KEY: "x", + }; + delete env.NAD_CLI_NO_RUN; + for (const value of ["dryrun", "dry_run", "simulate"]) { + const r = spawnSync(process.execPath, ["--import", shim, distCli], { + env: { ...env, WDK_GAS_MODE: value }, input: "/address\n", encoding: "utf8", timeout: 60_000, + }); + const out = r.stdout + r.stderr; + assert.equal(r.status, 1, `${value}:\n${out}`); + assert.ok(out.includes(`gas mode: WDK_GAS_MODE="${value}" is not a gas mode`), `${value}:\n${out}`); + assert.doesNotMatch(out, /initializing wallet/, `${value}: the wallet must not be touched`); + assert.doesNotMatch(out, /gasless/, `${value}: the banner must not claim a mode`); + } + const calls = existsSync(callLog) ? readFileSync(callLog, "utf8").split("\n").filter(Boolean).length : 0; + assert.equal(calls, 0, "the model must not be called"); +});