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.
Before you begin
This skill handles real Bitcoin on mainnet by default. Read this section before you fund anything.
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.
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.
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
.envthat 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.
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.
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.
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
/plugin marketplace add https://github.com/echennells/sparkbtcbot /plugin install sparkbtcbot@sparkbtcbot
claude plugin marketplace add <url> and
claude plugin install sparkbtcbot@sparkbtcbot.
npm install --ignore-scripts sparkbtcbot-skill there too
— then setup and backup resolve locally, pinned by your lockfile.
/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.
Settle money in chat over Lightning — buy gift cards, eSIMs, and VPNs, and move sats between Lightning and on‑chain when you need to.
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.
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 install --ignore-scripts sparkbtcbot-skill
# from your project directory, after installing $ ln -s "$(pwd)/node_modules/sparkbtcbot-skill/skills/sparkbtcbot" \ ~/.claude/skills/sparkbtcbot
<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.
$ 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.
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.
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
# Bringing a mnemonic from paper, a hardware wallet, or another machine. # It prompts on stderr — nothing lands in shell history. $ npm exec --no -- sparkbtcbot setup --import
# One-time path off a pre-existing plaintext SPARK_MNEMONIC in .env. # Leave that line in place, add SPARK_PASSPHRASE, then run setup. $ npm exec --no -- sparkbtcbot setup # Afterwards, delete the SPARK_MNEMONIC line from .env.
npm run setup and
npm run setup -- --import.
--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.
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.
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:
$ npm exec --no -- sparkbtcbot reveal-mnemonic
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.
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.
MNEMONIC_BACKUP_*.txt file. That was removed precisely because it
left a plaintext seed sitting on disk until someone remembered to rm it.
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.
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
loadMnemonicFromEnv() per request — decrypt
once, hold the wallet.
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.
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
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.
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.
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.
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. |
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_PASSPHRASElike a production secret. Out of source, out of build images, out of CI logs. A secret manager is fine; a gitignored.envis fine; a screenshot in a Slack thread is not. - Leave
seed.encat 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()orwallet.withdraw(). There is no automated sweeper in this skill — do it as part of your ops rhythm, or build the listener yourself (transfer:claimedevent, 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
accountNumbervalues 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:
$ npm exec --no -- sparkbtcbot set-policy
.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_SATScannot 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. Previouslyrmwas 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.
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.
# 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
# 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.
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.
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.
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.
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.
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.