Skip to content

Forward-port the 0.6.1 fixes to master - #964

Merged
bkeroack merged 24 commits into
masterfrom
fwdport/0.6.1-fixes
Oct 15, 2026
Merged

bkeroack merged 24 commits into
masterfrom
fwdport/0.6.1-fixes

Conversation

@bkeroack

Copy link
Copy Markdown
Contributor

Brings the 0.6.1 fixes from release/0.6 to master: 24 commits, each cherry-picked with -x and keeping its original subject, in the order they landed on release/0.6. #959 (3067a487) is not ported because it only touches the release notes. Everything up to 33902a46 was already on master (#921, #925, #926, #928, #934).

Please merge this PR with a rebase-merge, not a squash. That keeps one commit per original with its (cherry picked from commit …) line.

Release notes

The CHANGELOG.md bullets and the docs/release-notes/0.6.1-pre.md sections stay on release/0.6, as in #921, #926, #928 and #934. They will reach master with the 0.6.1 cut. Each pick that touched those paths keeps master's version of them. No ported change contradicts docs/release-notes/0.7.0-pre.md, so this PR leaves that file alone. The CORE_DIFFERENCES.md and Operator Manual changes are ported.

Patch identity

For each pair, I compared the git patch-id --stable of the original and of the pick, over every path except CHANGELOG.md and docs/release-notes/:

release/0.6 here patch-id
6be5669a #936 012f9e9f identical
e431f4ff #941 dc00f7ed identical
104b5b00 #945 b8b94de0 identical
c78d0963 #937 3a359c5f differs (1)
76cd867b #938 c5934964 identical
b66aa269 #939 27a6cce2 identical
7ff0c67b #933 7a9d9586 identical
aa95d9f6 #940 9ef914a4 identical
2958b919 #943 1590bb40 identical
c50f8ab6 #947 52b670f8 differs (2)
e2e40ca0 #944 d0fda700 identical
77e7fbd7 #952 a150719a identical
6a18e0f3 #953 1c517f89 identical
ee057982 #954 79607f71 identical
97298b0d #942 896f670d differs (3)
fbad3bd1 #946 5904571c differs (4)
b5a18d42 #955 6723d87d identical
b311bd1e #948 a12b2859 identical
b296fb44 #957 3bbd00dc identical
43fede28 #958 a7ef9cc0 identical
1899cf22 #960 4ff8b624 identical
44848468 #961 05703c57 identical
7b04ff43 #962 8931a246 identical
ab5a73b8 #963 bcba9beb identical

All four differences come from one source. On master, the Core-compatible ZMQ work (#929) gave every mempool removal a sequence number, Mempool::accept_transaction_bypass_fee now returns (Txid, u64), and #935 added a lead argument to the admission path. release/0.6 has none of these.

  1. Count sigops exactly as Bitcoin Core does #937, node/src/mempool/pool_sigops_tests.rs: the new fee-bypass sigop test destructures (txid, _) from accept_transaction_bypass_fee. No production code differs, and the commit applied without conflicts.
  2. Electrum/Esplora: run index reads off the runtime workers, make request timeouts fire, page /txs/chain from the cursor #947, node/src/index/address/notifier_electrumbounds_tests.rs: the LeaveEvicted the test builds now sets mempool_sequence: 0. No production code differs, and the commit applied without conflicts.
  3. mempool: verify scripts before evicting, and make eviction linear #942, node/src/mempool/pool.rs (conflict) and pool_fullpool_tests.rs:
    • Eviction (evict_lowest_fee_entries_except) returns (txid, scope, sequence) instead of (txid, scope).
    • emit_evicted puts the sequence on each acting entry's LeaveEvicted.
    • evict_lowest_fee_entries keeps master's (txid, sequence) return type for its existing callers: the policy reload and tests.
    • On a successful admission, events still go out in master's order: the lead events (Announce a package's ephemeral-dust parent before its child #935) first, then the evictions, then the replacements.
    • The fix's behaviour is unchanged. Scripts are checked before anything is evicted. The incoming transaction's ancestors are never evicted. The pool is trimmed to its limit. A refusal after eviction reports the entries it evicted. The -maxmempool reload trims the pool. Only acting entries get a LeaveEvicted.
    • Like master's RBF path, a held entry takes a sequence number but emits nothing.
    • In the tests, a LeaveEvicted pattern gains .., and two evict_lowest_fee_entries calls map (txid, _).
  4. mempool RPCs: match Bitcoin Core's getmempoolentry, getmempoolinfo, gettxout and sendrawtransaction #946, node/src/mempool/pool.rs (conflict) and pool_mempoolrpc_tests.rs:
    • accept_transaction_unexpired takes both master's lead and the fix's entry_time. accept_transaction_led passes None, and the new accept_persisted_transaction passes Vec::new() and the persisted time.
    • The function now has eight parameters, so it carries #[allow(clippy::too_many_arguments)] with a comment.
    • remove_for_block now also removes a block conflict's descendants. Each one records the sequence number its removal took, as master already did for the direct conflict.
    • remove_expired keeps master's (txid, sequence) list and adds the fix's cutoff and time floor.
    • In the test, a LeaveEvicted pattern gains ...

The other 20 picks applied cleanly, apart from the release-notes paths.

Code that exists only on master

I compared release/0.6 with this branch, leaving out the release notes. The only code that differs is master's own work: ZMQ (#929), the ephemeral-dust parent announcement (#935) and the version bump. node/src/net/, node/src/storage/, node/src/index/, node/src/chain/ (apart from one test and the chain-event channel capacity) and every RPC handler apart from getzmqnotifications are byte-identical to release/0.6. So:

Follow-up left out of scope

The sequence numbers mentioned above predate this PR and are not changed here. On master, a quarantined entry that leaves the pool still takes a mempool sequence number with no event. That happens in RBF, in admission eviction, and now also for a held descendant of an evicted or block-conflicting entry. A sequence ZMQ subscriber sees that as a gap. Whether held entries should take numbers at all is a separate question for the ZMQ design.

Verified

Ran locally on this branch:

  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo build --tests --features e2e
  • cargo test -p node --lib: 2437 passed
  • node integration tests feature_block_consensus and rpc_auth_failure; consensus, electrum-proto, esplora-handlers, node-index, sat-cli, satd-auth and the satd binaries
  • satd targets submitblock_order, p2p_addr_dials, p2p_limits, p2p_punish, mempool_rpc, local_hardening, rpc_parity, compact_blocks and zmq: all passed
  • satd --test regtest, filtered to filter, cfheader, mempool, reload, address_index, reorg and submitblock: 72 passed and 11 failed. All 11 failures are test_address_index_backfill_*. backfillindex refused to start because /tmp had about 79 GB free and it needs 80 GB. That limit only exists on the local machine, and CI runs these tests.
  • satd --features e2e --test e2e, filtered to electrum and esplora: 43 passed

On release/0.6, every original passed CI and a Devin review before it merged.

🤖 Generated with Claude Code

bkeroack and others added 24 commits October 15, 2026 08:12
…e as an invalid mark (#936)

* Check a block's merkle root first, and mark only what Core marks

check_block tested for an empty transaction list before comparing the
merkle root, so an empty body sent for a real header failed
bad-blk-length rather than Core's bad-txnmrklroot. accept_block then
marked the header's block Invalid for that and for every other
non-mutation check_block failure. Core runs CheckMerkleRoot first
(validation.cpp CheckBlock), and ProcessNewBlock never marks a block
for a CheckBlock failure; of the rules in satd's check_block, Core
marks only bad-blk-weight, from ContextualCheckBlock.

A block that extends the tip and arrived without its header first had
no index entry until it connected, so a failed connect marked nothing
and each copy sent again was validated and written again. accept_block
now writes the HeaderOnly row a header announcement would leave before
connecting, as Core's AcceptBlockHeader creates the index entry before
ConnectTip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* accept_block: say what a non-verdict failure leaves for an unannounced block

The comment on the HeaderOnly row written before connecting said every
outcome matched the headers-first path. A failure that is not a verdict
on the block does not move the best-header pointer, which an announced
header would have done. Say so, and say what reaches the block then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6be5669)
A block whose parent had no index entry was parked in the block
processor's buffer keyed by its prev hash: up to 8192 entries with no
byte bound, no per-peer bound and no expiry, whether or not this node had
asked for the block, before any proof-of-work or size check. Each entry
kept its sender's in-flight guard, so that peer's pongs waited for the
60-second bound until the block's parent connected, and the block went
through the per-input fee pass before accept_block refused it.

Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader
(validation.cpp:4206) and then refuses an unknown parent as
prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a
getdata, so a block it asked for can arrive before the parent it is still
learning about, and keeping that one saves a second download.

Now:
- Only a block this node asked for (Core's IsBlockRequested, read in
  handle_block before the request record is cleared) may wait.
- A waiting block must pass check_proof_of_work_bounded and be at most
  MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes.
- At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest
  goes first when it is at its share, then the oldest of all. Each block
  expires after 5 minutes.
- The in-flight guard is counted out when the block is set aside, as Core
  is done with it once refused.
- A block whose parent is unknown skips the fee pass and accept_block.
- The sender is asked for headers, as satd already does for a cmpctblock
  on an unknown parent.
- A block on a parent marked invalid is not kept.
- Two blocks on the same parent can both wait; when one fails to connect
  the next is tried.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e431f4f)
* Check a block's merkle root first, and mark only what Core marks

