Documentation

Wallet setup and seed security.

Everything that happens after you install the skill: how the wallet gets created, how the mnemonic is encrypted at rest, what you have to back up yourself, and what an attacker would actually need to drain it.

Node 20+ Mainnet by default MIT
01

Before you begin

This skill handles real Bitcoin on mainnet by default. Read this section before you fund anything.

Treat it as a hot wallet

Anything holding both the passphrase and the seed file has unrestricted control of the wallet — check balance, create invoices, and send every sat to any address. There is no permission scoping, no read-only mode, and no server-enforced spending cap anywhere on this path.

The funded balance is the only limit that survives a compromised process. Use a dedicated wallet holding an operational float you could absorb losing, set SPARK_DAILY_BUDGET_SATS, populate the recipient allowlist, and sweep earnings out regularly.

Good fit

Small, autonomous flows

Agents that send and receive small amounts — pay per API call, get paid for a task, tip, settle up, buy a gift card. Plus dev and test work on REGTEST, and trusted agents you control yourself.

Wrong fit

Custody of real balances

Not for holding meaningful sums, and not for anything needing hard server-enforced spending caps, revocable access, or an audit trail. No such enforcement exists here — see when this isn't enough.

Prerequisites

  • Node 20 or newer. The package declares engines.node >= 20.
  • A passphrase of at least 12 characters, which you will need on every boot. Treat it like a production secret — deployment secret manager, or .env that is in .gitignore.
  • Somewhere offline to write 12 words down. Paper, a hardware backup, or a password manager. Have it ready before you run setup, not after.
Harden npm first

Installing anything from npm on a machine that will hold a wallet is worth doing carefully. Use a current npm — v12+ disables install and lifecycle scripts by default, which kills the postinstall supply-chain attack class outright (needs Node ^22.22.2 || ^24.15.0 || >=26). Failing that, 11.10.0+ is the floor where the package-cooldown age gate actually enforces. Distro-packaged npm runs years behind and silently ignores hardening keys, so upgrade rather than trust the system one. Config to copy lives in the supply-chain-hardening repo.

02

Install methods

The one prompt on the home page does all of this for you. These are the same four paths by hand: a native plugin in Claude Code, a shared Bot in Grok Bot, the npm package in any other agent framework, or a plain clone of the repo.

Skill text and wallet runtime are two different things

Whatever delivers the skill text — plugin, Bot, package or clone — the wallet runtime is always the sparkbtcbot-skill npm package installed in your own project and pinned by its lockfile. Setup, backup and every wallet command resolve from there with npm exec --no -- sparkbtcbot <command>; never from a bare npx, which fetches a registry package named after the command when the local binary is missing.

Claude Code

Inside a Claude Code session
/plugin marketplace add https://github.com/echennells/sparkbtcbot
/plugin install sparkbtcbot@sparkbtcbot
From a terminal these are claude plugin marketplace add <url> and claude plugin install sparkbtcbot@sparkbtcbot.
The plugin delivers the skill text. The wallet runtime is always the npm package in your own project, so run npm install --ignore-scripts sparkbtcbot-skill there too — then setup and backup resolve locally, pinned by your lockfile.
Installing from your terminal instead? That does not reach a session that is already open — run /reload-plugins there, or restart Claude Code.

Grok Bot

Prefer a chat window to a terminal? The same skill is published as a shared Bot for Grok Bot, xAI's desktop agent. Open the share link, preview what the Bot ships with, and choose Add to Grok Bot — it becomes a copy in your own account.

Shared Bot template on x.ai
Bitcoin · by lightning eric

Settle money in chat over Lightning — buy gift cards, eSIMs, and VPNs, and move sats between Lightning and on‑chain when you need to.

Add to Grok Bot
Adding a shared Bot copies its configuration, skills and routines — never the creator's files, logins or seed material. The Grok Bot desktop app is required to finish adding it.
Same rule as the plugin: the Bot delivers the skill text. Grok Bot works on your own computer, so the wallet runtime installs there like every other path — npm install --ignore-scripts sparkbtcbot-skill in a project folder, then ask the Bot to set up your wallet. From there creating the wallet and backing up the words work exactly as on every other path.

Any other agent: the npm package

