Architecture

sats is a native Bitcoin wallet with a portable core. Agents create requests, humans authorize requests, sats executes requests: the MCP server files a request under a grant, the human reviews it and executes it with sats agent approve, and the agent observes the result. No agent-originated request reaches the signer unapproved; the direction this implements is in Direction.

System shape

flowchart TD
    CLI["Human CLI (send, approve)"] --> Native["Native workflows"]
    MCP["MCP server (token, no keys)"] --> Requests["request::create"]
    Requests --> State["Store + watch-only wallet"]
    Native --> Exec["request::execute"]
    Native --> Core["sats-core"]
    Exec --> Core
    Exec --> State
    Exec --> Providers["Chain providers + guards"]
    Native --> Providers

A human send unlocks the seed for the duration of one command. An agent cannot sign at all: the served process writes a request record and nothing else. sats agent approve prepares the transaction, derives what it pays from the wallet's descriptors, re-checks the grant with the real fee, takes the human's password, reserves the budget, signs, persists, and broadcasts — in the human's process, for exactly one request.

crates/sats-core owns deterministic wallet and authorization behavior. crates/sats owns environment effects: command parsing, terminal rendering, files, SQLite, network clients, provider selection, passwords, and MCP stdio.

Crates

sats-core

The portable engine has no filesystem, network, clock, terminal, or async runtime dependencies. Callers provide time and operate on a BDK wallet they own.

ModuleResponsibility
authzGrant model, spend request, the deterministic verdict ladder (every proposal is ask or deny), reservation and refund
requestThe request record and its state machine, with the signature boundary structural
intentThe canonical send intent and its digest
eventThe causal event kinds appended per request transition
tokenAgent capability tokens: mint, hash, constant-time verify
verifyRecomputing a PSBT's payments and fee from the wallet's own descriptors
engineIn-memory PSBT preparation and conservative UTXO exclusion
planPrepared spends and finalized transaction records
seedBIP-39 generation and parsing; BIP-86 public and private descriptors
sealVersioned Argon2id/XChaCha20-Poly1305 secret envelopes
signerEnvironment-neutral signer trait and local mnemonic signer
error, fmt, amountTyped errors, satoshi formatting, and shorthand amount parsing

sats

The native crate composes the portable core with operating-system and network adapters.

ModuleResponsibility
main, cliParse global flags and commands, resolve the selected network, dispatch workflows
commandsHuman CLI workflows and their text/JSON presentation
configTOML configuration and canonical network names
storeXDG paths, atomic files, finalized transactions, grants, requests, the event log, and sensitive-file permissions
walletdSQLite-backed watch-only BDK wallet creation, loading, and persistence
providerTyped capabilities, driver resolution, chain access, and UTXO guards
keys, passwordUnlock the master seed; prove the password without keeping anything
requestThe native request workflow: create, dismiss, reconcile, and the human-authorized executor (stage, commit)
spendThe shared signing and broadcast tail: persist before broadcast
mcpMCP stdio server and tool schemas — reads the wallet and files requests
uiTerminal presentation

main.rs is the composition root. Leaf feature logic belongs in the owning module, not in dispatch.

sats-alkanes

Pure Alkanes protocol composition: alkane ids, LEB128 varints, cellpack call encoding, the protostone/runestone OP_RETURN envelope, bytecode code-hashing, and tolerant simulation-result views. Byte encodings are derived from the published alkanes-rs reference and frozen by unit vectors. Environment-agnostic like sats-core: chain access, funding, and signing stay with the sats crate.

sats-web

The website playground: sats-core compiled to WebAssembly behind a small JSON API for the interactive terminal at the project website. Only the chain is simulated (an in-memory faucet and instant confirmation); planning, UTXO exclusion, signing, sealing, and grant authorization run the same core code as the native surfaces. There is no separate process in a web page, so the playground demonstrates the grant model without the process boundary that enforces it natively. It holds no compatibility surface — the CLI and MCP schemas remain the stable contracts — and it must never gain filesystem, network, or native-store dependencies.

Persistent state

Without SATS_DIR, configuration and data use platform XDG locations. SATS_DIR places both under one directory, which is useful for tests and isolated runs.

StatePath relative to the data/config rootContents
Configurationconfig.tomlDefault network and typed providers
Master seedseed.sealedPassword-sealed mnemonic
Wallet<network>/wallet.sqlitePublic descriptors and BDK changes only
Finalized transactions<network>/transactions/<txid>.jsonPrivate raw transaction hex, pending/broadcast status, payment metadata, and the originating request
Grants<network>/grants/<agent>.jsonAuthority mode, limits, accounting, and the bearer token's hash — no key material
Agent requests<network>/agent-requests/<agent>/<id>.jsonOne durable record per request: canonical intent digest, idempotency key, and its state
Request claims<network>/agent-requests/<agent>/<id>.lockPrivate OS file lock held while one process executes or reconciles the request
Event log<network>/events/log.jsonlAppend-only causal record of the agent path: one JSON line per state transition

Wallet state, transactions, requests, and grants are namespaced by Bitcoin network. The sealed master seed is shared so each network derives from the same mnemonic. Sensitive files are written atomically with restrictive permissions.