check_block tested for an empty transaction list before comparing the
merkle root, so an empty body sent for a real header failed
bad-blk-length rather than Core's bad-txnmrklroot. accept_block then
marked the header's block Invalid for that and for every other
non-mutation check_block failure. Core runs CheckMerkleRoot first
(validation.cpp CheckBlock), and ProcessNewBlock never marks a block
for a CheckBlock failure; of the rules in satd's check_block, Core
marks only bad-blk-weight, from ContextualCheckBlock.

A block that extends the tip and arrived without its header first had
no index entry until it connected, so a failed connect marked nothing
and each copy sent again was validated and written again. accept_block
now writes the HeaderOnly row a header announcement would leave before
connecting, as Core's AcceptBlockHeader creates the index entry before
ConnectTip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bound the unknown-parent block buffer

A block whose parent had no index entry was parked in the block
processor's buffer keyed by its prev hash: up to 8192 entries with no
byte bound, no per-peer bound and no expiry, whether or not this node had
asked for the block, before any proof-of-work or size check. Each entry
kept its sender's in-flight guard, so that peer's pongs waited for the
60-second bound until the block's parent connected, and the block went
through the per-input fee pass before accept_block refused it.

Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader
(validation.cpp:4206) and then refuses an unknown parent as
prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a
getdata, so a block it asked for can arrive before the parent it is still
learning about, and keeping that one saves a second download.

Now:
- Only a block this node asked for (Core's IsBlockRequested, read in
  handle_block before the request record is cleared) may wait.
- A waiting block must pass check_proof_of_work_bounded and be at most
  MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes.
- At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest
  goes first when it is at its share, then the oldest of all. Each block
  expires after 5 minutes.
- The in-flight guard is counted out when the block is set aside, as Core
  is done with it once refused.
- A block whose parent is unknown skips the fee pass and accept_block.
- The sender is asked for headers, as satd already does for a cmpctblock
  on an unknown parent.
- A block on a parent marked invalid is not kept.
- Two blocks on the same parent can both wait; when one fails to connect
  the next is tried.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* accept_block: say what a non-verdict failure leaves for an unannounced block

The comment on the HeaderOnly row written before connecting said every
outcome matched the headers-first path. A failure that is not a verdict
on the block does not move the best-header pointer, which an announced
header would have done. Say so, and say what reaches the block then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Stop on a coin read error instead of treating the coin as missing

RocksDbStore turned a failed coin read into "no such coin": get_coin
did `.ok()??`, get_coins_batch `.ok().flatten()` and has_coin matched
only `Ok(Some(_))`. A row that did not decode was logged and returned as
`None` too. connect_block takes a missing coin as a verdict on the block
that spends it (bad-txns-inputs-missingorspent), and accept_block and
the reorg path then record that block as invalid, a record that
survives a restart.

Bitcoin Core reads coins through CCoinsViewErrorCatcher, which logs
"Error reading from database" and calls std::abort() on any read error
(coins.cpp:398-412), because returning would be "interpreted as 'entry
not found'".

Each coin read now goes through one helper: a missing row is `None`, a
read error or an undecodable row stops the node with abort(), as Core
does and as satd's other fail-stops do. A test build panics instead so
the path can be tested. The Operator Manual says what the stop looks
like and what to do after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Manual: an unreadable coin stops the offline audit too

satd-chainstate-audit looks coins up through the same store, so a coin
the database cannot read now stops it with the outpoint and the RocksDB
error instead of listing the coin as missing. Say so beside its exit
statuses, and say where the stop applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 104b5b0)
satd counted sigops with rust-bitcoin 0.32's `Script::count_sigops` and
`Transaction::total_sigop_cost`. In accurate mode those keep the last
OP_1..OP_16 across later OP_CHECKSIG and OP_CHECKMULTISIG opcodes, where
Core's `GetSigOpCount` reads the opcode immediately before each multisig
opcode (src/script/script.cpp). An OP_CHECKMULTISIG that followed another
signature opcode took the earlier number instead of 20, so P2SH redeem
scripts and P2WSH witness scripts could count lower than Core counts them
(`OP_1 OP_CHECKSIG OP_CHECKMULTISIG`: 2 instead of 21). This is
rust-bitcoin issue #6368; no released rust-bitcoin version has the fix.

Add `validation::sigops`, a port of Core's `GetSigOpCount(fAccurate)`,
`GetSigOpCount(scriptSig)`, `CountWitnessSigOps`, `GetLegacySigOpCount`,
`GetP2SHSigOpCount` and `GetTransactionSigOpCost`, with Core's script
parsing, and Core's sigopcount_tests, script_p2sh_tests and
transaction_tests vectors. Use it for the block limit in `connect_block`
and `check_block`, the cost each mempool entry records (which the block
template budgets by and getblocktemplate reports), the policy engine's
`tx.sigops_cost`, and the Stratum coinbase bound.

The submitpackage fee-bypass path recorded a sigop cost of 0 and no
prevout metadata for the parents it admits, so the template counted them
as free. It now records what `accept_transaction` records, through one
shared helper.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c78d096)
The status satd announced (the blockchain.scripthash.subscribe answer and
every notification) re-sorted the history by (height, txid), which put
the mempool rows (height 0 and -1) before every confirmed row.
get_history listed confirmed rows first and then mempool rows by Txid's
Ord, with 0 and -1 mixed. The Electrum protocol hashes "tx_hash:height:"
over the history in the order get_history returns it: confirmed by
height and position in the block, then mempool by (-height, tx_hash).
A client checks an announced status by hashing get_history as received,
so for any address with confirmed and unconfirmed history at once the
two never matched, and Electrum's synchronizer disconnects on that.

Build the history in one place, node_index::history, in protocol order,
and use it for the subscribe answer, the notifier, get_history and
get_mempool. The status hashes rows in the order given. Confirmed rows
in one block are ordered by their chain ordinal (Store::get_tx_seq),
looked up only for blocks holding more than one of the script's
transactions. Mempool rows sort height 0 before -1, then by display-hex
txid, as ElectrumX does. A mempool-index row whose transaction has
already left the mempool is left out, as electrs does, instead of being
listed with a guessed height and no fee.

Subscribing now records the answered status in the subscription
registry, so the next event that leaves it unchanged does not push it
again. The registry takes it only while the subscribing connection is
the scripthash's sole receiver and no status is recorded yet.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 76cd867)
…ds per peer (#939)

* p2p: apply Core's message size and count limits, and bound queued sends per peer

- v1 frames are capped at Core's MAX_PROTOCOL_MESSAGE_LENGTH (4,000,000
  bytes) instead of 32 MiB. The v1 and v2 receive buffers grow as payload
  bytes arrive, at most 256 KiB ahead of them (V1Transport::readData,
  V2Transport MAX_RESERVE_AHEAD), and a large v1 buffer is released after
  its message.
- inv/getdata over MAX_INV_SZ (50,000), headers over MAX_HEADERS_RESULTS
  (2000) and addr/addrv2 over MAX_ADDR_TO_SEND (1000) are misbehaviour, as
  in Core; a notfound over 5016 entries is ignored.
- A peer's queue counts its bytes. getdata is served as Core's
  ProcessGetData does: entries are kept in a per-peer backlog and served
  until the queue passes 1,000,000 bytes (-maxsendbuffer's default), and
  the rest as it drains. The peer's next message is not read while a
  getdata of its is being served or its queue is over the limit
  (ProcessMessages). -maxuploadtarget is charged only for blocks queued.
- The write loop watches the disconnect signal during a write, and drops
  a peer whose socket takes no bytes for 20 minutes (TIMEOUT_INTERVAL,
  "socket sending timeout"). Sent bytes are counted as the socket takes
  them.
- A version whose user agent is over MAX_SUBVERSION_LENGTH is dropped, as
  Core's LIMITED_STRING read does; the user agent and unknown message
  types are sanitized with Core's SanitizeString before they are stored
  or logged.
- getcfilters/getcfheaders/getcfcheckpt requests that Core's
  PrepareBlockFilterRequest refuses now disconnect instead of being
  ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: wake the manager when a getdata or a getdata resume is handed over

The write loop reads nothing more from a peer until its getdata has been
served, so the manager's 500 ms drain tick bounded a peer fetching blocks
to about one getdata, or one send buffer's worth of a large one, per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: keep a getdata cmpctblock entry that could not be queued

serve_compact_block reported a MSG_CMPCT_BLOCK entry as answered even
when its cmpctblock could not be queued, so the entry left the getdata
backlog with nothing sent. It now says whether the message was queued,
and an entry that was not stays first, as every other entry does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b66aa26)
)

The Rust interpreter's OP_SUCCESSx set left out 0x80, 0x81 and 0x83-0x86
(OP_LEFT, OP_RIGHT, OP_INVERT, OP_AND, OP_OR, OP_XOR). In a tapscript
leaf those are OP_SUCCESSx under BIP342 and Bitcoin Core's IsOpSuccess
(src/script/script.cpp:364-370), so a leaf containing one is valid. The
Rust engine instead evaluated the leaf and failed it as a disabled
opcode. libbitcoinconsensus already had Core's set. is_op_success now
uses Core's ranges as written.

Pay-to-anchor (witness v1, program 0x4e73, not P2SH-wrapped) is now
exempt from DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM, as in Core's
VerifyWitnessProgram (src/script/interpreter.cpp:1990). satd passes no
policy flags to the interpreter today, so this changes no current
verdict.

Tests:
- every opcode checked against Core's OP_SUCCESS list, and every opcode
  as a tapscript leaf (alone and in an unexecuted IF) compared with
  libbitcoinconsensus;
- the five taproot rows of Core's script_tests.json now run in the
  script_tests and differential harnesses instead of being skipped;