Cursor, LangChain, the OpenAI Agents SDK, or your own harness — install the package and load the skill body with getSkillContent(). opencode works differently: it's a CLI that discovers skills from directories rather than calling a function, so it needs the one-line registration below.

Point your agent at AGENTS.md. Claude Code loads SKILL.md on its own; nothing else does. That file carries the rules that must reach every agent regardless — never echo the mnemonic or the passphrase, never run the seed reveal itself. An agent that only read the README may never have seen them.
npm
npm install --ignore-scripts sparkbtcbot-skill
opencode — register the skill directory
# from your project directory, after installing
$ ln -s "$(pwd)/node_modules/sparkbtcbot-skill/skills/sparkbtcbot" \
       ~/.claude/skills/sparkbtcbot
opencode finds skills by scanning for <dir>/<name>/SKILL.md — it never calls getSkillContent(), so installing the package alone leaves it with the wallet runtime and none of the instructions for using it. ~/.claude/skills/ is one of the locations opencode reads by default. Linking into node_modules keeps the skill on whatever version your lockfile pins; a copied file goes stale at the next npm update. Link the inner sparkbtcbot directory, not its skills/ parent — one level too high and SKILL.md sits too deep to be found. If it doesn't show up, restart opencode.

Prefer config to a symlink? opencode's config schema also takes additional skill roots — { "skills": { "paths": ["./node_modules/sparkbtcbot-skill/skills"] } } in opencode.json. Point that one at the skills/ parent, since it expects a directory containing skill folders.

Clone the repo

No plugin, no marketplace — clone it and let the agent read the skill straight from the repo.

Clone the repo
$ git clone https://github.com/echennells/sparkbtcbot
$ cd sparkbtcbot && npm ci --ignore-scripts   # lockfile-exact; no install scripts
# then: "read this repo and set me up a Spark wallet"
npm ci is not optional — reading SKILL.md teaches the agent what to do, but the wallet code needs the SDK installed before anything can run. --ignore-scripts matters here for the same reason it does on the npm path: plain npm ci runs the full install lifecycle, and one dependency (protobufjs) executes code at install time. In a clone the commands are npm run setup and npm run reveal-mnemonic. Tell your agent to read AGENTS.md and skills/sparkbtcbot/SKILL.md before it runs any wallet code — opencode and Cursor pick up AGENTS.md automatically, but not every harness does.
03

Create the wallet

One command, three ways in depending on where the mnemonic comes from. Setup generates or accepts a BIP39 mnemonic, encrypts it under your passphrase, and writes a single file at ~/.spark/seed.enc with mode 0600.

Your agent can run this for you — it is the one setup step that is safe to delegate, because the words are never printed. The commands below are the same thing run by hand.

However the skill text reached you — plugin, clone or npm — the runtime is always the sparkbtcbot-skill package installed in your own project, so start with npm install --ignore-scripts sparkbtcbot-skill there, so your application code loads a version your lockfile pins. --ignore-scripts is worth keeping: one transitive dependency (protobufjs) runs code at install time, and nothing here needs it. The one-off setup and backup commands below are then invoked with npm exec --no -- from that same project directory — including the fresh terminal you open for the seed backup, where you cd there first.

Install first, then npm exec --no. Never npx

npx <command> does not fail closed. If the local binary isn't found — wrong directory, package not installed — it falls back to fetching a registry package named after the command. The five sparkbtcbot-* binaries retired in 0.6.0 are unregistered on npm, so each of those names is there for anyone to claim. npm exec --no refuses instead of installing, which leaves nothing to claim.

Don't rely on the install prompt. npx only asks when it has an interactive terminal. In a script, a CI job, or an AI agent running commands on your behalf there is no TTY — npm logs one line and installs anyway, with nothing to read and nothing to refuse. Even interactively, the prompt defaults to yes. Guidance built on reading it protects only the people who were already fine.

Because the package is installed first, what runs is the copy your lockfile pins, not whatever is newest on the registry. ./node_modules/.bin/sparkbtcbot is the same thing spelled out, and never contacts the registry at all.

# The SDK generates a new 12-word mnemonic and the script encrypts it.
# The words are never printed and never hit disk in plaintext.
$ SPARK_NETWORK=MAINNET npm exec --no -- sparkbtcbot setup
In a cloned repo the same three run as npm run setup and npm run setup -- --import.
The script resolves inputs in import → env → generate order: an explicit --import flag beats a SPARK_MNEMONIC in the environment, which beats generating a fresh one. That only matters if more than one is present at once.

