Expand description
Post-broadcast verification — fail loudly when a broadcast was silently dropped, and never when it was not.
§Bug 1 — the silent data loss this module was built to close
A send (CLI send or the served /createAction endpoint) delegates to
Wallet::create_action, which signs the tx and broadcasts it. For a
monitor-less served wallet (no chain monitor / chaintracks) the wallet
has never fetched merkle proofs for its confirmed ancestors, so the BEEF it
hands ARC carries the whole unconfirmed chain. ARC then charges the fee for
the entire package and rejects the tx with error 465 “fee too low”.
In bsv-wallet-toolbox-rs, an ARC 465 is tagged service_error = true, so
classify_broadcast_results treats it as a transient ServiceError
(retryable) rather than a permanent InvalidTx. create_action therefore
returns Ok with a txid — a phantom txid that never propagates. The send
path reported success and exit 0 while the funds were never sent.
§Bug 2 — the false negative this module introduced (fixed here)
The first cut of this module polled a hardcoded source list and never looked at which broadcaster the wallet had actually been configured to use. It was plane-blind, and every one of its sources was marked authoritative for absence. Three separate defects fell out of that:
- The plane that actually holds the answer was never asked. A wallet in
Arcade V2 mode (
ARC_MODE=arcade) submits to the Arcade endpoint, and the verifier never queried it — so the one store that is guaranteed to have a record of our own submission contributed nothing. arc.gorillapool.iowas trusted for absence unconditionally. It is a submission-scoped metamorph store, not a chain index: it answers 404 for transactions that are mined with hundreds of thousands of confirmations. (Verified directly:GET https://arc.gorillapool.io/v1/tx/<genesis coinbase txid>→404 {"extraInfo":"transaction not found"}.) Its 404 carries no information about a transaction it was never handed.- Only WhatsOnChain could ever vote
Present, inside a ~7.5 s window. So the verdict reduced to a coin flip on WoC’s mempool-indexing latency.
The module’s own comment asserted that “ARC keeps recently submitted txs
queryable … so all default sources are authoritative here”. That is true only
of the ARC instance you actually submitted to. That unchecked proposition was
the root cause: in the dHouse funder’s entire history, all four Rejected
verdicts were false negatives — every one of those transactions was on
chain.
§Bug 3: “present” is not “on the network” (2026-09-02)
A 200 from the broadcaster we submitted through used to count as presence.
It is not network evidence: Arcade answers GET /tx/{txid} with a 200 and
txStatus: RECEIVED / SENT_TO_NETWORK for a transaction it holds but
that no node has seen, and with a 200 and txStatus: REJECTED for one it
will never relay. On 2026-09-02 four beta wallets sent EF children whose
202’d parents had never propagated; the children were orphans forever,
and a verifier that read “200” as “present” could not tell.
So a probe now reads the body. Every source yields one of: network-level
NetworkEvidence (SEEN_ON_NETWORK / SEEN_MULTIPLE_NODES / MINED
from an ARC-style store, any 200 from the chain index), held (the store
has the bytes, the network has not vouched: pre-gate statuses, an
orphan-pool hit), a fatal verdict from the broadcaster we submitted
through (REJECTED / DOUBLE_SPEND_ATTEMPTED), absence, or unknown.
BroadcastVerifier::verify_report surfaces all of it in a
PresenceReport; the served follow-up credits the wallet’s broadcast
memory (seen for the tx AND its unproven ancestors: presence of the
child implies the parents connected) and the reconciler runs its absence
clock on it.
§The model this module now implements
Doctrine (CLAUDE.md): “2xx is never success — truth = visible in our own
index / on chain”; a positive answer may be trusted, an absence must
be chain-verified. Applied to the verifier itself: absence from the wrong
plane is not truth.
- Presence is trusted from anybody. A store holding the transaction
(held or seen) means the broadcast was not silently dropped. A
freshly-minted txid we just created cannot be known to a third party
unless it really propagated. So any held / seen answer →
Confirmed. - Absence is trusted from almost nobody. See [
AbsenceAuthority]: a 404 (or a fatal verdict) is evidence only from the broadcaster we personally submitted through (scope) or from a real chain+mempool index after its indexing window has elapsed (time), and we require both before declaringRejected. - The broadcaster we used is consulted first, so the happy path
short-circuits to
Confirmedon a single request. - If we cannot satisfy that bar we return
Inconclusive, and callers preserve prior behaviour — a down (or unidentifiable) confirmation service never turns a real send into a false failure.
Structs§
- Broadcast
Verifier - Verifies that a broadcast tx actually reached the network.
- Presence
Report - Everything one verification learned, for callers that act on more than the verdict (the broadcast memory, the absence clock).
Enums§
- Broadcast
Verification - Outcome of verifying that a just-broadcast tx actually reached the network.
- Chain
Index Answer - What the chain index (WhatsOnChain) answered in the last probe round.
- Network
Evidence - Network-level presence a source reported: the transaction was seen by a node (so its parents connected), or mined.