- a port of Core's script_assets_test, run on a committed 225-vector
  subset of Core's qa-assets script_assets_test.json, and on the full
  file when DIR_UNIT_TEST_DATA points at it (as in Core);
- pay-to-anchor with and without the policy flag, bare and P2SH-wrapped.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7ff0c67)
…s, no request dumps in trace logs (#940)

* tor: read the SAFECOOKIE cookie file with a size bound, regular files only

The control port names the cookie file in its PROTOCOLINFO reply, and
satd read it with fs::read, to end of file and without checking what it
was. Bitcoin Core reads it with ReadBinaryFile(cookiefile,
TOR_COOKIE_SIZE) (src/torcontrol.cpp:616), which never reads more than
32 bytes.

satd now opens the file non-blocking, refuses anything that is not a
regular file, and reads at most 33 bytes, so /dev/zero or a FIFO named
as the cookie fails authentication at once instead of filling memory or
blocking startup in open(2). A file longer than 32 bytes is still
refused, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read bitcoin.conf comments as Core does; keep request dumps and secrets out of logs

bitcoin.conf: satd skipped only lines that start with `#`, so a comment
after a value became part of it. `txindex=1 # c` left txindex off,
`rpcport=8332 # c` fell back to the default port, and
`rpcpassword=pw # c` set the password to the whole string, all without
a warning. Bitcoin Core's GetConfigOptions (src/common/config.cpp:41-62)
drops everything from the first `#`, trims " \t\r\n", and refuses an
rpcpassword line holding a `#` ("using # in rpcpassword can be ambiguous
and should be avoided"). satd's parser and sat-cli's now do the same,
with the same message.

Trace logging: under -loglevel=trace (or RUST_LOG=trace) jsonrpsee-server
logged every HTTP request with its headers, the Authorization header
included, before satd's auth layer ran (jsonrpsee-server 0.26
src/server.rs:1026), and jsonrpsee-core logs call parameters; tungstenite
logs WebSocket payloads. Core logs no credentials or parameters at any
level. A global filter layer now drops TRACE output from the jsonrpsee
and tungstenite targets whatever the reloadable filter allows; their
DEBUG output is unchanged.

Debug: Config, RpcAuthEntry, the reorg webhook target and the satd-auth
operator credentials (behind RpcAuth) derived Debug over their secrets.
Their Debug output now names them without the password, cookie token,
rpcauth salt and tag, or webhook secret. Nothing printed them today.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit aa95d9f)
…t empty witness and unknown fee targets (#943)

* Esplora: page /txs with after_txid, bound block pages, omit empty witness and unknown fee targets

Four answers differed from upstream Esplora (Blockstream's and
mempool.space's electrs, src/rest.rs):

- /address/:addr/txs and /scripthash/:hash/txs ignored ?after_txid=, so a
  client paging with mempool.space's cursor got the first page on every
  request. They now continue after the cursor: the next 25 confirmed for a
  confirmed cursor (the /txs/chain/:last_seen_txid page), or the mempool
  transactions after it and then the first 25 confirmed for a mempool
  cursor. A cursor in neither list is 422 "after_txid not found", as
  mempool.space answers; a malformed one is 400 and an empty one is the
  first page. A script's mempool transactions are now listed in the order
  the node admitted them (then by txid), the order upstream keeps them in,
  instead of HashSet order, so a mempool cursor is stable between requests.
- /block/:hash/txs/:start_index returned [] at or past the end and served
  any start. It now answers 404 "start index out of range" there and 400
  "start index must be a multiple of 25" for an unaligned start, as
  mempool.space does (blockstream.info answers 400 for both).
- Every legacy input carried "witness": []. Upstream sets witness only
  when the input has one; satd now skips the empty array.
- /fee-estimates reported the relay floor for every target when the node
  had no fee signal (empty mempool, no confirmed-block fee history, as in
  IBD or right after startup). Upstream leaves out each target bitcoind
  cannot estimate and answers {} when there is none. satd now leaves out
  the Confidence::Low targets, which in blend mode are exactly the ones
  that fell back to the floor.

The Esplora manual chapter documented the old answers; it now describes
these.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora /txs: clone only the served mempool transactions; list a just-confirmed one once

Ordering a script's mempool transactions by admission time fetched every
one of them with Mempool::get, which clones the whole entry and its
transaction, where the earlier code fetched at most the 50 it served. The
order now comes from Mempool::admission_times, which reads the times of the
given txids under one read lock without cloning anything, and only the
transactions on the page are fetched.

Between a block connecting and the mempool dropping its transactions, a
transaction can be in a script's mempool list and in its confirmed history
at once. /txs listed it in both parts, and an after_txid naming it resolved
in the mempool list, so the page restarted the confirmed history at the
cursor itself. It is now listed once, as confirmed, as Blockstream's
electrs does (prepare_history), and a cursor is looked up in the confirmed
history first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2958b91)
…st timeouts fire, page /txs/chain from the cursor (#947)

* Electrum: hash scripthash status in history order

The status satd announced (the blockchain.scripthash.subscribe answer and
every notification) re-sorted the history by (height, txid), which put
the mempool rows (height 0 and -1) before every confirmed row.
get_history listed confirmed rows first and then mempool rows by Txid's
Ord, with 0 and -1 mixed. The Electrum protocol hashes "tx_hash:height:"
over the history in the order get_history returns it: confirmed by
height and position in the block, then mempool by (-height, tx_hash).
A client checks an announced status by hashing get_history as received,
so for any address with confirmed and unconfirmed history at once the
two never matched, and Electrum's synchronizer disconnects on that.

Build the history in one place, node_index::history, in protocol order,
and use it for the subscribe answer, the notifier, get_history and
get_mempool. The status hashes rows in the order given. Confirmed rows
in one block are ordered by their chain ordinal (Store::get_tx_seq),
looked up only for blocks holding more than one of the script's
transactions. Mempool rows sort height 0 before -1, then by display-hex
txid, as ElectrumX does. A mempool-index row whose transaction has
already left the mempool is left out, as electrs does, instead of being
listed with a guessed height and no fee.

Subscribing now records the answered status in the subscription
registry, so the next event that leaves it unchanged does not push it
again. The registry takes it only while the subscribing connection is
the scripthash's sole receiver and no status is recorded yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora: page /txs with after_txid, bound block pages, omit empty witness and unknown fee targets

Four answers differed from upstream Esplora (Blockstream's and
mempool.space's electrs, src/rest.rs):

- /address/:addr/txs and /scripthash/:hash/txs ignored ?after_txid=, so a
  client paging with mempool.space's cursor got the first page on every
  request. They now continue after the cursor: the next 25 confirmed for a
  confirmed cursor (the /txs/chain/:last_seen_txid page), or the mempool
  transactions after it and then the first 25 confirmed for a mempool
  cursor. A cursor in neither list is 422 "after_txid not found", as
  mempool.space answers; a malformed one is 400 and an empty one is the
  first page. A script's mempool transactions are now listed in the order
  the node admitted them (then by txid), the order upstream keeps them in,
  instead of HashSet order, so a mempool cursor is stable between requests.
- /block/:hash/txs/:start_index returned [] at or past the end and served
  any start. It now answers 404 "start index out of range" there and 400
  "start index must be a multiple of 25" for an unaligned start, as
  mempool.space does (blockstream.info answers 400 for both).
- Every legacy input carried "witness": []. Upstream sets witness only
  when the input has one; satd now skips the empty array.
- /fee-estimates reported the relay floor for every target when the node
  had no fee signal (empty mempool, no confirmed-block fee history, as in
  IBD or right after startup). Upstream leaves out each target bitcoind
  cannot estimate and answers {} when there is none. satd now leaves out
  the Confidence::Low targets, which in blend mode are exactly the ones
  that fell back to the floor.

The Esplora manual chapter documented the old answers; it now describes
these.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Electrum: run requests on the blocking pool so the request timeout fires

Every Electrum handler is synchronous: it reads the address index,
RocksDB and the flat files inline. The connection task ran it inside
tokio::time::timeout(async { process_request(..) }), a future with no
await point, so the dispatch finished on its first poll and the timeout
could never fire. A long request held an API-runtime worker for as long
as it took, and with --api-threads at its small-box default of two, two
such requests stalled every Electrum connection, Esplora, events gRPC
and /metrics with it.

Each request now runs under spawn_blocking with the timeout around the
join. Past --electrumrequesttimeout the client gets the existing
"request timed out" error and the connection is closed: the handler's
work cannot be interrupted and the connection's subscriptions are
inside it. The connection task keeps its slot until that work ends, so
reconnecting after every timeout cannot run more handlers at once than
--electrummaxconns allows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora /txs: clone only the served mempool transactions; list a just-confirmed one once

Ordering a script's mempool transactions by admission time fetched every
one of them with Mempool::get, which clones the whole entry and its
transaction, where the earlier code fetched at most the 50 it served. The
order now comes from Mempool::admission_times, which reads the times of the
given txids under one read lock without cloning anything, and only the
transactions on the page are fetched.

Between a block connecting and the mempool dropping its transactions, a
transaction can be in a script's mempool list and in its confirmed history
at once. /txs listed it in both parts, and an after_txid naming it resolved
in the mempool list, so the page restarted the confirmed history at the
cursor itself. It is now listed once, as confirmed, as Blockstream's
electrs does (prepare_history), and a cursor is looked up in the confirmed
history first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address index: page a confirmed history newest first from the cursor

Esplora's /txs/chain/:last_seen_txid read and resolved a script's whole
confirmed history (every row's ordinal through the reverse map) for
every page, then searched it for the cursor, so walking an
N-transaction history 25 at a time cost O(N^2) reads.

Store::addr_rows_desc reads a script's address rows newest first from
an ordinal down, without resolving them: RocksDB seeks backwards over
both address families within the key prefix, the CoinCache merges its
unflushed rows in the way the flush will write them, and a run never
ends between two rows of one transaction. The address index uses it
for AddressIndex::confirmed_txs_newest_first, which starts at the end
of the cursor's block (its block-mates sort by txid on either side of
it) and stops once the page holds only settled blocks. The page order
is unchanged: height descending, then txid descending. The trait's
default keeps the whole-history read for other implementations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora: page /txs/chain from the cursor and run address reads off the runtime

The address routes did their RocksDB and flat-file reads inline in the
async handler. A busy script's history held an API-runtime worker
(shared with Electrum, events gRPC and /metrics) for as long as the
read took, and the request TimeoutLayer could not answer until it
ended, since a future that never yields cannot be dropped early.
/txs/chain/:last_seen_txid also read the whole history for every page.

- Every /address and /scripthash route now runs its reads on the
  blocking pool. The worker stays free and --esplorarequesttimeout
  answers 503 on time. A read cannot be stopped once started, so it
  holds a permit until it ends, even after its request timed out; the
  permits are sized from --esploramaxconns, so retrying after every
  timeout cannot pile up running reads.
- /txs/chain[/:last_seen_txid] and the confirmed part of /txs (including
  ?after_txid=) use AddressIndex::confirmed_txs_newest_first, which
  reads from the cursor down instead of resolving the whole history.
  Answers and order are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address index: run status recomputes on the blocking pool, one per queued batch of blocks

Every block event made the chain notifier recompute the status of every
subscribed scripthash, and every mempool event touching a subscription
made the mempool index task recompute those: each recompute reads a
history from the index, synchronous RocksDB work as long as the busiest
subscribed history. Both tasks run on the core runtime, so a set of
heavy subscriptions held a core worker (the one connecting blocks and
serving P2P) for the length of that read on every block.

- The recomputes, and the mempool task's lagged resync, now run under
  spawn_blocking. The tasks still take their events in order and wait
  for each recompute, so index mutation and notification keep their
  order; the core workers only wait.
- The chain notifier takes every block event already queued before a
  pass and runs one pass for them: the pass reads the index as it then
  stands, so it covers them all. During IBD or a reorg with subscribers
  that was one full pass per block.
- A mempool event looks up only the scripthashes it touched in the
  registry (SubscriptionRegistry::subscribed) instead of building the
  set of every subscription, and skips the thread hop when none is
  subscribed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Electrum: document the subscription caps, and log when the server-wide one fills first

blockchain.scripthash.subscribe is bounded twice: per connection
(--electrummaxsubsperconn, default 1000) and server-wide
(--addrindexsubscriptions, default 10000, shared with Esplora's address
SSE streams). With the defaults ten connections at their own cap fill
the server-wide registry while 54 of the 64 connection slots are still
free, and every other client's subscribe is then refused. Neither the
manual nor the log said so.

satd now logs at startup how many connections at the per-connection cap
fill the registry, when that is fewer than --electrummaxconns, and what
--addrindexsubscriptions would let every connection reach its own cap.
The Electrum chapter gets a "Subscription limits" section with the same
sizing, the manual describes the request timeout's new behaviour, and
the Esplora chapter's high-history note describes the paged /txs/chain
reads and the blocking-pool address routes. No default changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora /txs: drop a mempool txid the confirmed page already lists

The mempool part of /txs is filtered by a per-txid confirmed check, and
the first confirmed page is read afterwards. A transaction that
confirmed between the two could be listed in both parts. The served
mempool slice is now also filtered against the page itself, so it is
listed once, as confirmed; one that confirms after the page read is
still listed, as mempool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c50f8ab)
…(Core parity) (#944)

* p2p: apply Core's message size and count limits, and bound queued sends per peer

- v1 frames are capped at Core's MAX_PROTOCOL_MESSAGE_LENGTH (4,000,000
  bytes) instead of 32 MiB. The v1 and v2 receive buffers grow as payload
  bytes arrive, at most 256 KiB ahead of them (V1Transport::readData,
  V2Transport MAX_RESERVE_AHEAD), and a large v1 buffer is released after
  its message.
- inv/getdata over MAX_INV_SZ (50,000), headers over MAX_HEADERS_RESULTS
  (2000) and addr/addrv2 over MAX_ADDR_TO_SEND (1000) are misbehaviour, as
  in Core; a notfound over 5016 entries is ignored.
- A peer's queue counts its bytes. getdata is served as Core's
  ProcessGetData does: entries are kept in a per-peer backlog and served
  until the queue passes 1,000,000 bytes (-maxsendbuffer's default), and
  the rest as it drains. The peer's next message is not read while a
  getdata of its is being served or its queue is over the limit
  (ProcessMessages). -maxuploadtarget is charged only for blocks queued.
- The write loop watches the disconnect signal during a write, and drops
  a peer whose socket takes no bytes for 20 minutes (TIMEOUT_INTERVAL,
  "socket sending timeout"). Sent bytes are counted as the socket takes
  them.
- A version whose user agent is over MAX_SUBVERSION_LENGTH is dropped, as
  Core's LIMITED_STRING read does; the user agent and unknown message
  types are sanitized with Core's SanitizeString before they are stored
  or logged.
- getcfilters/getcfheaders/getcfcheckpt requests that Core's
  PrepareBlockFilterRequest refuses now disconnect instead of being
  ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: wake the manager when a getdata or a getdata resume is handed over

The write loop reads nothing more from a peer until its getdata has been
served, so the manager's 500 ms drain tick bounded a peer fetching blocks
to about one getdata, or one send buffer's worth of a large one, per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: don't ban local, inbound-onion or manual peers for misbehaviour

Deal with a peer whose misbehaviour reaches the threshold as Core's
MaybeDiscourageAndDisconnect does (net_processing.cpp), in its order:
a noban peer and a manual connection are not punished; a peer on a
local address (CNetAddr::IsLocal: 127.0.0.0/8, 0.0.0.0/8, ::1) is
disconnected without punishing the address; anyone else is
disconnected and its address banned for -bantime, as before (Core
discourages it).

satd banned every peer but noban ones. Every inbound onion peer arrives
from the address Tor dialled from, usually 127.0.0.1, as does every
unwhitelisted local client, so one such peer's misbehaviour refused all
of them until the ban expired, across restarts. A manual peer was
banned and then skipped by the redial loop.

Peers accepted on the onion listener are now marked (Core's
m_inbound_onion), and getpeerinfo reports them on the onion network, as
Core's ConnectedThroughNetwork does. An outbound onion peer is banned by
its onion host, the address Core punishes, rather than by the 0.0.0.0
placeholder socket every onion peer shares.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: keep a getdata cmpctblock entry that could not be queued

serve_compact_block reported a MSG_CMPCT_BLOCK entry as answered even
when its cmpctblock could not be queued, so the entry left the getdata
backlog with nothing sent. It now says whether the message was queued,
and an entry that was not stays first, as every other entry does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: tidy the MisbehaviourAction docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: disconnect an inbound onion peer without a ban wherever Tor dials from

Core decides the disconnect-only rung on the address alone
(pnode.addr.IsLocal(), net_processing.cpp), and Tor on the same host
dials from loopback. With Tor on another host (-bind=<lan address>=onion)
every onion peer arrives from that host's address. Core discourages it,
and still admits it while inbound slots are free (net.cpp); satd's ban
would refuse every onion peer for -bantime. An inbound onion peer is
now disconnected without a ban whatever address it arrives from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e2e40ca)
… the automatic cap (#952)

* p2p: apply Core's message size and count limits, and bound queued sends per peer

- v1 frames are capped at Core's MAX_PROTOCOL_MESSAGE_LENGTH (4,000,000
  bytes) instead of 32 MiB. The v1 and v2 receive buffers grow as payload
  bytes arrive, at most 256 KiB ahead of them (V1Transport::readData,
  V2Transport MAX_RESERVE_AHEAD), and a large v1 buffer is released after
  its message.
- inv/getdata over MAX_INV_SZ (50,000), headers over MAX_HEADERS_RESULTS
  (2000) and addr/addrv2 over MAX_ADDR_TO_SEND (1000) are misbehaviour, as
  in Core; a notfound over 5016 entries is ignored.
- A peer's queue counts its bytes. getdata is served as Core's
  ProcessGetData does: entries are kept in a per-peer backlog and served
  until the queue passes 1,000,000 bytes (-maxsendbuffer's default), and
  the rest as it drains. The peer's next message is not read while a
  getdata of its is being served or its queue is over the limit
  (ProcessMessages). -maxuploadtarget is charged only for blocks queued.
- The write loop watches the disconnect signal during a write, and drops
  a peer whose socket takes no bytes for 20 minutes (TIMEOUT_INTERVAL,
  "socket sending timeout"). Sent bytes are counted as the socket takes
  them.
- A version whose user agent is over MAX_SUBVERSION_LENGTH is dropped, as
  Core's LIMITED_STRING read does; the user agent and unknown message
  types are sanitized with Core's SanitizeString before they are stored
  or logged.
- getcfilters/getcfheaders/getcfcheckpt requests that Core's
  PrepareBlockFilterRequest refuses now disconnect instead of being
  ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: wake the manager when a getdata or a getdata resume is handed over

The write loop reads nothing more from a peer until its getdata has been
served, so the manager's 500 ms drain tick bounded a peer fetching blocks
to about one getdata, or one send buffer's worth of a large one, per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: don't ban local, inbound-onion or manual peers for misbehaviour

Deal with a peer whose misbehaviour reaches the threshold as Core's
MaybeDiscourageAndDisconnect does (net_processing.cpp), in its order:
a noban peer and a manual connection are not punished; a peer on a
local address (CNetAddr::IsLocal: 127.0.0.0/8, 0.0.0.0/8, ::1) is
disconnected without punishing the address; anyone else is
disconnected and its address banned for -bantime, as before (Core
discourages it).

satd banned every peer but noban ones. Every inbound onion peer arrives
from the address Tor dialled from, usually 127.0.0.1, as does every
unwhitelisted local client, so one such peer's misbehaviour refused all
of them until the ban expired, across restarts. A manual peer was
banned and then skipped by the redial loop.

Peers accepted on the onion listener are now marked (Core's
m_inbound_onion), and getpeerinfo reports them on the onion network, as
Core's ConnectedThroughNetwork does. An outbound onion peer is banned by
its onion host, the address Core punishes, rather than by the 0.0.0.0
placeholder socket every onion peer shares.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: keep a getdata cmpctblock entry that could not be queued

serve_compact_block reported a MSG_CMPCT_BLOCK entry as answered even
when its cmpctblock could not be queued, so the entry left the getdata
backlog with nothing sent. It now says whether the message was queued,
and an entry that was not stays first, as every other entry does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: tidy the MisbehaviourAction docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: disconnect an inbound onion peer without a ban wherever Tor dials from

Core decides the disconnect-only rung on the address alone
(pnode.addr.IsLocal(), net_processing.cpp), and Tor on the same host
dials from loopback. With Tor on another host (-bind=<lan address>=onion)
every onion peer arrives from that host's address. Core discourages it,
and still admits it while inbound slots are free (net.cpp); satd's ban
would refuse every onion peer for -bantime. An inbound onion peer is
now disconnected without a ban whatever address it arrives from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: bound learned addresses and outbound dials; manual peers outside the automatic cap

- Core's address rate limit (ProcessAddrs): a token bucket per peer, one
  token to start, 0.1 a second up to 1000, plus 1000 when the node sends
  getaddr; entries are shuffled first and the addr permission is exempt.
  getpeerinfo reports addr_processed and addr_rate_limited.
- A gossiped address joins the dial list only if the address book holds
  it. The list is a set in arrival order, capped at the book's size.
- reserve_dial checks a dial's limit and takes its place under one lock,
  counting dials in flight: automatic dials against the automatic target,
  added nodes at most 8 at a time (Core's semAddnode), -connect,
  -seednode and onetry without a limit.
- The reconnect tick redials manual peers whatever the automatic count,
  then fills the free automatic slots. Automatic dials skip banned
  addresses, onion hosts included; manual dials do not (Core's
  OpenNetworkConnection). Gossip naming a banned onion host is not kept.
- Manual backoff stops at 60 s; backoff is cleared only by a dial that
  connects, not by a new tip.
- addnode onetry is outside every limit and succeeds for a peer already
  connected or being dialled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: exempt a two-address test peer from the address rate limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: count only the announced addresses in the addr-fetch test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: setban of an onion host disconnects that onion peer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full address book replaces a terrible entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full network group replaces a terrible entry; a replaced address's backoff goes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full book of terrible tried entries takes new addresses; lock the backoff while checking membership

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 77e7fbd)
* p2p: apply Core's message size and count limits, and bound queued sends per peer

- v1 frames are capped at Core's MAX_PROTOCOL_MESSAGE_LENGTH (4,000,000
  bytes) instead of 32 MiB. The v1 and v2 receive buffers grow as payload
  bytes arrive, at most 256 KiB ahead of them (V1Transport::readData,
  V2Transport MAX_RESERVE_AHEAD), and a large v1 buffer is released after
  its message.
- inv/getdata over MAX_INV_SZ (50,000), headers over MAX_HEADERS_RESULTS
  (2000) and addr/addrv2 over MAX_ADDR_TO_SEND (1000) are misbehaviour, as
  in Core; a notfound over 5016 entries is ignored.
- A peer's queue counts its bytes. getdata is served as Core's
  ProcessGetData does: entries are kept in a per-peer backlog and served
  until the queue passes 1,000,000 bytes (-maxsendbuffer's default), and
  the rest as it drains. The peer's next message is not read while a
  getdata of its is being served or its queue is over the limit
  (ProcessMessages). -maxuploadtarget is charged only for blocks queued.
- The write loop watches the disconnect signal during a write, and drops
  a peer whose socket takes no bytes for 20 minutes (TIMEOUT_INTERVAL,
  "socket sending timeout"). Sent bytes are counted as the socket takes
  them.
- A version whose user agent is over MAX_SUBVERSION_LENGTH is dropped, as
  Core's LIMITED_STRING read does; the user agent and unknown message
  types are sanitized with Core's SanitizeString before they are stored
  or logged.
- getcfilters/getcfheaders/getcfcheckpt requests that Core's
  PrepareBlockFilterRequest refuses now disconnect instead of being
  ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: wake the manager when a getdata or a getdata resume is handed over

The write loop reads nothing more from a peer until its getdata has been
served, so the manager's 500 ms drain tick bounded a peer fetching blocks
to about one getdata, or one send buffer's worth of a large one, per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: don't ban local, inbound-onion or manual peers for misbehaviour

Deal with a peer whose misbehaviour reaches the threshold as Core's
MaybeDiscourageAndDisconnect does (net_processing.cpp), in its order:
a noban peer and a manual connection are not punished; a peer on a
local address (CNetAddr::IsLocal: 127.0.0.0/8, 0.0.0.0/8, ::1) is
disconnected without punishing the address; anyone else is
disconnected and its address banned for -bantime, as before (Core
discourages it).

satd banned every peer but noban ones. Every inbound onion peer arrives
from the address Tor dialled from, usually 127.0.0.1, as does every
unwhitelisted local client, so one such peer's misbehaviour refused all
of them until the ban expired, across restarts. A manual peer was
banned and then skipped by the redial loop.

Peers accepted on the onion listener are now marked (Core's
m_inbound_onion), and getpeerinfo reports them on the onion network, as
Core's ConnectedThroughNetwork does. An outbound onion peer is banned by
its onion host, the address Core punishes, rather than by the 0.0.0.0
placeholder socket every onion peer shares.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: keep a getdata cmpctblock entry that could not be queued

serve_compact_block reported a MSG_CMPCT_BLOCK entry as answered even
when its cmpctblock could not be queued, so the entry left the getdata
backlog with nothing sent. It now says whether the message was queued,
and an entry that was not stays first, as every other entry does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: tidy the MisbehaviourAction docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: disconnect an inbound onion peer without a ban wherever Tor dials from

Core decides the disconnect-only rung on the address alone
(pnode.addr.IsLocal(), net_processing.cpp), and Tor on the same host
dials from loopback. With Tor on another host (-bind=<lan address>=onion)
every onion peer arrives from that host's address. Core discourages it,
and still admits it while inbound slots are free (net.cpp); satd's ban
would refuse every onion peer for -bantime. An inbound onion peer is
now disconnected without a ban whatever address it arrives from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: bound learned addresses and outbound dials; manual peers outside the automatic cap

- Core's address rate limit (ProcessAddrs): a token bucket per peer, one
  token to start, 0.1 a second up to 1000, plus 1000 when the node sends
  getaddr; entries are shuffled first and the addr permission is exempt.
  getpeerinfo reports addr_processed and addr_rate_limited.
- A gossiped address joins the dial list only if the address book holds
  it. The list is a set in arrival order, capped at the book's size.
- reserve_dial checks a dial's limit and takes its place under one lock,
  counting dials in flight: automatic dials against the automatic target,
  added nodes at most 8 at a time (Core's semAddnode), -connect,
  -seednode and onetry without a limit.
- The reconnect tick redials manual peers whatever the automatic count,
  then fills the free automatic slots. Automatic dials skip banned
  addresses, onion hosts included; manual dials do not (Core's
  OpenNetworkConnection). Gossip naming a banned onion host is not kept.
- Manual backoff stops at 60 s; backoff is cleared only by a dial that
  connects, not by a new tip.
- addnode onetry is outside every limit and succeeds for a peer already
  connected or being dialled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: exempt a two-address test peer from the address rate limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: count only the announced addresses in the addr-fetch test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: setban of an onion host disconnects that onion peer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full address book replaces a terrible entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full network group replaces a terrible entry; a replaced address's backoff goes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: a full book of terrible tried entries takes new addresses; lock the backoff while checking membership

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* p2p: count only requested blocks in the IBD download window; ask each peer for blocks it can serve

The IBD scheduler recorded any re-sent block whose hash matched a height as
downloaded, including heights the connector had already passed. Those
entries drain only as the cursor advances, so they stayed in the
`-maxahead` window, and the near-cursor priority zone was gated by that
window: once it was full while the next height was not in flight, nothing
handed that height out again. Any delivered block also reset the sender's
stall clock. Now only an in-flight height above the cursor is recorded,
only a block asked of the peer resets its clock (Core's
RemoveBlockRequest), the priority zone is exempt from the window (Core's
window starts at the last common block), and a pooled height the cursor
has passed is dropped.

A failed block-file or database write while storing an IBD or background
block was charged to the sender (+10 / +5). Only a Validation or
CheckpointMismatch error is the block's fault now.

The tip-following sweep sent every missing block to every peer on each
pass. It now asks one peer per block, at most 16 per peer, and asks again
after 30 s, of a different peer when there is one.

Block download now requires NODE_NETWORK or NODE_NETWORK_LIMITED (Core's
CanServeBlocks); a limited peer gets IBD work only when every height it
could be given is within 286 of its tip, and the AssumeUTXO background
download uses NODE_NETWORK peers only. The background download caps what
is outstanding per peer at 16, rotates its starting peer, gives a released
height to a different peer, and releases a height on notfound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: the IBD window always fetches the blocks above the tip; which peers are asked for blocks

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* p2p: the sweep asks for a block only a peer known to have it; notfound frees a sweep request

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* tests: four compact peers are each asked for the block by inv, not by the sweep

The tip-following sweep now asks one peer per block, so it no longer asks
every peer for the same block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6a18e0f)
…t start from the branch (#954)

* Header sync: continue from the peer's last header; derive the retarget start from the branch

After a full headers batch, ask that peer for the next headers with a
locator built from the last header it sent, as Core's
ProcessHeadersMessage does (MaybeSendGetHeaders(pfrom,
GetLocator(pindexLast))), whether or not the batch held new headers.
satd built every locator from its own height rows and only continued
when a batch added headers, so a peer whose chain forks below a
competing branch with more work than that peer had shown so far was
answered from the fork point with the same batch each time, and header
sync could not continue past the competing branch.

Find the first block of a retarget period by following parent links
from the candidate's parent (Core: pindexLast->GetAncestor(nHeightFirst))
instead of through the height index, so a block on a branch that forked
before the period is judged against its own branch's timestamps. The
by-height lookup argument is removed from check_difficulty/next_bits.

Check headers and blocks with check_proof_of_work_bounded, which also
requires the claimed target to be within the network's powLimit (Core's
CheckProofOfWork / DeriveTarget). Signet does not pin nBits to the
retarget schedule, so its headers were bounded only by their own claim.

Compare the testnet 20-minute rule in 64 bits, as Core does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Header sync: test the branch locator's shape

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ee05798)
* Count sigops exactly as Bitcoin Core does

satd counted sigops with rust-bitcoin 0.32's `Script::count_sigops` and
`Transaction::total_sigop_cost`. In accurate mode those keep the last
OP_1..OP_16 across later OP_CHECKSIG and OP_CHECKMULTISIG opcodes, where
Core's `GetSigOpCount` reads the opcode immediately before each multisig
opcode (src/script/script.cpp). An OP_CHECKMULTISIG that followed another
signature opcode took the earlier number instead of 20, so P2SH redeem
scripts and P2WSH witness scripts could count lower than Core counts them
(`OP_1 OP_CHECKSIG OP_CHECKMULTISIG`: 2 instead of 21). This is
rust-bitcoin issue #6368; no released rust-bitcoin version has the fix.

Add `validation::sigops`, a port of Core's `GetSigOpCount(fAccurate)`,
`GetSigOpCount(scriptSig)`, `CountWitnessSigOps`, `GetLegacySigOpCount`,
`GetP2SHSigOpCount` and `GetTransactionSigOpCost`, with Core's script
parsing, and Core's sigopcount_tests, script_p2sh_tests and
transaction_tests vectors. Use it for the block limit in `connect_block`
and `check_block`, the cost each mempool entry records (which the block
template budgets by and getblocktemplate reports), the policy engine's
`tx.sigops_cost`, and the Stratum coinbase bound.

The submitpackage fee-bypass path recorded a sigop cost of 0 and no
prevout metadata for the parents it admits, so the template counted them
as free. It now records what `accept_transaction` records, through one
shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: verify scripts before evicting, and make eviction linear

A transaction arriving at a full pool evicted entries to make room for
itself before its scripts were checked. Core checks the scripts
(PolicyScriptChecks, ConsensusScriptChecks) before it adds the
transaction and trims the pool (src/validation.cpp,
AcceptSingleTransactionInternal), so a transaction whose scripts fail
changes nothing. satd now does the same: the cheap fee-rate refusal
stays ahead of the scripts, and eviction comes after them.

Eviction itself rescanned the whole pool for every evicted entry and
every descendant, checking each candidate against a Vec of what was
already chosen. It now pops a heap built in one pass, finds descendants
through the spends index, and tracks membership in a HashSet.

Around the same path:
- Eviction never takes the incoming transaction's own ancestors, which
  left the child in the pool spending an output nothing provided. The
  fee-rate bar is the cheapest entry it may actually evict.
- Admission trims the pool down to its limit, as Core's TrimToSize does,
  instead of freeing only its own size. A lowered -maxmempool trims the
  pool at once on reload; before, every admission was refused until the
  pool drained.
- A refusal after eviction reports the entries it evicted.
- The specific script error behind a libbitcoinconsensus rejection is
  found by running the native engine over the failing input only, after
  the pool's write lock is released.
- The orphanage's FIFO queue holds each live orphan once, keyed by an
  arrival sequence as Core's orphanage is, instead of keeping the txid of
  every orphan it ever held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: report only acting entries evicted; test helpers index spends

Eviction removes a held descendant of an evicted acting entry with it.
That entry never emitted an Enter, so a LeaveEvicted for it is a Leave
for a txid the standard stream never showed. The eviction now returns
each entry's scope, and admission (success and refusal) and the
-maxmempool reload trim emit LeaveEvicted only for entries that were
acting.

The test insert helpers that take a caller's transaction now index its
spends, as every admission does, since eviction finds descendants
through that index.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 97298b0)
…ettxout and sendrawtransaction (#946)

* Count sigops exactly as Bitcoin Core does

satd counted sigops with rust-bitcoin 0.32's `Script::count_sigops` and
`Transaction::total_sigop_cost`. In accurate mode those keep the last
OP_1..OP_16 across later OP_CHECKSIG and OP_CHECKMULTISIG opcodes, where
Core's `GetSigOpCount` reads the opcode immediately before each multisig
opcode (src/script/script.cpp). An OP_CHECKMULTISIG that followed another
signature opcode took the earlier number instead of 20, so P2SH redeem
scripts and P2WSH witness scripts could count lower than Core counts them
(`OP_1 OP_CHECKSIG OP_CHECKMULTISIG`: 2 instead of 21). This is
rust-bitcoin issue #6368; no released rust-bitcoin version has the fix.

Add `validation::sigops`, a port of Core's `GetSigOpCount(fAccurate)`,
`GetSigOpCount(scriptSig)`, `CountWitnessSigOps`, `GetLegacySigOpCount`,
`GetP2SHSigOpCount` and `GetTransactionSigOpCost`, with Core's script
parsing, and Core's sigopcount_tests, script_p2sh_tests and
transaction_tests vectors. Use it for the block limit in `connect_block`
and `check_block`, the cost each mempool entry records (which the block
template budgets by and getblocktemplate reports), the policy engine's
`tx.sigops_cost`, and the Stratum coinbase bound.

The submitpackage fee-bypass path recorded a sigop cost of 0 and no
prevout metadata for the parents it admits, so the template counted them
as free. It now records what `accept_transaction` records, through one
shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: verify scripts before evicting, and make eviction linear

A transaction arriving at a full pool evicted entries to make room for
itself before its scripts were checked. Core checks the scripts
(PolicyScriptChecks, ConsensusScriptChecks) before it adds the
transaction and trims the pool (src/validation.cpp,
AcceptSingleTransactionInternal), so a transaction whose scripts fail
changes nothing. satd now does the same: the cheap fee-rate refusal
stays ahead of the scripts, and eviction comes after them.

Eviction itself rescanned the whole pool for every evicted entry and
every descendant, checking each candidate against a Vec of what was
already chosen. It now pops a heap built in one pass, finds descendants
through the spends index, and tracks membership in a HashSet.

Around the same path:
- Eviction never takes the incoming transaction's own ancestors, which
  left the child in the pool spending an output nothing provided. The
  fee-rate bar is the cheapest entry it may actually evict.
- Admission trims the pool down to its limit, as Core's TrimToSize does,
  instead of freeing only its own size. A lowered -maxmempool trims the
  pool at once on reload; before, every admission was refused until the
  pool drained.
- A refusal after eviction reports the entries it evicted.
- The specific script error behind a libbitcoinconsensus rejection is
  found by running the native engine over the failing input only, after
  the pool's write lock is released.
- The orphanage's FIFO queue holds each live orphan once, keyed by an
  arrival sequence as Core's orphanage is, instead of keeping the txid of
  every orphan it ever held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool RPCs: match Bitcoin Core's entry, info, gettxout and sendrawtransaction answers

getmempoolentry / getrawmempool verbose: vsize rounds up as Core's
GetVirtualTransactionSize does; height is the tip at admission; depends
lists direct in-mempool parents only; bip125-replaceable follows an
ancestor that signals (IsRBFOptIn); the ancestor and descendant fee
rollups sum modified fees. getmempoolsummary rows follow.

getmempoolinfo: bytes is the sum of virtual sizes, usage an estimate of
the entries' memory, total_fee the base fees, and loaded reports whether
the startup load of mempool.dat has finished.

sendrawtransaction reads maxfeerate and maxburnamount as Core does
(AmountFromValue / ParseFeeRate): a negative, unreadable or >= 1 BTC/kvB
value is an error instead of a silently weakened limit. -27 is decided
from the UTXO set, not the txindex. With a policy ruleset loaded, a
transaction the dry run refused but the submission would admit is still
held to maxfeerate.

gettxout honours include_mempool (default true): an output a mempool
transaction spends is null, and a mempool transaction's outputs are
reported with zero confirmations.

remove_for_block removes the descendants of a block conflict and clears
the prioritisation of confirmed transactions and direct conflicts, as
Core's removeForBlock / removeConflicts do.

Mempool reload applies the persisted delta before admission, keeps each
record's entry time and skips expired records (Core's LoadMempool). The
expiry pass keeps a lower bound on entry times and walks the pool only
when something can have expired, so an admission no longer costs a walk
over the pool and the reload is linear.

Docs: -limitclustercount bounds ancestors only; -maxmempool counts
serialized bytes; submitpackage applies maxfeerate and maxburnamount.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: report only acting entries evicted; test helpers index spends

Eviction removes a held descendant of an evicted acting entry with it.
That entry never emitted an Enter, so a LeaveEvicted for it is a Leave
for a txid the standard stream never showed. The eviction now returns
each entry's scope, and admission (success and refusal) and the
-maxmempool reload trim emit LeaveEvicted only for entries that were
acting.

The test insert helpers that take a caller's transaction now index its
spends, as every admission does, since eviction finds descendants
through that index.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* gettxout: read the pool inside the chain snapshot; fee rollups keep negative modified fees

gettxout read a pool output and the chain tip separately, outside the
coherent snapshot. The pool trails the chain until remove_for_block
runs, so a block that confirmed a pool transaction and spent its output
could be named as bestblock beside that output reported unspent. The
pool reads now sit inside the snapshot, and a pool transaction counts
only while every coin it and its in-pool ancestors spend is still a coin
at the tip.

The ancestor and descendant fee rollups added each modified fee clamped
at zero. Core keeps modified fees signed (CTxMemPoolEntry::m_modified_fee)
and sums them as they are (CalculateAncestorData), so a negative
prioritisetransaction delta now lowers the totals, and fees.modified can
be negative, formatted as Core's ValueFromAmount does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* gettxout: one pool read for spend and output; keep the expiry floor on a missed lookup

gettxout asked the pool whether the outpoint was spent and what output
created it under two separate read locks, so a policy reload that
promoted a parent and child between them could be read half before and
half after. Mempool::acting_view_of answers both under one lock.

EntryMap::get_mut reset the expiry time floor even when the entry was
absent. A mempool.dat reload prioritises each record before admitting
it, so every prioritised record forced the next admission to walk the
pool again. A miss now leaves the floor alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit fbad3bd)
…ter and failed-auth answers (#955)

- Doubles (difficulty, networkhashps, verificationprogress) are printed as
  Core's UniValue::setFloat prints them, %.16g, via rpc::util::univalue_double.
- getnetworkinfo relayfee/incrementalfee and the PSBT fee (v0 and v2) are
  amounts with eight decimals (and follow -rpcdefaultunits).
- submitblock: -22 "Block decode failed" for undecodable hex;
  "duplicate-invalid" for a block whose index entry is marked invalid;
  -25 for node failures (storage, block files, snapshot load, interrupted
  connect); fills in the coinbase witness reserved value as Core's
  UpdateUncommittedBlockStructures does.
- getblockfilter: Core's order, codes and messages ("Unknown filtertype",
  "Index is not enabled for filtertype basic", "Block not found",
  ParseHashV -8).