The passphrase

Setup reads SPARK_PASSPHRASE from the environment if it is set, and otherwise prompts for it on stderr with a confirmation. It must be at least 12 characters. Whichever way you supply it at setup time, it has to remain available to the runtime afterwards — the wallet decrypts the seed on every boot. If you set it inline for the setup invocation only, put it in .env now.

What setup leaves you with

An encrypted seed at ~/.spark/seed.enc (override with SPARK_SEED_PATH), and a printed Spark address — the script initializes a wallet from the file it just wrote, so that address is your confirmation that the right wallet loaded. If you migrated from a plaintext .env, remove SPARK_MNEMONIC from it now.

04

Back up the mnemonic yourself

This is the step nobody else can do for you, and the one that decides whether a lost laptop is an inconvenience or a total loss.

A fresh wallet's twelve words exist in exactly one place: inside seed.enc, encrypted. Setup never prints them and never writes them to disk in plaintext. To read them, you decrypt them deliberately — in your own terminal, not through your agent:

Reveal the mnemonic You run this, not the agent
$ npm exec --no -- sparkbtcbot reveal-mnemonic
Decrypts seed.enc, asks a y/N confirmation, and prints the words once. Copy them to paper or a hardware backup. There is no plaintext file left behind to delete.

Why the agent must not run it

The command refuses to run unless both stdin and stdout are real TTYs. An agent shelling out over a normal Bash tool has piped stdio, so it is refused — as is CI, and anything on the end of a pipe. The interactive y/N confirmation is a second gate that piped stdin cannot auto-answer.

That check is a backstop, not a guarantee

An agent that allocates a full pseudo-terminal has isTTY true and captures exactly what the PTY renders. Nothing can make "print a secret to a terminal the caller controls" safe. The TTY gate stops accidental capture into a transcript; it does not stop a determined harness.

So the real rule is behavioral, and it is written into the skill's instructions for the agent: the agent never runs reveal-mnemonic — it tells you to. The same rule covers the passphrase: never echo it, never print it. Either one alone is useless; together they are the whole wallet.

Earlier versions wrote a MNEMONIC_BACKUP_*.txt file. That was removed precisely because it left a plaintext seed sitting on disk until someone remembered to rm it.
05

How the seed is stored

One self-describing file, encrypted with authenticated crypto from Node's standard library. No extra dependencies.

  • scrypt for key derivation — N=2^17, r=8, p=1. Memory-hard and OWASP-blessed for password hashing. Roughly 250 ms on a modern CPU; slow on purpose, so brute-forcing your passphrase is expensive.
  • AES-256-GCM for encryption — 256-bit key, 96-bit IV, 128-bit auth tag. Authenticated, so a wrong passphrase or tampered ciphertext is detected rather than silently producing garbage.
  • Node's built-in node:crypto — zero additional dependencies in the path that touches your seed.
File format — 48 bytes of header, then ciphertext

The version, KDF id and cipher id up front make the file self-describing, so future formats can be added without breaking files written today. Setup writes v1, whose payload is the bare mnemonic. sparkbtcbot set-policy rewrites the file as v2, whose payload is JSON carrying the mnemonic plus the bound budget — and v2 passes these four header bytes as GCM additional authenticated data, so flipping the version byte back to 0x01 fails the auth tag instead of silently dropping the sealed policy.

1 byte  version      0x01 = v1 · 0x02 = v2 (seed-bound budget)
1 byte  kdf id       0x01 = scrypt
1 byte  cipher id    0x01 = aes-256-gcm
1 byte  reserved     0x00
16 bytessalt
12 bytesiv           gcm nonce
16 bytesauth tag
N bytes ciphertext

At runtime

Your application reads SPARK_PASSPHRASE from the environment, decrypts the file once at boot, and holds the wallet. The mnemonic falls out of scope; only the wallet object is retained.

import "dotenv/config";
import { SparkWallet } from "@buildonspark/spark-sdk";
import { loadMnemonicFromEnv } from "sparkbtcbot-skill";

