JSON-RPC Extensions
satd preserves Bitcoin Core's JSON-RPC contract by default: the same method
names, response field names, and types, so existing clients work unchanged.
On top of that, satd adds opt-in extensions for developers and integrators.
Each extension is either enabled by a server flag (and is therefore
live-reloadable over SIGHUP) or exposed as an additional method or
parameter. None of them alters the default Core-compatible wire shape. All
are governed by the
stability policy.
The authoritative catalogue of where satd differs from Core is
CORE_DIFFERENCES.md.
For the push-based event firehose and cursor-resumable watch subscriptions (gRPC, WebSocket, SSE, ZMQ), see the Streaming Consumption API chapter. That is a distinct surface from the extensions described here.
Note. JSON-RPC keeps Bitcoin Core's cookie /
rpcuser/rpcauthcredentials by default. Capability-scoped bearer tokens (-rpcauthbearer,rpc:read/rpc:submit/rpc:write) are an opt-in addition. See Authentication & Authorization.
Satoshis-as-integers
Bitcoin Core emits every amount as an IEEE-754 double in whole BTC
(0.00001000), which loses precision near dust and at the supply boundary.
This is Core's long-standing
#3249, open since 2013.
satd can instead emit exact integer satoshis.
This is a server-wide default, --rpc-default-units=sats|btc
(rpcdefaultunits as a config key), not a per-request flag. The default is
btc, where output is byte-identical to Core: a fixed 8-decimal number,
formatted from the integer satoshi value so it is exact. Set it to sats
and amounts serialize as JSON integers everywhere. In that mode responses
also carry a _units: "sats" tag so a client can confirm the shape it
received. The tag is absent in the default btc mode, which stays
byte-for-byte compatible. The option is live-reloadable. A per-request
HTTP-header override is a planned follow-up.
Structured RPC errors
By default, error responses are byte-identical to Core's {code, message}.
--rpc-extended-errors (rpcextendederrors; default off, live-reloadable)
is a server-wide option. With it enabled, satd additionally populates the
JSON-RPC data object with machine-actionable fields:
category: a stable taxonomy string, for examplemempool.policy.feerate,validation.consensus,storage.not_found.suggestion: a concrete remediation hint, when one applies.debug: arbitrary structured detail (field positions, computed values), when present.
Category names are stable once shipped in a release: new names can be
added, and existing ones never change meaning. As with the units default,
this is a server-wide option, since the common deployment pattern is satd
driven only by satd-aware tooling. A per-request X-Satd-Extended-Errors
header is a planned follow-up.
Fee estimation
Core's estimatesmartfee conf_target [estimate_mode] is kept with its
exact response shape ({feerate, blocks, errors}) and is Core-compatible
by default. The optional mode argument accepts Core's economical /
conservative / unset vocabulary, all treated as the historical
estimator. It also accepts satd's own historical / mempool / blend
values.
satd also adds an estimatefees [targets] [mode] method (default mode
blend, default targets [1, 3, 6, 12, 24]). It simulates the next N
block templates from the current mempool, with ancestor-feerate
(CPFP-aware) package sorting. It never hard-errors; it always returns a
result. The response maps each target to a {feerate, confidence} pair,
where confidence is high | medium | low, and includes a feerate
histogram. This is the basis for Core's
#11500.
Mempool subscription stream
subscribemempool is a JSON-RPC WebSocket subscription, paired with
unsubscribemempool, that emits structured lifecycle events. Each event is
tagged by a kind field:
enter: a transaction was admitted to the mempool.leave_confirmed: it was confirmed in a block.leave_evicted: it was dropped, with an explicitreason(full_pool|expiry|block_conflict|policy|reorg).leave_replaced: it was RBF-replaced, carrying thereplacing_txid.
Bitcoin Core requires polling getrawmempool or rebuilding this state from
per-tx ZMQ frames. This stream carries explicit eviction reasons and RBF
replacement linkage directly. For the richer firehose with cursor replay,
see the Streaming Consumption API; subscribemempool is
the lightweight JSON-RPC option.
Bounded mempool aggregate
getmempoolsummary ( top_n ) returns the view a dashboard renders — a vsize
histogram over the whole acting mempool, plus the top_n transactions by
ancestor feerate (descending, ties broken by smaller vsize).
{
"size": 71594,
"bytes": 117777467,
"vsize_histogram": [
{ "min_vsize": 0, "max_vsize": 100, "count": 4127 },
{ "min_vsize": 50000, "max_vsize": null, "count": 6 }
],
"top": [
{ "txid": "…", "vsize": 141, "time": 1789255111,
"ancestorcount": 1, "ancestorsize": 141, "ancestorfees": 282,
"descendantcount": 1 }
]
}
The reply is bounded by top_n (default 50, ceiling 1000; above the ceiling is
an -8 error rather than a silent clamp) instead of by mempool size. The same
view built from getrawmempool verbose costs a reply of tens of MiB and
seconds of node CPU at a mainnet-sized mempool, essentially all of it discarded
by a caller drawing an aggregate — the per-entry depends and spentby arrays
alone are most of that payload.
size and bytes match getmempoolinfo. Row field names and semantics match
the getrawmempool verbose entry they derive from: ancestorcount and
descendantcount include the transaction itself, ancestorsize and
ancestorfees include its own vsize and fee. Bucket edges are half-open
[min_vsize, max_vsize) and travel with the counts, so a client labels its axis
from the response rather than hardcoding edges; the top bucket's max_vsize is
null. The method is read-only.
UTXO age distribution
gettxoutsetinfo adds utxo_age_distribution, the UTXO set counted by age in
blocks relative to the reply's height. On a regtest chain of 206 blocks, each
with one spendable coinbase output:
"utxo_age_distribution": {
"labels": ["<1h", "<1d", "<1w", "<1mo", "<6mo", "<1y", "<3y", "3y+"],
"counts": [6, 138, 62, 0, 0, 0, 0, 0],
"exact": true
}
The bucket edges are 6, 144, 1008, 4320, 25920, 51840 and 155520 blocks. The
counts sum to txouts. When exact is true the first four buckets are exact
counts. The last four are estimated from a per-1000-block histogram, to within
one 1000-block chunk at each edge. exact is false while the node is still
building its per-height window, a one-time background scan of the coins after
upgrading an existing datadir. Until then the first four buckets are estimated
the same way.
Silent-payment block data
getsilentpaymentblockdata "blockhash" ( verbosity dust_limit ) returns the
public BIP 352 tweak data for one block, from the tweak index
(-silentpaymentindex=1, default off). It is the JSON-RPC fallback for the
streaming tweaks category — the same bytes, for scripts, the
reference-implementation differential, and integrators not yet on an SDK.
verbosity 0(default) →{ "block_hash", "height", "tweaks": ["<33-byte hex>", …] }.verbosity 1→ each entry becomes{ "txid", "tweak", "max_value" }.dust_limit(sats, default0) drops entries whose largest taproot output value is below the floor.
Errors: -5 for an unknown or non-active block, -8 when the index is
disabled, and -1 when the block is not yet indexed at that height (the row is
absent — a height-by-height scanner cannot proceed past a gap, but unlike BIP
157 it cannot silently miss its own outputs either). The method is read-only. A
light client runs one ECDH per returned tweak locally, so the scan key never
reaches the node; for the streaming firehose with cursor replay, and the
integrator guide to every silent-payment consumption mode, see
Silent Payments (BIP 352).
OP_RETURN-pruned blocks
On a node running with pruneopreturn (see
OP_RETURN data pruning), old blocks
can hold transactions whose large OP_RETURN payloads were stripped from disk.
The RPCs read such a block through its strip record:
getblockat verbosity 1 is unchanged, txids and sizes included, and adds"opreturn_stripped": {"transactions": k, "outputs": m, "bytes": b}on a stripped block. At verbosity 0 a stripped block is refused with Core's-1 Block not available (pruned data).- A stripped transaction, in
getblock2 and 3 and verbosegetrawtransaction, has its realtxidandhash, nohex, and"opreturn_stripped": true. Each stripped output'sscriptPubKeyreads as the bareOP_RETURNit keeps (asmOP_RETURN,hex6a, typenulldata), plus"stripped_size", the original length. Fees and prevouts come from undo data and are unchanged. getrawtransactionhex, andgettxspendingprevout'sspendingtx, refuse a stripped transaction with-1 Transaction data not available (OP_RETURN data stripped).gettxoutproof,verifytxoutproof,getblockstatsandverifychainwork as on an intact block.- A reorg or
invalidateblockthat would disconnect a stripped block fails naming it;invalidateblockchecks first and changes nothing (-8). getblockfrompeerrestores a stripped block in place.
getopreturnpruneinfo (no arguments) reports the feature:
{
"enabled": true, "depth": 4320, "min_size": 4096,
"examined_below_height": 912345, "examine_target_height": 912345,
"blocks": 123, "transactions": 140, "outputs": 141,
"bytes_zeroed": 9876543, "bytes_freed_estimate": 9400000,
"peers": "limited", "worker": "running",
"filesystem_block_size": 4096, "probe": "ok",
"blocks_dir_apparent_bytes": 812345678901, "blocks_dir_allocated_bytes": 812336278901
}
Under restoreopreturns the response also carries restore, the download's
counts, and worker is one of restoring, waiting_for_peers,
waiting_for_disk, waiting_for_tip or restored:
"restore": {
"total_blocks": 123, "remaining_blocks": 40, "restored_blocks": 83,
"bytes_downloaded": 96000000, "remaining_bytes": 47000000,
"in_flight": 32, "peers": 2,
"progress_ratio": 0.675, "estimated_remaining_seconds": 240
}
progress_ratio is over the current pass. estimated_remaining_seconds is
null until the first block is restored, then the blocks left at the block
rate so far: a rough guide, since block sizes change along the chain. Once
the last block is restored, restore keeps the last pass's counts until the
node finishes.
getblockchaininfo carries the first nine of these, worker and restore
under opreturn_prune, only on a node that strips or still holds stripped
blocks.
Node attestation (NAP)
createnapattestation, submitnapattestation, listnapattestations and
removenapattestation publish coin-weighted statements signed by holders,
with -nap=1, and listnapaggregate lists those collected from publishers,
with -napaggregator=1. getnapreport reports the weight behind each value
of each key in the collected set, and replaynapsnapshot the same for a
snapshot that exportnapsnapshot wrote. They have a chapter of their own:
Node Attestation (NAP).
BIP 322 message verification
verifybip322 "address" "signature" "message" verifies a
BIP 322
generic signed message: a proof that whoever can spend address signed
message. Unlike verifymessage, which is Core's and covers P2PKH only, it
works for any address type, multisig and Taproot script paths included.
sat-cli verifybip322 "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l" "smpAkcwRAIgZRfIY…" "Hello World"
{ "result": "valid", "format": "simple", "version": 0, "locktime": 0, "sequence": 0 }
- Signature formats.
smp…(simple: a witness stack) andful…(full: the signedto_signtransaction) as the BIP defines them. An unprefixed string, which most wallets made before the BIP was finalized in 2026 still emit, is read as Core's legacy format for a P2PKH address and as simple for anything else. Proof-of-funds (pof…) is refused with-8. resultisvalid,invalid, orinconclusive: the proof is valid only under rules BIP 322 treats as upgradeable, such as an unknown witness version, anOP_SUCCESSleaf or a reserved NOP. Notvalidcomes with areason. A signature that does not decode isinvalid, not an error.version,locktime,sequenceare the BIP's "valid at time T and age S": a full proof for a timelocked script setsto_sign's locktime or input sequence, and the proof holds only from that time or age.verifybip322reports them and does not judge them against the chain. A legacy result has none.- Rules. Verification runs satd's script interpreter under the BIP's
required rules (SIGHASH_ALL only, or SIGHASH_DEFAULT for Taproot; no
OP_CODESEPARATOR; LOW_S, STRICTENC, NULLFAIL, MINIMALDATA, CLEANSTACK and MINIMALIF, all for every script type), then its upgradeable ones. It is checked against the BIP's published test vectors and btcd's negative vectors.
Errors: -5 for an address that is not valid on this network. The method
is read-only and needs no chain state.
Stratum
getstratuminfo reports the Stratum mining server. It takes no
arguments, is read-only, and answers on a node with the server off too — with
enabled: false, null listeners and zero counters — so a monitor can poll it
unconditionally.
{
"enabled": true,
"listeners": { "v1": "127.0.0.1:3333", "v1_tls": null, "v2": "0.0.0.0:3336" },
"authority_pubkey": "<32-byte x-only key, hex>",
"job_declaration": false,
"connections": 2,
"channels": 3,
"current_job": {
"height": 912345,
"job_id": "1a",
"prev_hash": "<hex>",
"template_txs": 3210,
"template_fees": 12345678
},
"shares": { "accepted": 412, "rejected": 3, "stale": 1 },
"blocks_found": 0,
"last_block": null,
"hashrate": 1210000000000.0,
"miners": [
{
"protocol": "v1",
"peer": "192.0.2.10:51234",
"channel_id": null,
"address": "bc1q…",
"worker": "rig1",
"device": "<user agent the firmware sent>",
"difficulty": 10000,
"connected_time": 1760000000,
"shares": { "accepted": 208, "rejected": 1, "stale": 0 },
"best_share_difficulty": 4812337.6,
"last_share_time": 1760006280,
"hashrate": 1210000000000.0
}
]
}
listenersare the bound addresses, with the real port when a bind used:0.authority_pubkeyis null without a Stratum V2 listener.channelscounts authorized Stratum V1 connections and open Stratum V2 channels.current_jobis the work being handed out, or null while none is (during initial block download).job_ididentifies the work within this server; the job ids a miner sees are per connection.sharescount since startup.staleis a share for a job that is no longer current;rejectedis every other refusal.blocks_foundcounts blocks found through the server that joined the active chain;last_blockis{ "height", "hash", "time" }for the most recent, or null.minerslists every miner connected now, oldest first: an authorized Stratum V1 connection, or a Stratum V2 channel (channel_idset). A miner leaves the list when it disconnects.addressis the payout address, or null when--stratumaddresspays;workeris the part of the username after the first., reduced to printable ASCII and at most 64 characters.deviceis the Stratum V1 user agent, or the Stratum V2 vendor, hardware version and firmware, reduced to printable ASCII; null if the miner sent none.difficultyis the share difficulty the miner is set to now.connected_timeandlast_share_time(null before the first accepted share) are Unix times.sharescount this miner's shares, as the node-widesharesdo.best_share_difficultyis the highest difficulty an accepted share's header achieved.hashrateis an estimate in hashes per second, from the difficulty of the shares accepted over the last ten minutes (see Verifying a miner).
- The top-level
hashrateis the sum overminers.
PSBT version 2 (BIP 370) and BIP 375
Every PSBT method — decodepsbt, analyzepsbt, combinepsbt,
joinpsbts, utxoupdatepsbt, finalizepsbt — accepts a version 2 PSBT
and answers with a version 2 PSBT. Bitcoin Core does not: it rejects any
PSBT whose PSBT_GLOBAL_VERSION is above zero. Version 0 behaviour is
unchanged, so nothing you already do moves.
Version 2 matters because BIP 375, which is how a wallet sends to a silent payment address through a PSBT, is version 2 only. Its fields are carried losslessly: anything satd does not understand comes back with the same bytes in the same place, which is what lets a PSBT pass through this node on its way between two wallets that do understand it.
BIP 375 is a draft. satd is refereed against the vectors the BIP publishes, and its field set has not changed since 0.1.0, but a draft can still change.
What decodepsbt returns
A version 2 PSBT decodes to a different shape from a version 0 one. Field names follow Bitcoin Core's own version 2 pull request wherever it has one.
{
"psbt_version": 2,
"tx_version": 2,
"fallback_locktime": 0,
"locktime": 0,
"input_count": 2,
"output_count": 2,
"inputs_modifiable": false,
"outputs_modifiable": false,
"has_sighash_single": false,
"silent_payments": { // only when a BIP 375 global field is present
"global_shares": [
{ "scan_key": "02…", "ecdh_share": "03…", "dleq_proof": "…" }
]
},
"unknown": { }, // global pairs satd does not recognise
"inputs": [
{
"previous_txid": "…",
"previous_vout": 0,
"sequence": 4294967293,
"witness_utxo": { "amount": 0.001, "scriptPubKey": { "hex": "0014…" } },
"has_utxo": true,
"is_final": false,
"sp_shares": [
{ "scan_key": "02…", "ecdh_share": "03…", "dleq_proof": "…" }
],
"unknown": { }
}
],
"outputs": [
{
"amount": 0.0009,
"script": { "hex": "5120…" }, // absent while it has not been computed
"silent_payment": { "scan_key": "02…", "spend_key": "03…", "label": 3 },
"unknown": { }
}
],
"tx": { }, "tx_hex": "…", // only when every output has a script
"unique_id": "…", // BIP 370's identifier for this PSBT
"fee": 0.0001 // as in version 0: null unless every input's UTXO is known
}
An output's script is absent, not empty, while a silent payment
script is still being computed. The two mean different things, and a
PSBT library that renders the second as the first describes an output
paying nobody. For the same reason tx and tx_hex appear only once
every output has a script.
amount honours the per-request amounts=sats|btc switch, as elsewhere.
A share whose DLEQ proof is missing keeps its row with a null
dleq_proof rather than disappearing: a missing proof is the thing a
reader most needs to see.
Creating one: createpsbt psbt_version=2
createpsbt takes a trailing optional psbt_version. It defaults to 0, so
every existing call is unaffected, and it sits after Core's own
replaceable and version arguments so a positional call written against
Core keeps meaning what it meant.
sat-cli createpsbt \
'[{"txid":"…","vout":0}]' \
'[{"tsp1q…":0.25},{"bcrt1q…":0.2499}]' \
0 null null 2
An sp1… (or tsp1…) output key is only accepted with psbt_version=2.
Without it the node refuses by name — silent payment recipients need a version 2 PSBT; pass psbt_version=2 — rather than building a PSBT that
cannot carry the recipient, so a caller whose signer speaks only version 0
learns at creation instead of at signing. createrawtransaction refuses an
sp1… key outright and says why: a raw transaction has nowhere to put the
information a Signer needs.
Two details worth knowing.
- Use the array form of
outputswhen order matters. A JSON object's key order is the serialiser's business, and the output order decides both the transaction and each silent payment output'sk. - A repeated
sp1…key is legal, unlike a repeated ordinary address. Two payments to one recipient getk = 0andk = 1, and each keeps its own amount.
The PSBT comes back with PSBT_GLOBAL_TX_MODIFIABLE saying both inputs and
outputs may still be added, and the silent payment output carrying its two
keys and no script. Computing that script is the Signer's job, because
computing it is what freezes the transaction.
decodepsbt and analyzepsbt spell the recipient back as the sp1… address
it came from. The PSBT itself carries only the two keys, so this is the only
way an operator can check the recipient against what they were given.
Where satd is stricter than Core's version 0 rules
combinepsbtrefuses a conflict. Where two PSBTs give the same key different values, satd refuses and names the field. Core keeps whichever it saw first. For a BIP 375 ECDH share that value decides where the money goes.joinpsbtsrefuses a committed silent payment PSBT. Joining adds inputs, and every silent payment output script derives from the input set. A PSBT whose scripts are already computed, whose global ECDH share is set, or which does not allow inputs and outputs to be added is refused by name.finalizepsbtis the Transaction Extractor. It recomputes every silent payment output script and refuses unless each one verifies and matches. There is no override flag, andextract=falseis gated too.
analyzepsbt reports a silent_payments object with a per-output
verdict; see Sending in the Silent
Payments chapter for the statuses and what satd checks to reach them. A
verified: false object carries a reason and means satd could not run
the checks — it never means they passed.
Client-side PSBT signing (no signing RPC)
There is no signing method: satd never handles private keys. Signing is a
client-side sat-cli command.
sat-cli signpsbtwithkey reads a WIF private key or a BIP-32 xpriv from
stdin, prompting without echo when stdin is a terminal. It signs the PSBT
entirely locally, using only the prevout data already carried in the PSBT.
It covers the common single-sig script types (Legacy, SegWit v0, nested
SegWit, and Taproot key-path) and writes partial_sigs / tap_key_sig for
the node's finalizepsbt to assemble, rather than finalizing itself. An
xpriv is expanded over the standard BIP 44/49/84/86 paths, so it can sign
PSBTs that carry no derivation metadata, including satd's own createpsbt
output. The key never crosses the JSON-RPC boundary, so satd stays strictly
keyless.
A version 2 PSBT is signed too, and signing one means doing BIP 375's Signer work first. See Sending in the Silent Payments chapter.
sat-cli signpsbtwithsigner passes a version 2 PSBT to an external
signer untouched, because for a silent payment the only party that can
compute an ECDH share is the one holding the input's private key — a
device is exactly who should be asked, and sat-cli cannot know which
devices have learned BIP 375. What comes back is checked with the same
code the node runs: a reply describing a different transaction, one
whose silent payment script is not what its own shares derive to, or one
carrying a signature while an output still has no script, is refused
rather than emitted. A signer that simply does not understand the PSBT
has its own message relayed, with one line saying the device must
support BIP 375.