- getnetworkhashps -1 uses DifficultyAdjustmentInterval (144 on regtest).
- getrawtransaction's genesis coinbase refusal uses Core's exact text.
- A credential that does not authenticate is logged as "ThreadRPCServer
  incorrect password attempt from <ip:port>" and answered after 250 ms;
  a request without credentials gets its 401 at once, as in Core.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b5a18d4)
…l window (#948)

* Check a block's merkle root first, and mark only what Core marks

check_block tested for an empty transaction list before comparing the
merkle root, so an empty body sent for a real header failed
bad-blk-length rather than Core's bad-txnmrklroot. accept_block then
marked the header's block Invalid for that and for every other
non-mutation check_block failure. Core runs CheckMerkleRoot first
(validation.cpp CheckBlock), and ProcessNewBlock never marks a block
for a CheckBlock failure; of the rules in satd's check_block, Core
marks only bad-blk-weight, from ContextualCheckBlock.

A block that extends the tip and arrived without its header first had
no index entry until it connected, so a failed connect marked nothing
and each copy sent again was validated and written again. accept_block
now writes the HeaderOnly row a header announcement would leave before
connecting, as Core's AcceptBlockHeader creates the index entry before
ConnectTip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bound the unknown-parent block buffer

A block whose parent had no index entry was parked in the block
processor's buffer keyed by its prev hash: up to 8192 entries with no
byte bound, no per-peer bound and no expiry, whether or not this node had
asked for the block, before any proof-of-work or size check. Each entry
kept its sender's in-flight guard, so that peer's pongs waited for the
60-second bound until the block's parent connected, and the block went
through the per-input fee pass before accept_block refused it.

Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader
(validation.cpp:4206) and then refuses an unknown parent as
prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a
getdata, so a block it asked for can arrive before the parent it is still
learning about, and keeping that one saves a second download.

Now:
- Only a block this node asked for (Core's IsBlockRequested, read in
  handle_block before the request record is cleared) may wait.
- A waiting block must pass check_proof_of_work_bounded and be at most
  MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes.
- At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest
  goes first when it is at its share, then the oldest of all. Each block
  expires after 5 minutes.
- The in-flight guard is counted out when the block is set aside, as Core
  is done with it once refused.
- A block whose parent is unknown skips the fee pass and accept_block.
- The sender is asked for headers, as satd already does for a cmpctblock
  on an unknown parent.
- A block on a parent marked invalid is not kept.
- Two blocks on the same parent can both wait; when one fails to connect
  the next is tried.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* accept_block: say what a non-verdict failure leaves for an unannounced block

The comment on the HeaderOnly row written before connecting said every
outcome matched the headers-first path. A failure that is not a verdict
on the block does not move the best-header pointer, which an announced
header would have done. Say so, and say what reaches the block then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Stop on a coin read error instead of treating the coin as missing

RocksDbStore turned a failed coin read into "no such coin": get_coin
did `.ok()??`, get_coins_batch `.ok().flatten()` and has_coin matched
only `Ok(Some(_))`. A row that did not decode was logged and returned as
`None` too. connect_block takes a missing coin as a verdict on the block
that spends it (bad-txns-inputs-missingorspent), and accept_block and
the reorg path then record that block as invalid, a record that
survives a restart.

Bitcoin Core reads coins through CCoinsViewErrorCatcher, which logs
"Error reading from database" and calls std::abort() on any read error
(coins.cpp:398-412), because returning would be "interpreted as 'entry
not found'".

Each coin read now goes through one helper: a missing row is `None`, a
read error or an undecodable row stops the node with abort(), as Core
does and as satd's other fail-stops do. A test build panics instead so
the path can be tested. The Operator Manual says what the stop looks
like and what to do after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* signet: rewrite the commitment section exactly as Core does

A signet solution signs the block with the solution cut out of the
coinbase witness commitment, so the bytes left behind are part of the
signed message. satd rebuilt that script from rust-bitcoin's
`instructions()`, which differs from Core's FetchAndClearCommitmentSection
(signet.cpp:32-57) in two places:

- A zero-length OP_PUSHDATA1/2/4 comes back as an empty push, which satd
  wrote as OP_0 (0x00). Core writes an op with no data back as its own
  opcode byte (`CScript << opcode`), 0x4c, 0x4d or 0x4e.
- An op that does not parse made satd give up on the whole script and
  check the block as if it carried no solution. Core's GetOp loop stops
  at that op, keeps the section it found before it, and drops the rest
  from the rebuilt script.

Either way the merkle root the solution signs differed from Core's, so
satd refused blocks Core accepts and could accept, under a challenge an
empty solution meets, a block whose solution Core refuses.

The rewrite is now a port of Core's: GetScriptOp to read each op, the
opcode byte for an op with no data, and AppendDataSize (the shortest
length prefix) for a push with data, which Core also applies to pushes
that used a longer prefix.

The expected rewrites in the tests are Core's own output: the function
from signet.cpp, compiled with Core's script.cpp, run over each input.
Core's signet_parse_tests are ported too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take the median time past over a whole window, or fail the connect

The connect paths read the median time past through the height index
(or a replay plan) and skipped any height whose block they could not
resolve, taking the median of the rest; with nothing resolved they used
0. Core's GetMedianTimePast (chain.h) walks the block and up to ten
ancestors through pprev and has fewer only near genesis, so it never
takes a median over a window with a hole. A shorter window moved BIP 113
and BIP 68 decisions, and an empty one met every BIP 68 time lock. A
refusal it caused was then recorded against the block as invalid.

A window with a missing block is now ConnectError::MedianTimeWindowGap,
which is not a verdict on the block: the connect fails, nothing is
marked, and the block connects once the index is whole. Every connect
path uses it: accept_block and both reorg loops, the IBD connector, the
reindex paths, the background chainstate and TestBlockValidity
(through ChainState::median_time_past_for_connect, which still answers
from the MTP cache first). The prefetcher hands such a block to the
direct read. The mempool, templates and RPCs keep the old lenient
get_median_time_past.

BIP 68 now takes the coin's median as Core's CalculateSequenceLocks
does (consensus/tx_verify.cpp:74), from the block before the coin's,
GetAncestor(max(nCoinHeight - 1, 0)). That only differs for a coin at
height 0, which the genesis coinbase never creates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Count block work as Core's GetBitsProof does

work_for_bits decoded nBits with target_from_compact, which ignores the
sign bit and has no notion of overflow, and gave a zero target
2^256 - 1 work. Core's GetBitsProof (chain.cpp:121-134) gives no work
to bits that SetCompact (arith_uint256.cpp:176-194) flags as negative
or overflowing, or that decode to zero, and decodes exponents 33 and 34
differently from target_from_compact. work_for_bits now decodes as
SetCompact does and returns zero work in those cases. For every target
a header's proof of work can meet the value is unchanged, so stored
chainwork stays valid; a test checks that over exponents 1 to 32.

add_u256 wrapped on overflow. Chainwork is a sum of block work, and a
sum that wrapped would rank the chain with the most work below every
other, so it now saturates. Core's arith_uint256 wraps, but no reachable
chain gets near 2^256.

The expected values in the tests are Core's own output: GetBitsProof
from chain.cpp, compiled with Core's arith_uint256.cpp, run over each
nBits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Manual: an unreadable coin stops the offline audit too

satd-chainstate-audit looks coins up through the same store, so a coin
the database cannot read now stops it with the outpoint and the RocksDB
error instead of listing the coin as missing. Say so beside its exit
statuses, and say where the stop applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b311bd1)
…e header tip (#957)

* Retry the best valid chain after a failed connect, and recalculate the header tip

When a block fails to connect, Core's InvalidBlockFound marks it
BLOCK_FAILED_VALID whichever path reached ConnectTip, and InvalidChainFound
recalculates the header tip when that tip descends from the failed block
(validation.cpp). satd did neither consistently:

- mark_subtree_invalid now recalculates the header tip when the marked
  subtree contains it, and clears the activate_best_stored_chain attempt
  key. When the failed reorg was that function's own, no part of the key
  changed, so the stored prefix below the failed block was not tried until
  another block or header arrived.
- The connector's connect_stored_block and connect_preprocessed_block mark
  a stored block that fails a consensus rule, as accept_block does. A
  failure that reports this node's storage (such as a gap in the
  median-time-past window) is not a verdict and still leaves the block
  DataStored.