const mnemonic = await loadMnemonicFromEnv(); // reads SPARK_PASSPHRASE
const { wallet } = await SparkWallet.initialize({
  mnemonicOrSeed: mnemonic,
  options: { network: process.env.SPARK_NETWORK || "MAINNET" },
});
// `mnemonic` falls out of scope; only `wallet` is retained
The ~250 ms scrypt cost is paid once, at boot. After that, performance is identical to loading a plaintext mnemonic. Do not call loadMnemonicFromEnv() per request — decrypt once, hold the wallet.
06

Threat model

To drain the wallet an attacker needs the seed file and the passphrase together, or the memory of the running process. Anything that leaks only one of the two is survivable.

Leak vector Outcome
.env accidentally committed to git Survivable. Passphrase only — useless without the seed file.
Env-var dump in logs Survivable. Passphrase only — useless without the seed file.
Casual cat .env snooping Survivable. Passphrase only.
Server backup capturing env vars only Survivable. Useless without the seed file.
Server backup capturing the full disk Both files. Only passphrase strength saves you.
Memory dump while the wallet is running Funds drained. The mnemonic is in process memory after decrypt.

What encryption at rest does not do

  • It doesn't stop memory dumps. After loadMnemonicFromEnv() returns, the mnemonic is in memory. Attacking that needs shell on the host under the same UID as the agent.
  • It doesn't stop a compromised host while the process is running — same reason.
  • It doesn't give you scoped or revocable access. Encryption at rest is all-or-nothing: whoever can decrypt has full custody, and you cannot revoke that without sweeping to a new wallet.
07

Recovery scenarios

The mnemonic is the ultimate backup. Encryption defends the seed file at rest; it does not replace an offline copy of the words.

What you lost What you need What to do
The passphrase Mnemonic backup Re-run setup with --import, paste the mnemonic, choose a new passphrase
seed.enc Mnemonic backup Re-run setup with --import, paste the mnemonic
The entire machine Mnemonic backup Install on the new machine, re-run setup with --import
The mnemonic backup Passphrase + seed.enc Run reveal-mnemonic to recover the words — and save them offline this time
All three Nothing helps Funds are gone permanently

The mnemonic doesn't cover the operators-gone case

Restoring from the mnemonic recovers cooperative access — the normal case, where Spark's Signing Operators are online. Unilateral exit additionally needs the wallet's local leaf material, the pre-signed transactions operators hand out at claim and transfer time, which is not derivable from the mnemonic.

That is what the auto-maintained recovery bundle (the "leaf-vault") is for. It refreshes on every balance change, and its integrity gate refuses to write a bundle unless every leaf reconstructs a complete exit chain offline — so a written bundle is an exitable one. Blink's spark-unilateral-exit tool consumes it. Check yours:

$ npm exec --no -- sparkbtcbot leaf-vault verify
A stale bundle is a stale balance, not a broken one: it recovers what you still held at snapshot time, cannot reclaim leaves you have since sent, and is blind to leaves you have since received.
Exit is a fire escape, not a door

Measured on a real mainnet exit: 11 of 19 leaves were too small to be worth exiting and were skipped automatically, refunds carried 550 and 1,450 block timelocks rather than the ~2,000 a fresh leaf implies, and first broadcast to swept funds took 20 days. Fees scale with the leaf count, not the balance — a dusty wallet can owe most of its value in fees.

The practical lesson: consolidate leaves while the operators are cooperative, and never size an exit from a fixed sat/vB figure — quote against the mempool at the time. Undershooting costs delay, not money; an unmined package expires after ~14 days and its inputs become spendable again.

08

Fees

Spark-to-Spark transfers are free and instant. Lightning interop costs 0.15–0.25% plus routing. Withdrawing to Bitcoin L1 costs a flat amount per exit, so its share depends entirely on how much you move.

Fee to withdraw to Bitcoin L1

The fee is flat rather than a percentage, so its share depends on how much you withdraw. Shown at medium withdrawal speed. Approximate, measured August 2026 — the on-chain component tracks the mempool, so quote the live figure before sending rather than budgeting from this table.

Amount withdrawn Fee Share of amount
5,000 sats ~2,430
49%
25,000 sats ~2,430
10%
100,000 sats ~2,430
2.4%
1,000,000 sats ~2,430
0.24%

Because the fee is flat and is deducted from the amount, small withdrawals are fee-dominated: discourage any L1 exit under 25,000 sats and batch small balances into one. Always quote first and show the user the net they will receive. The cooperative exit is performed by the Spark operators, who can delay but not take funds; the operator-less path is the unilateral exit.

