Security and trust model

What sats protects, how, and where that protection ends: keys, agent grants, request execution, providers, and failure behavior.

The core rule: no agent-originated spend reaches the signer without explicit human authorization bound to that exact request. An unapproved agent request is pending or denied, never signed. Today the only path from an agent request to the signer is sats agent approve. Direction explains why.

Test networks only

sats runs on signet (the default), testnet4, and regtest, and refuses mainnet. The refusal is one rule (sats_core::network) checked wherever mainnet could enter: parsing --network or the config's network, loading a config with a [mainnet] section, opening or creating a wallet, resolving a chain provider, and constructing the local signer. The agent server takes its network from the same flag, so it refuses to start on mainnet. Every refusal happens before anything is written.

The reason is the signing boundary described below: the software signer runs on the same machine, as the same user, as the agent. That is acceptable for coins without value and not for mainnet. Treat any seed sats has held as a development key: never send mainnet funds to it or restore it into a mainnet wallet.

Keys and wallet state

sats init creates a BIP-39 mnemonic and derives BIP-86 single-key Taproot descriptors. Public descriptors back the persisted, watch-only BDK wallet. Private descriptors exist only in memory, during key derivation or signing.

The mnemonic is stored in seed.sealed, encrypted with Argon2id and XChaCha20-Poly1305. The format is versioned and authenticated, and its associated data binds the blob to its purpose. The SQLite wallet holds public descriptors and chain state, never private keys.

The password and mnemonic are entered only at a terminal, never through arguments, environment variables, or pipes, which other programs, agents included, can read or inherit.

Sealed keys, grants, agent requests, the event log, and finalized transactions are written atomically and owner-only (0600). The directories that hold them are owner-only (0700), and existing installs are re-hardened as those directories are touched. This guards against partial writes and other OS users, not against a process running as your user.

Limits

Human sends

sats send validates and prepares a PSBT with the watch-only wallet, shows amount, fee, and total, and asks for confirmation unless you pass --yes. It then unseals the mnemonic with your password, signs with an ephemeral signing wallet, saves the raw finalized transaction privately, and broadcasts. The private descriptor is never written to the wallet database. A normal send never persists the PSBT, prepared or signed. Because the transaction is saved before broadcast, a failure or lost response leaves an exact retry.

Agent grants

sats agent grant <name> creates bounded authority to propose sends, never to sign them. Every limit is hard: approval authorizes one proposal inside the grant and can never exceed it.

A grant holdsEffect
Modeask (default): every request waits for you. observe: read-only. There is no autonomous mode.
BudgetTotal for amounts plus fees
--max-txPer-transaction amount cap; above it is over_max_tx
--max-feePer-transaction fee cap, always present. Default: 2% of the budget, at least 1,000 sats, never above the budget
--to allowlistOptional; any other recipient is recipient_not_allowed
Expiry--for, default 24h
NetworkThe one network the grant authorizes
AccountingSpend, transaction count, and a per-request reservation ledger
Token hashSHA-256 of the agent's bearer token

Changing a grant follows one rule: reducing authority is cheap and widening it needs the password. Switching ask to observe or removing an allowlist entry needs no password. Switching observe to ask or adding an entry does. Allowlist entries change only through these commands, never through payment history, so an agent can't launder an address into "known" by paying it once.

Agent names are 1–32 characters of a-z, 0-9, -, or _. They are path components on disk, so sats checks them at grant creation, at the MCP boundary, and in the store.

The agent token

Creating a grant requires your password, but nothing derived from it enters the grant. A grant file holds no key material. sats mints a random 32-byte token, prints it once inside the MCP setup commands, and stores only its hash, compared in constant time. Re-issuing a grant mints a new token and invalidates the old one, so rotation and revocation are the same act.

A grant is bound to its network. A grant file copied into another network's directory is refused on load, so a signet grant can never authorize a signature on another network.

The cost: the MCP client stores the raw token in its configuration, where the agent can read it. A stolen token grants the authority to ask. Its holder can file proposals you will see in sats agent requests, bounded by the grant until it expires, and can use the grant's read tools. It can't cause a signature, touch the seed, or reach another network. Keep budgets small and expiries short, and revoke grants you don't need.

Grant format

Grants carry format_version: 1. sats is pre-release, so earlier development shapes are not migrated: they fail with an error that says to revoke and grant again. A grant claiming a newer version is refused, because it may carry restrictions this build can't see.