- The IBD connector leaves IBD when the block it connects is marked,
  instead of retrying it and restarting IBD onto it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the connector's verdict filter directly

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b296fb4)
… blocks (#958)

* Tests: index serving gate, backfill reorg cleanup, flush window, coin-free batches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tests: flush the live rows before planting backfill rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Index serving gate, backfill reorg safety, flush window, coin-free batches

- The filter, address and spend indexes now serve only while their
  backfill cursor is Idle or Completed, as the silent-payment index
  already does. The "complete" marker stays set while a later backfill
  runs over the same index and after one stops part-way. The filter
  gate also drives the NODE_COMPACT_FILTERS service bit.
- The filter and address backfills check their anchor and write each
  batch under accept_lock, so a reorg can no longer land between the
  check and the write and leave rows for a disconnected block. The
  filter tail catch-up re-walks from the new tip after a reorg instead
  of writing a row for a replaced block.
- Reorg cleanup after an invalidated run no longer removes rows the
  active chain owns: filter rows at heights the active chain still has,
  and address funding/spending/spend rows the replacement blocks wrote
  under the same ordinal keys.
- CoinCache::flush_inner holds the pending-batch lock from the take
  until the backing-store write returns (and, on failure, until the
  rows are back), so a read during a flush finds each row in pending
  or in the store, never in neither.
- A coin-free batch that moves the tip (a block whose outputs are all
  unspendable and which spends nothing) is buffered like any other
  connect or disconnect, so a reorg's pending removes no longer delete
  its rows and a reorg abort retracts them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tests: build the cursor batch with a struct initializer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Push status updates refused during an address backfill once it ends

While the address backfill cursor is not Idle or Completed, the address
index refuses reads, so a status recompute triggered by a block or a
mempool transaction in that time cannot run. Record the refusal and
have the notifier recompute every subscription once serving resumes,
so a subscriber whose history changed meanwhile is told.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Regtest: address history is refused while the backfill is Failed; check rows after the re-run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 43fede2)
dump_mempool wrote mempool.dat in the pool's map order, and load_mempool
re-admitted records in order of their one-second entry time, keeping file
order on a tie. A child that entered in the same second as its parent was
often tried first, refused for missing inputs, and its descendants after
it, so a restart could drop most of the saved mempool.