09

Environment variables

One required, the rest optional. Set them in .env (gitignored) or your deployment's secret manager.

Variable Description
SPARK_PASSPHRASE Required Passphrase (12+ chars) that decrypts the seed file at boot. Set during setup.
SPARK_NETWORK Optional MAINNET (default), REGTEST, TESTNET, SIGNET. Develop on REGTEST.
SPARK_SEED_PATH Optional Override the encrypted-seed location. Default ~/.spark/seed.enc.
SPARK_MNEMONIC Legacy Plaintext mnemonic, read by setup only, for the one-time migration off an older .env. Ranks below --import and above generating a fresh wallet. Delete the line once setup has encrypted it — leaving it is a plaintext seed on disk, which is the thing all of this exists to avoid.
SPARK_ACCOUNT_NUMBER Optional BIP32 account index — separate wallets from one mnemonic. Defaults: 1 on MAINNET, 0 on REGTEST. Read from env only by the leaf-vault CLI; app code passes it to SparkWallet.initialize itself.
SPARK_DAILY_BUDGET_SATS Optional Cumulative spend ceiling over a rolling 24h window, across Spark transfers, Lightning pays, invoice fulfillment and L1 withdrawals. The guard that bounds a loop of sends. Unset means unenforced — and because it lives in files the agent can write, prefer sealing it into the seed, which overrides this.
SPARK_LEAF_VAULT Optional Set to off to disable the automatic unilateral-exit recovery bundle. On by default — leave it on.
SPARK_LEAF_VAULT_PATH Optional Override the bundle location. Default ~/.spark/leaf-vault/current.json. Use a distinct path per wallet or network on a shared machine.
SPARK_SPEND_LEDGER_PATH Optional Override the spend-ledger location — one path per agent. Default ~/.spark/spend-ledger.json.
10

Operational security

The habits that decide whether a bad day costs you a float or a balance.

Protect the two secrets

  • Back up the seed phrase offline. Paper or a hardware backup. The encrypted seed file is not a substitute.
  • Never expose either secret in code, logs, git history, or error messages.
  • Treat SPARK_PASSPHRASE like a production secret. Out of source, out of build images, out of CI logs. A secret manager is fine; a gitignored .env is fine; a screenshot in a Slack thread is not.
  • Leave seed.enc at mode 0600, and don't bundle it into container images that ship the passphrase alongside it.

Don't accumulate a balance

  • Keep only the minimum operational float the agent needs on Spark.
  • Sweep earnings out to cold storage or a wallet you control directly, using wallet.transfer() or wallet.withdraw(). There is no automated sweeper in this skill — do it as part of your ops rhythm, or build the listener yourself (transfer:claimed event, balance check, transfer).
  • Size the float as a loss you could absorb without changing your day.

Per-agent hygiene

  • Separate mnemonics per agent — never share one across agents. Each runs its own setup with its own seed file and passphrase.
  • Separate accountNumber values if you want multiple wallets from a single mnemonic, or to split funding tiers.
  • Monitor transfers with event listeners for unexpected outgoing activity.
  • Call cleanup() when the wallet is no longer needed.
  • REGTEST for development, MAINNET only for production.

Seal the budget into the seed

Requires sparkbtcbot-skill 0.6.1 or newer for this command form. The capability landed in 0.5.0, under the per-command binaries that 0.6.0 retired.

SPARK_DAILY_BUDGET_SATS has a structural weakness: it is enforced from files the agent can write. Deleting or truncating ~/.spark/spend-ledger.json, or dropping the line from .env, silently restores the full balance — a cleanup command, a prompt injection or a machine migration all fail open. Since 0.5.0 you can bind the cap into the encrypted seed instead:

Bind a daily budget You run this, not the agent
$ npm exec --no -- sparkbtcbot set-policy
TTY-gated like reveal-mnemonic, and it always prompts for the passphrase rather than reading .env — the file sitting next to the wallet is exactly what a seal is meant to stop counting as authorization. It rewrites seed.enc in the v2 format with dailyBudgetSats inside the encrypted payload, and re-keys the spend ledger with an HMAC derived from the seed.
  • A bound budget beats the env var absolutely. Raising SPARK_DAILY_BUDGET_SATS cannot loosen it.
  • Tampering fails closed. A missing, unsigned or edited ledger stops spending and tells you to run sparkbtcbot reset-ledger — the passphrase-gated legitimate reset, which is what finally makes a real reset distinguishable from an attack. Previously rm was both.
  • The agent can't remove the cap without removing the money. Reading the policy needs the passphrase, tampering fails the GCM tag, and deleting the payload deletes the wallet.
  • It is opt-in and backward compatible. v1 seeds and env-var budgets behave exactly as before.
Honest limit: this moves the bar from rm to executing code, not to impossibility. Raw SDK calls still bypass the wrapper's guards, and replaying a validly-signed older ledger cannot be defended against client-side.

The recipient allowlist

An optional file that pins outbound sends to addresses you named in advance. There is no env var and no flag — it turns on by existing. Create ~/.spark/recipients.allow with at least one address and every gated send checks against it; leave the file absent and nothing is enforced.

~/.spark/recipients.allow
# One address per line. Spark and L1 addresses share the file.
sp1q...your-cold-wallet          # sweep target
bc1q...your-hardware-wallet     # L1 withdrawals

# bc1q...old-address            <- commented out, so not permitted
Comments run from # to end of line; blank lines and surrounding whitespace are ignored. The check is a plain string match, not address-type aware, so Spark sp1… and on-chain addresses live together with no separate sections. A send to anything not listed fails with RECIPIENT_NOT_ALLOWED, and the error names the file and the rejected address.
  • An empty file means not enforced, and so does a file where every line is commented out. Zero entries reads as "still setting this up" rather than "permit nothing" — so a file you meant as a lockdown but left blank silently permits everything. Verify with a send to an address you did not list.
  • Edits apply immediately. The file is re-read on every check rather than cached at boot, so adding a recipient does not need an agent restart.
  • Keep it out of the agent's writable working tree. The gate is enforced in the same process as the agent, so bypassing it is just editing the file — it stops the agent from surprising you, not an attacker who already has the host.
What the allowlist does and does not bound

The optional recipient allowlist (~/.spark/recipients.allow) gates Spark transfers, token transfers and L1 withdrawals to addresses on the list. It does not gate Lightning or L402 payments — those pay a node pubkey embedded in a BOLT11 invoice, not an address, so there is no address for it to check.

Nothing in-process survives a compromised process, either: SPARK_DAILY_BUDGET_SATS and the wrapper's per-call ceilings bound mistakes and runaway loops, but anything running inside the agent can call wallet.transfer() directly past them. The funded balance is the real cap.

11

When this isn't enough

Encryption at rest is the minimum bar the skill enforces, and it has no server-side variant — no scoped tokens, no server-enforced caps, no shared wallet views, no audit log.

If any of these describe your setup, this skill alone is not sufficient custody infrastructure. Keep the balance here to an operational float and hold the rest elsewhere:

  • Non-trivial balances — rule of thumb, more than you'd lose without changing your day.
  • Multiple agents needing access to the same funds.
  • A need for revocable or role-scoped access instead of all-or-nothing custody.
  • A need for spending caps that survive a compromised agent process.
  • A need for an audit trail of every wallet operation.

Spark itself carries its own assumptions worth knowing: it has a small number of infrastructure providers (Signing Operators), so there is some downtime risk, and transfers require trusting that at least one operator behaves honestly.

12

Reporting a vulnerability

Found a security issue? Please don't open a public GitHub issue. Email eric@yvrbtclabs.dev with a description and impact, steps to reproduce, the affected version (npm view sparkbtcbot-skill version or a commit SHA), and your severity assessment if you have one. Expect an acknowledgement within 7 days, and coordinated disclosure timing before any public write-up.

In scope

The encryption helpers, the skill content shipped to agents, and the example scripts. Anything that could leak a mnemonic, passphrase or decrypted seed to disk, logs, network or process output where the docs say it won't. Anything that could make an agent following the skill's instructions send funds to an address other than the one specified.

Out of scope

Upstream dependency bugs (report to that project — unless the skill's usage amplifies them), Spark protocol or Signing Operator issues, the separate sparkbtcbot-proxy project, and attacks that require an already-compromised passphrase.

Pre-1.0: only the latest published version receives security fixes. Pin a version if you need stability.