Native HTTP uses a pinned local minreq patch for bounded TCP address fallback. A failed address cannot consume the whole request deadline while other DNS candidates remain. No HTTP bytes are sent during this fallback; provider identity, TLS hostname verification and broadcast retry semantics remain unchanged. The portable core has no transport dependency.

Human send

sequenceDiagram
    participant H as Human
    participant C as CLI
    participant P as Providers
    participant E as Core
    participant S as Store
    H->>C: sats send address amount
    C->>P: sync, guards, fee estimate
    C->>E: prepare PSBT
    C-->>H: amount, fee, confirmation
    C->>E: sign and finalize locally
    C->>S: save pending raw transaction
    C->>P: broadcast transaction
    C->>S: mark transaction broadcast

The shared preparation pipeline validates the address before network I/O, syncs the wallet, builds the union of dust and configured-guard exclusions, estimates the fee when none was supplied, and asks sats-core to build the PSBT. Preparation fails rather than using stale state after a sync failure.

The prepared PSBT stays in memory during a normal send. After finalization, sats writes private raw transaction hex before any broadcast attempt. A broadcast failure therefore leaves a pending transaction that can be retried without retaining the signed PSBT.

Agent request: file, authorize, execute

Filing happens in the served process and touches nothing but the store:

sequenceDiagram
    participant A as Agent
    participant M as MCP server
    participant S as Store
    participant H as Human (CLI)
    A->>M: request_send(address, amount, request_id)
    M->>S: verify token, canonical intent, ladder (fee unknown)
    S-->>M: pending_approval (or denied)
    M-->>A: request_id + status
    H->>H: sats agent requests --watch shows the request
    A->>M: check_request until it settles

Execution happens in the human's process, once, with the password:

sequenceDiagram
    participant H as Human
    participant C as sats agent approve
    participant P as Providers
    participant E as Core
    participant S as Store
    H->>C: sats agent approve <id>
    C->>S: claim the request, re-check the grant (fee unknown)
    C->>P: sync, guards, fee estimate
    C->>E: prepare PSBT; derive what it pays; ladder with the real fee
    C-->>H: recipient, amount, fee, total; password
    C->>S: under the grant lock: check the grant instance, reserve budget, then record signing
    C->>E: construct the signer, sign, finalize
    C->>S: save the attributed transaction (before broadcast)
    C->>P: broadcast
    C->>S: record sent, or broadcast_pending

The grant is re-read under the lock before the reservation, so revocation or a tightened boundary between the review and the password still refuses, and a request executes only under the grant instance that created it. The reservation — a ledger entry on the grant keyed by the request id — and the signing record are persisted before the signer is constructed, so no denied or unauthorized request can reach it. Budget is returned only before Signer::sign is invoked; nothing the signer reports afterwards is trusted to mean "no signature", so any failure from the invocation on leaves unresolved: never refunded, never signed again. Once the transaction record exists the reservation is final, the request is never signed again, and a failed broadcast leaves broadcast_pending for sats tx broadcast to settle.

A signing record left by a dead process is reconciled by the next listing or approve from the durable truth: a transaction attributed to the request (origin agent, request id, and intent digest all match) means broadcast_pending or sent; none means unresolved, with nothing refunded. The ledger is reconciled the same way: a draw whose request is pending_approval, failed, or denied was orphaned by a crash before signing or before the refund reached disk and is returned once; every other draw stays.

Every transition — received, denied, approved, dismissed, reserved, signed, broadcast, refunded, failed — appends to the per-network event log. Finalized transactions carry an origin naming the surface, agent, request, and canonical intent digest.

Provider model

A provider is bound to one network and advertises audited capabilities:

  • chain.sync;
  • chain.fees;
  • chain.broadcast;
  • guard.ord;
  • guard.alkanes;
  • guard.native for deterministic tests;
  • alkanes.view for the explicit sats alkanes contract tools.

Provider resolution is configuration work and performs no network I/O. Operations validate the selected network when they execute. See Providers and guards for precedence and configuration.

Change map

ChangePrimary ownerRequired checks
Transaction selection, preparation, or finalized-record metadatasats-core::engine, sats-core::planCore unit tests plus CLI/MCP integration paths
Grant rule or accountingsats-core::authzDecision edge cases, persistence ordering, MCP denial tests
What a PSBT is taken to dosats-core::verifyAdversarial derivation tests: forged hints, extra outputs, foreign inputs
Request creation, execution, or reconciliationrequestExecutor unit tests with the signer probe, crates/sats/tests/mcp.rs
Human command or flagcli, commands, main dispatchCLI integration test and docs/cli.md
MCP tool or result schemamcp::serverMCP integration test and docs/mcp.md
Provider driver or capabilityprovider, configMocked driver, network mismatch, ambiguity and failure tests
Persisted statestore, walletd, relevant core modelBackward-reading and atomic-write tests
Key or signing behaviorseed, seal, signer, keysSecurity-focused unit and end-to-end signing tests

Read AGENTS.md before implementing any of these changes.