Bitcoin Core writes the file from infoAll(), which is in topological order
(src/txmempool.cpp, GetSortedScoreWithTopology), and LoadMempool admits in
file order (src/node/mempool_persist.cpp). satd now writes the file
oldest-first with every parent ahead of its children, and on load runs
the same stable dependency pass over the records after the time sort, so
a file written by 0.6.0 in arbitrary order also reloads in full. The pass
is one depth-first walk over the in-file spend edges, linear in inputs.
The file format is unchanged.

Fixes #949.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1899cf2)
)

* chain: index the header of a block stored before its parent (#922)

A block passed to `submitblock` before its parent's block was stored by
`accept_block` without moving the best header. The stored-tail drain
connects stored blocks along the best-header chain, so once the parent
connected nothing reached the child: it stayed `valid-headers` and
`getblockchaininfo.headers` did not count it until another block or
header arrived.

Core's `AcceptBlock` indexes every block's header through
`AcceptBlockHeader` (src/validation.cpp:4307), and
`BlockManager::AddToBlockIndex` raises `m_best_header` to an entry with
more work (src/node/blockstorage.cpp:249). The stored path of
`accept_block` now commits its index entry through the header path
(`commit_accepted_headers_locked`), so the best header, the header
height and the height rows above the tip follow it; the drain connects
the child as soon as its parent connects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: changelog and 0.6.1 notes for #922

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4484846)
…rs after IBD (#894, #895) (#962)

* net: one automatic outbound peer per network group; trim outbound peers after IBD

Automatic outbound dials now skip a candidate whose network group (the
/16, the /32, or the ASN under -asmap) already holds a manual, full-relay
or block-relay-only peer, or a dial in flight, as Core's
ThreadOpenConnections requires (src/net.cpp:2654-2690, :2829). Addresses
that are not publicly routable, which reach the dial list on regtest only,
are outside the rule, as are onion peers.

When the outbound full-relay target drops from the IBD figure back to 8,
the surplus is disconnected one peer per reconnect pass, modelled on
Core's EvictExtraOutboundPeers (src/net_processing.cpp:5264): the peer that
least recently announced a block with more work than the tip, never a
manual or block-relay-only peer, a chain-sync-protected peer or the only
peer on its network, and not while it has blocks in flight or has been
connected under 30 seconds. A peer sharing its group with another goes
first. Headers now record each peer's last block announcement and protect
the first four outbound full-relay peers whose chain has the tip's work,
as Core's UpdatePeerStateForReceivedHeaders does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Notes: one outbound peer per network group; outbound peers trimmed after IBD (#894, #895)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* net: clippy question_mark in evict_extra_outbound_peer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* net: dial DNS and fixed seeds through the automatic dial pass

Seed answers were each dialled by a task of their own
(connect_peer_addr_automatic), which checked neither the outbound target
nor the one-peer-per-network-group rule, so a seed answering with several
addresses in one /16 could fill the outbound slots with them. They now
join the learned addresses and are dialled by the same pass as every
other automatic connection (PeerManager::dial_learned_peers), as Core adds
a seed's answers to its address book and leaves the dialling to
ThreadOpenConnections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* net: collect outbound groups under the dial list's lock

A dial registers its peer before it leaves pending_connections. Reading
the peers and then the dial list in two separate steps could miss a dial
that completed between them, and let a second automatic dial into its
group. The dial list is now read first and its lock held while the peers
are read, the order connect_outbound_inner takes them in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7b04ff4)
…963)