One older shape gets special handling. Pre-daemon development builds stored the seed re-sealed inside the grant file, so anything that could read the file could sign. sats recognizes those grants, reports them in sats agent list, and refuses them. If an agent with shell access ever ran while one existed, treat the seed as disclosed and move funds to a fresh wallet. Revoking alone is not enough.

The authorization ladder

evaluate_send is one deterministic, pure decision with typed outcomes. It checks, in order:

  1. expiry (expired);
  2. observe mode (observe_only);
  3. arithmetic: an amount plus fee that overflows is amount_overflow, never a saturated value that could pass a budget check;
  4. the recipient allowlist (recipient_not_allowed);
  5. the amount cap (over_max_tx);
  6. the fee cap (over_max_fee);
  7. the remaining budget (over_budget);
  8. otherwise ask: the request is pending_approval.

Ask is the only outcome that isn't a denial, so "no unapproved request reaches the signer" is a structural property of the ladder, not a setting. Every denial is a grant boundary and none of them is approvable. Tests freeze the order so a refusal names the specific boundary it crossed.

The ladder runs twice:

A denied or pending request consumes no budget. The engine takes the current time as input. If the system clock can't be read, filing and execution fail with clock_unavailable rather than treating every grant as live.

An advisory per-network grant lock serializes decisions, reservations, grant creation, and revocation across processes. Concurrent requests can't double-draw, and an in-flight request can't undo a revocation.

Agent requests and the audit log

Each request is a durable record under <network>/agent-requests/<agent>/. It holds the canonical intent digest (network, agent, normalized recipient, and amount, but not the fee), its state, and the grant_id (128 random bits) of the grant instance that created it.

Every transition (received, denied, approved, dismissed, reserved, signed, broadcast, refunded, failed) appends to <network>/events/log.jsonl, linked by request id and intent digest. Filing requires its event write to succeed; some later writes are best-effort and warn on failure. Recovery relies on request, grant, and transaction records, never on the log being complete. The log is append-only and never pruned. Lines this build doesn't understand are shown raw by sats agent log, never hidden. Finalized transactions carry an origin naming the surface, agent, request, and intent digest.

sats agent requests --watch lets you discover requests without relying on the agent to report them. It reads the local store only: no provider calls, no grant writes, no events.

Approving a request

sats agent approve runs in your process with your password. It is the only place an agent request reaches the signer.

  1. Claim. sats locks the request and checks that the grant on file is the instance that created it, then re-runs the ladder with the fee unknown. A revoked or re-issued grant makes the request denied with reason revoked. A new grant never inherits old requests.
  2. Prepare. sats builds the transaction on fresh chain state through the same pipeline as a human send. Approval has no --allow-dust option.
  3. Verify. sats derives what the PSBT pays from the wallet's own descriptors and refuses unless it matches the recorded recipient and amount. A foreign input is refused outright.
  4. Re-check. The ladder runs again with the real fee.
  5. Review. You see wallet, network, full recipient, amount, real fee, total, and remaining budget, then confirm and enter your password. With --json, the review goes to stderr. It is never skipped.
  6. Reserve. Under the grant lock, sats re-reads the grant, draws amount plus fee on the grant's ledger under the request id, and persists the request as signing.
  7. Sign. Only now is the signer constructed. It signs, and sats saves the finalized transaction.
  8. Broadcast. The request becomes sent, or broadcast_pending if the provider refuses it or the response is lost.

Interactive selection opens a review and nothing else. It creates no durable authority and still requires confirmation, even with --yes. Requests that may already be signed can't be selected. Because execution needs your password, an "approved" record forged on disk gains an attacker nothing.

The signing boundary

The irreversible step is the call to Signer::sign, not a successful write afterwards. Before that call, a failure is provably unsigned. From that call on, nothing the signer reports counts as proof of "no signature", because a signer can sign, or leak a signature, and still return an error.

StateSigner invoked?Budget drawNext step
pending_approvalNoNoneApprove or dismiss
deniedNoNoneTerminal
failedProvably notReturned onceYou may approve again
signingMaybeKeptReconciled, see below
unresolvedMaybeKept, never refundedNever signed again; check sats status, then dismiss
broadcast_pendingYes, transaction savedKeptsats tx broadcast <txid>
sentYes, broadcastKeptDone
dismissedAs in the prior stateKept if one was heldTerminal

A failure before signing, such as an unwritable audit log or a signer that can't be constructed, returns the draw and leaves the request failed. Sync, fee, and provider failures during preparation also leave it failed. They happen before any draw. A signer error, an unfinalized result, or a failure to finalize or save leaves the request unresolved. A failed record never overwrites a settled or uncertain one.

Budget reservations

The draw is an entry in the grant's ledger, keyed by request id and written in the same atomic update as the grant's totals. A request holds at most one draw. Repeating the same amount and fee draws nothing; a different amount or fee fails closed. A draw is returned exactly once, and only while the request record is pending_approval, failed, or denied, the states that prove the signer never ran. A request that signed keeps its entry, so the ledger doubles as the grant's spend history.

Crash recovery

Recovery is a function of the durable records and is never guessed:

Under the durability assumptions above, a crash can't produce a second signature for one approval, or a refund after a signature may exist.

The MCP boundary

The MCP server reads the wallet and files requests under the grant its token names. It holds no key material and never prepares, signs, or broadcasts. Agents never receive the password, mnemonic, a signer, a PSBT-signing or unlock tool, or the --allow-dust bypass.

The token authenticates every tool call, never execution. Each call re-reads the grant, so revocation, re-issue, or expiry cuts a running session off at its next call, reads included, and approvals under a revoked grant stop immediately. check_request never syncs, mutates records, reconciles, refunds, signs, or broadcasts. An absent execution lock never proves a request is unsigned. The full contract is in MCP.

Providers and inscription postage

Preparing a spend requires fresh chain state. A sync failure stops every send mode, including --dry-run and --export-psbt, and stops agent execution. Balance, status, and history may show cached state and say so.

Coin selection excludes outputs of exactly 546 or 330 sats, common inscription postage. The check is local: no provider is asked which outputs carry assets, so an asset on any other value is not detected. Only a human CLI invocation can bypass the check, with --allow-dust, and only for that invocation.

Provider data is untrusted. A fee rate that is non-finite, negative, or above 10,000 sat/vB is a typed error. Malformed checkpoint data fails the sync, not the process. Diagnostics show only a provider's origin, never user-info, paths, queries, or response bodies. Credentials still go to the configured endpoint. Redaction does not make a provider honest.

Provider credentials live in config.toml, which sats writes owner-only. sats providers add reads a key from a hidden prompt or stdin, never from the command line, and checks the endpoint before saving it. A saved Esplora token or Subfrost key never follows a new URL: a changed endpoint needs the credential typed again, and a --provider override never receives one. Configuring providers is a human CLI task: no MCP tool configures them, and the MCP server only uses what is configured.

PSBTs

A normal send, human or agent, keeps its PSBT in memory. --export-psbt is the exception: it writes the unsigned PSBT to an owner-only file you name, and leaves the directory's permissions alone. sats psbt sign turns a finalized artifact into a private transaction record. It also signs external PSBTs, which are untrusted input. Check them with sats psbt inspect and an independent tool before signing. A PSBT that still needs other signers is reported as partially signed and written back as an artifact.

Alkanes

Default builds include no Alkanes commands. Inspection and advisory simulation are compiled only with the non-default experimental-alkanes feature, and execution only with the development-only experimental-alkanes-execute feature. Views come from the network's provider, which must be Subfrost. There is no Alkanes MCP tool or grant authority. The simulation request format has not been verified against a live endpoint.

Threat summary

ThreatBoundaryRemaining risk
Stolen wallet databaseNo private descriptors in SQLiteAddresses and balances exposed
Stolen sealed seedArgon2id plus authenticated encryptionPassword strength; offline guessing
Read grant fileBudget and token hash onlyReveals amounts and expiry
Stolen agent tokenEvery request waits for your password; caps and expiry enforced at filing and executionCan file and read within the grant until it is revoked or expires
Lied-about amount or feeRecomputed from the PSBT against the wallet's descriptorsAn understated input wastes the agent's own budget on an unrelayable transaction
Compromised MCP processHolds a token, never a key; forged approvals need the passwordSame as a stolen token
Debugger on an approving processKey material lives only for one approvalSeed recoverable in that window where ptrace is allowed
Pre-daemon wrapped-seed grantReported and refusedThe file is a seed disclosure until the wallet is rotated
Revoked agentGrant and token rechecked on every MCP tool call and under the lock at executionTransactions signed before revocation stay valid
Provider outagePlanning fails closed on a failed syncLoss of availability
Inscription on a non-postage valueNot detectedCan be spent by accident
Broadcast failureTransaction saved first; request broadcast_pendingRetry with sats tx broadcast
Wrong networkAddress and provider network validationMisconfigured third-party responses
Malicious Alkanes view (experimental builds)Advisory display onlyCan mislead inspection or simulation

Operational guidance