* index: a filter backfill row replaces the pending live row for its height

The filter backfill (main walk and tail catch-up) writes its rows through
CoinCache's pass-through branch straight to the backing store. The live
connect path's rows for recent blocks sit in the pending batch until the
next flush. Where both held a row for the same height, the pending one
shadowed the backfill's in reads and the flush wrote it back over it.
Those live rows were emitted before the rows below them existed, so
their headers chained off the all-zero fallback, and every later live
header chained off them: the stored BIP 157 header chain broke from the
first such height on.

The pass-through branch now drops the pending filter rows its batch
supersedes, last-writer-wins by (filter_type, height), as it already
does for block-index rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* index: check the stored filter header chain once and repair it

A datadir on which an earlier release ran a filter backfill can hold
filter headers chained off zeros. The filters themselves are correct (a
filter depends only on its block), so the headers can be recomputed
from them exactly: header[h] = sha256d(sha256d(filter[h]) || header[h-1]).

On start, before resuming or starting any backfill, the filter
supervisor walks the stored rows from genesis to the first gap or the
tip, in chunks under accept_lock, and rewrites each header that differs.
It runs once per datadir (block_filter_index.header_chain_checked), and
not at all where no filter backfill has ever run (cursor Idle): there
every row came from in-order live connects. With the index off it waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Changelog and 0.6.1 notes: filter backfill header chain

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tests: build the cursor batch in its initializer (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* storage: drop superseded pending filter rows only after the write succeeds

A failed pass-through write used to have already removed the pending live
rows it would have superseded, so the next flush stored the block without
them. `pending` is now held across the backing write, as `flush_inner`
holds it, and the superseded rows are dropped only once the write
returned Ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* index: name the current CoinCache helper in the header chain doc

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ab5a73b)
@bkeroack
bkeroack merged commit ad092f9 into master Oct 15, 2026
40 checks passed
@bkeroack
bkeroack deleted the fwdport/0.6.1-fixes branch October 15, 2026 17:50
bkeroack added a commit that referenced this pull request Oct 15, 2026
Brings the 0.6.1 release notes, the 0.6.1 CHANGELOG releases-table row and
the release-notes index row from release/0.6, as the 0.6.1 fixes reached
master in #964. Master's [Unreleased] section (0.7.0) is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant