feat(wraith): Wraith rounds and the four-lane Ghost Lock, replacing the old lock - #853
Merged
Merged
Conversation
First phase of the Locks/Wraith/Wallet work that replaces Ghost Pay. Purely
additive — a new crate wired into no binary, so nothing in the live tree
changes behaviour.
A Lock is a policy, not a coin. Three lanes, escalating delegation:
VAULT key path musig(owner, backup) quorum has no part
leaf 1 older(61_200) owner ~14 months
leaf 2 older(65_535) backup ~15 months (CSV ceiling)
leaf 3 after(HEIGHT) heir absolute — CSV cannot reach this
HOT key path musig(owner, quorum) quorum can only complete what
leaf 1 older(EXIT) owner the owner pre-signed
LIQUIDITY key path quorum quorum can spend alone;
leaf 1 older(RECALL) owner this lane is custody
Two things worth knowing before editing this crate:
- **Inheritance cannot use CSV.** `nSequence` carries a relative lock in 16
bits, so 65,535 blocks (~15 months) is the ceiling and an 18-24 month delay
does not fit. Leaf 3 is an absolute `OP_CLTV` height, pushed forward at each
rollover — a dead-man's switch that matures only if the owner stopped
rolling over. `BACKUP_RECOVERY_BLOCKS` sits exactly on the ceiling
deliberately, not as a placeholder.
- **Every lane spends through the key path.** A MuSig2 aggregate is one
64-byte Schnorr signature over a bare 32-byte output key, indistinguishable
from an ordinary single-signature wallet. A script-path spend reveals its
leaf, and on the hot lane that would mark every round input as Wraith.
The recovery delays are asymmetric on purpose: whichever key an attacker
holds, the honest key's path opens roughly a month earlier. That invariant and
the BIP-68 ceiling are enforced by `const _: () = assert!(...)`, so breaking
one fails the build rather than a test run (verified by temporarily changing
`OWNER_RECOVERY_BLOCKS` and watching E0080 fire).
MuSig2 aggregation is deliberately not implemented here — aggregate keys are
taken as opaque `XOnlyPublicKey` inputs so a vetted implementation can supply
them. This crate takes public keys and never generates one.
7 tests, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…tion Phase 3 groundwork. Additive — a new module, not yet wired into `single_round.rs`, so the live coordinator path is untouched. Removes the foresight problem in a denominated rail. Today `#698` pins a round input to *exactly* the seat price because rounds have no change output, which means a user needs the right coin before they can pay at all. With a ladder you contribute the rungs you have, the recipient is paid in rungs, and the surplus returns as more rungs to fresh addresses. Change is safe because it is denominated like everyone else's, not because it is hidden. `decompose` is greedy from the largest rung, which for a 1-2-5 series is the canonical representation. That is the right choice twice over, and the second reason is the one worth recording: - fewest rungs is cheapest, since every output costs 43 vB; - determinism makes decompositions **collide**. Two people paying the same amount emit the same multiset of values. A randomised decomposer would give each payer a distinctive output pattern, which is the opposite of what a mix is for. Greedy is proven minimal against exhaustive search over 400 amounts, so a future ladder change that breaks canonicality fails the test rather than quietly costing everyone fees. The real privacy question is not in decomposition but in `select_inputs` — which coins you spend says something about what you hold. The current selector is largest-first and documented as v1; a smarter one should prefer combinations many other participants could also have produced. Ladder coarseness was an open decision, so `compare_shapes` measures it instead of arguing it. Over 500 sampled amounts: ladder values mean outputs mean vB at 5 sat/vB 1-2-5 (standard) 16 4.50 193.6 968 sats powers of ten 6 11.14 478.8 2,394 sats Coarser gives a bigger crowd per value but costs ~2.5x the output bytes. The 1-2-5 default takes the cheaper side; the numbers are now in a test rather than in a discussion. Amounts that are not floor-multiples are refused, never silently rounded — `quantise` is a separate, explicit call. 7 new tests, 151 green overall, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…roofs Phase 6 groundwork. Additive module; nothing calls it yet. This is the mechanism behind an instant payment being final before it confirms. A hot-lane coin is a 2-of-2 between owner and quorum, so a sender cannot sign a conflicting transaction alone — unlike ordinary Bitcoin, where holding your own key is exactly why zero-conf is unsafe. The recipient therefore holds a complete valid transaction plus a signed statement that no conflicting one can exist. The safety property is one sentence, and it belongs in the coordinator rather than here: **the quorum signs exactly once per coin, ever.** `create()` will happily sign a second attestation, which is deliberate — enforcement is state, not cryptography, and `DoubleSignProof` is what makes a failure of that enforcement provable. Be clear about what this does not claim: it does not prevent a corrupt quorum signing twice. It makes doing so publicly provable by anyone holding the two statements, with no chain access and no trust in whoever presents them. Same shape as the mesh equivocation proofs already in the tree. Two tests are the ones that matter: - `a_forged_half_cannot_slash_an_innocent_quorum` — an attacker who fabricates a second "attestation" and stamps the victim's key on it produces something that *looks* like a conflict and fails verification. A proof must not verify unless both halves were genuinely signed, or the fraud-proof mechanism becomes a weapon against honest operators. - `honest_behaviour_cannot_be_dressed_up_as_fraud` — a re-issued attestation for the same transaction, two different outpoints, and two different signers are all rejected as non-conflicts. Signing many different coins is the job. The message is fixed-shape under a versioned domain tag (`wraith/attestation/v1`, BIP-340 doubled-tag convention), so no field can be shifted into another to produce a collision, and a signature cannot be replayed as an ownership proof or a transaction sighash. 6 tests, 157 green overall, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Retires `seat_prices_are_pinned`, which asserted the bug. A published constant
is a chain-analysis marker hiding in the economics rather than the script:
grep the chain for outputs of exactly 101,596 sats
-> every Wraith seat ever funded, forever
Same class as the `WL01` OP_RETURN already removed, one layer down.
The pinning was never the formula — `per_participant_mining_share` always took
a fee rate. It was that callers passed `DEFAULT_FEE_RATE_SATS_PER_VB`, a
constant, so the price never moved. The fix is not added jitter. It is to price
each round from the live fee estimate, so the value moves for a reason any
observer attributes to ordinary fee conditions rather than to obfuscation.
Also consolidates the calculation. #698 exposed the coordinator and the round
builder computing the required input independently and never matching — 102,026
against 101,440 — invisible because both only checked `>=`, so the larger won
and the difference went to miners. `crate::seat_price` is now the single source;
never add a second copy.
Measured spread over fee rates 1-60 sat/vB:
tier 1 sat/vB 60 sat/vB distinct
100k_sats 101,110 107,576 60
1m_sats 1,002,610 1,009,076 60
10m_sats 10,025,110 10,031,576 60
Read that honestly. It removes the single-constant grep, but 60 values across a
6,466-sat band is still a *range* an analyst can scan, because the denomination
dominates the price and the denomination is fixed. This is a real improvement to
the legacy tier path and not a solution — the ladder is the actual fix, since
under it a participant contributes ordinary rungs covering amount plus fee and
there is no seat price to fingerprint at all.
`the_price_is_not_a_constant` fails if anyone re-pins the rate, and
`fee_variation_alone_gives_a_wide_spread` fails if the spread ever collapses to
a shortlist.
6 tests, 162 green overall, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
`attestation` gave a quorum the shape of a promise. This is the promise: the quorum signs exactly once per coin, ever. That single rule is what lets a recipient act before confirmation — the sender cannot produce a conflicting transaction alone because the coin is 2-of-2, and if the quorum refuses a second signature then no conflicting transaction can exist at all. Worth being precise about where the guarantee lives. It is not cryptographic — nothing here stops a corrupt quorum signing twice, which is exactly why `DoubleSignProof` exists. It is **state**, and state has to survive retries, races and restarts. Which produces the finding this commit really exists for: **This store must be durable, unlike every other coordinator store.** Outpoint bans are deliberately in-memory; a restart clears them and the worst case is a griefer getting another go. If the signing ledger works that way, a restart makes the coordinator sign a second transaction for a coin it already signed. That is not a degraded service — it is the precise fraud the design promises cannot happen, committed accidentally by an honest operator, and it mints a valid double-sign proof against a quorum that did nothing wrong. So `SignatureStore` carries a durability contract in its docs, and the in-memory implementation is called `VolatileStore` so that finding it in a production path is uncomfortable. `losing_the_store_is_a_double_sign_vector` pins that failure mode as a test — it asserts the *bad* behaviour deliberately, so nobody swaps in a volatile store believing a restart is merely inconvenient. Two details that are easy to get backwards: - **Idempotency.** Asking twice for the same transaction is a dropped packet, not an attack. It returns `AlreadyCommitted` and is not counted as a refusal. Only a *different* txid for the same coin is equivocation. - **Ordering.** The commitment is recorded before signing. A crash between the two loses a payment; the other order loses the guarantee. `refusals()` counts equivocation attempts, because a check whose failure produces no observable output is not a check. The dashboard is its reader. 7 tests, 169 green overall, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Phase 8, which gates the paid-anonymity-set work: the moment you charge for a set of fifty you have to be able to stand behind that number. The attack costs almost nothing today. An adversary fills a round with their own seats, gets every coin back, and pays only fees — about 500 sats a seat, roughly 14,000 to dominate a 30-seat round. They then subtract their own outputs, and a victim who believed they had a set of thirty actually had two. Three levers, none of which prevent it: - **Aging** (`min_age_blocks`, default 144 ≈ a day). Sybils need capital *parked* rather than cycled, turning a flat per-round fee into an opportunity cost proportional to how long they keep attacking. Short enough that a user who entered yesterday can pay today. - **Diversity** (`max_seats_per_cluster`, default 2). Splitting a hoard into forty outputs stops buying forty seats. - **Dispersion** (`DispersionGuard`). A victim repeatedly rounded with the same crowd is being farmed, and no per-round check can see that — only history can. And a fourth that is not code: `dominance()` is the number a dashboard plots. A single round tells you nothing, since a legitimate user can hold two seats; a rising trend across many rounds is what a Sybil looks like, and it is only visible if somebody is looking. Two boundaries stated in the module docs rather than discovered later: - **This is not prevention.** Wasabi and Whirlpool live with the same attack. It cannot be prevented in a permissionless round, only made expensive and made visible. Anyone who writes "Sybil-proof" in this file is wrong. - **It does not identify clusters.** `cluster` is supplied by the caller from chain analysis and is only as good as that analysis. A Sybil funding each seat from genuinely unrelated coins defeats diversity entirely; aging and dispersion are what remain. `an_unclustered_seat_never_counts_as_dominant` is the test worth keeping: `None` means analysis found no link, not that everyone is the same person, and collapsing those into one cluster would flag every honest round as an attack. `unrelated_participants_are_unaffected` is its pair — the policy must not punish a clean round of thirty strangers. Both defaults are open parameters in the build plan and want tuning against real fill rates before launch. 9 tests, 178 green overall, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
`GET /api/v1/session/:session_id/round-tx` returned a per-output
`participant_id`. The endpoint is unauthenticated — the router carries no auth
layer, with a comment noting "v1 trusts peers on a private network" — and the
same response serves `prevouts` in registration order, which assembly preserves
because only outputs are shuffled.
Those two together are the complete answer key. Anyone able to reach a
coordinator could read which output belonged to which input, for any round in
signing state. The blind signatures and the ChaCha20 shuffle both exist to make
exactly that unrecoverable.
The field survived because of the comment justifying it:
Diagnostic only — the privacy-relevant property (which mix output belongs
to which input) is preserved by the shuffle, not by hiding this field.
That is inverted. The shuffle randomises output *order* so that no one, the
coordinator explicitly included, can recover the mapping. Publishing per-output
attribution re-attaches the identity the shuffle just detached. `single_round.rs`
says as much three lines above the shuffle call.
The fix costs nothing. Nothing consumes the field — `grep -rn participant_id
apps/wraith-wallet/` returns zero hits, because a participant locates their own
output by matching their own `mixed_output_address`. Structure a participant
genuinely needs (index, kind, amount, fee, txid) is unchanged.
`no_participant_attribution_is_ever_serialised` is a permanent regression test
over the serialised body, checking a banned-name list rather than one field, so
a differently-spelled reintroduction is caught too. Verified it can actually
fail: re-adding the field made it panic with the leaked JSON in the message,
which is the point — a check whose failure produces no observable output is not
a check. `structural_verification_data_is_retained` is its pair, so the fix is
not later "improved" by stripping the endpoint of things wallets need.
⚠ Related, NOT fixed here: inputs are never shuffled, so input order is
registration order. That leaks arrival sequence to anyone who watched
registration, and is the same class of leak one step down. Wants its own issue.
27 lib tests green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Follow-up to the round-tx attribution fix. Inputs were emitted in registration
order, which encoded arrival sequence into the transaction. The comment above
the loop explained why that was fine:
Bitcoin doesn't care about input ordering for privacy because the inputs
are observable on chain and in the mempool already. Output ordering is
what matters.
Half right. The inputs are observable; their *order* is not. Order was a
coordinator choice, and it chose arrival order — so anyone who watched
registration, or who correlates submission timing, could map input position
back to a participant. The chain would never have revealed that on its own.
Inputs are now shuffled with ChaCha20, seeded under a **separate domain tag**
(`WraithLite/v1/input_shuffle`). Sharing the output shuffle's seed would
correlate input index i with output index i and hand back exactly the mapping
the output shuffle exists to destroy; `input_and_output_permutations_are_
independent` pins that.
Nothing caught this change, which is its own finding: no test asserted input
order at all, so all 178 stayed green when the ordering flipped.
`inputs_do_not_appear_in_registration_order` now asserts both that the order
differs and that the input *set* is unchanged — a shuffle must permute, never
alter.
The correctness half is the part that would have been nasty. `round-tx` built
`prevouts` by indexing the registration-ordered store positionally, on the
documented assumption that "only outputs get shuffled". With inputs shuffled
that pairs each input with another participant's scriptPubKey and amount, and
because BIP-341 commits to every prevout it fails as an opaque signature error
rather than as a lookup error. Prevouts are now looked up by outpoint, walking
the transaction's own input order, with an explicit 500 if an assembled input
is not in the store rather than a silent mismatch.
`round_tx_full_pipeline_assembles_a_valid_transaction` now asserts prevouts[i]
really describes tx.input[i], and that no participant attribution appears
anywhere in the response body.
288 tests green across both crates, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
*A privacy property without a continuous test harness is assumed broken.* This module does not check the code does what it meant to. It plays the analyst: given a round transaction, it tries to recover the input-to-output mapping, and anything it recovers is a bug. Written because the two leaks fixed in the preceding commits were both found by reading code, and neither was caught by a test — both lived behind a comment confidently explaining why they were fine. Three probes: - `scan_for_markers` — any OP_RETURN. v1 shipped `WL01` and removed it; this is the regression probe. - `attempt_amount_attack` — counts outputs unique enough to be singled out. This is the executable form of the rule that privacy is collision, not entropy: `equal_denominations_defeat_the_amount_attack` finds nothing, while `random_amounts_hand_the_analyst_every_participant` recovers all five. Anyone who later reasons that random amounts add confusion can run it. - `probe_value_constancy` — a value present in every round is greppable across the whole chain without touching the script. ⚠ **It found a live one on its first run against the real builder.** `service_fee_sats()` is a pure function of tier, so the fee output is always `fee * n` — 5,000 sats for a 5-participant 100k Mix. One grep over the chain finds every Mix round of a given size. Same class as the pinned seat price, one layer over: that fix addressed the input side only, and this was sitting untouched next to it. Not fixed here, because it needs a decision rather than a patch: 1. derive the fee from the live fee rate, as the seat price now is 2. pay the fee in ladder rungs, indistinguishable from mixed outputs 3. take it as a spread and emit no fee output at all (3) is what the provider model already does and leaves nothing to grep. `real_rounds_carry_no_greppable_constant` pins the violation rather than tolerating it: a NEW constant fails the test, and so does the known one changing shape, so the pin cannot quietly widen to cover something else. `a_real_round_survives_the_privacy_probes` runs the full harness against a round from the production builder, not a synthetic fixture — that is the one that fails if the real path regresses. 9 tests, 189 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…ndpoint The `/round-tx` leak was in a response body, not a transaction, so the harness could not have caught it. This closes that half. `probe_api_response` scans a **serialised** body rather than checking struct shape, which is deliberate: a type-level check would miss a field added under a different name, a map keyed by identity, and anything reaching the wire through `serde(flatten)` or a hand-written `Serialize`. `a_renamed_field_is_still_caught` covers the case where someone "fixes" a leak by renaming it. Requests are explicitly out of scope — a caller naming themselves is how they authenticate. This is about what comes back. `no_endpoint_leaks_participant_attribution` drives a real session to Signing and sweeps `/pool/discover`, `/session/:id` and `/session/:id/round-tx`. All three are clean. Two notes from auditing the rest of the surface by hand: - `SessionDescriptor` already projects `participants.len()` rather than the roster, which is the right call and worth not regressing. - `session_witness` accepts `ghost_id` and `input_index` together, which links identity to position — but only in the *request*, and the coordinator necessarily knows who submitted which input. The blind signature protects the output destination, not the input. Not a leak; noted so the next reader does not have to re-derive it. The sweep ends by planting a known-bad field and asserting the probe rejects it. A check whose failure produces no observable output is not a check, and that applies to the checker too. If a new endpoint is added and not listed in that test, that is the gap. 4 new tests, 192 protocol + 109 coordinator green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Sits alongside `LiteRoundBuilder` rather than replacing it, so the tier path keeps working while this is proven. Two changes against the tier round: **You bring the rungs you have.** #698 required an input of exactly the seat price, because the round had no change output — so a user needed the right coin before they could pay at all. Here a participant contributes whatever rungs they hold, the recipient is paid in rungs, and surplus returns as more rungs to fresh addresses. Change is safe because it is denominated like everyone else's, not because it is hidden. **There is no fee output.** The tier round paid the coordinator with one distinct output worth `service_fee_sats() * n` — the constant the privacy harness caught last commit. Varying its value would not have helped: it is *the one output that does not match the others*, and an analyst seeing N equal outputs and one odd one knows what they are looking at. The coordinator is instead paid in ordinary rungs, accrued across rounds and settled when the total crosses one. Same mechanism as provider commission. `ladder_rounds_carry_no_solitary_constant` proves the probe now finds nothing. `NotARung` refuses a non-rung value at build time rather than leaving a probe to find it later. Every output value is a rung — not "mostly", not "except the fee". Two properties surfaced while building this, both worth recording because they look like bugs and are not: - **The mining fee is already rung-quantised, necessarily.** Every rung is a multiple of the ladder floor, so `inputs - outputs` is too. The fee lands on the smallest floor-multiple that covers it, and the residual below one floor unit is unavoidable, not waste. - **There is a band where no change can be taken at all.** Between `floor` and `floor + output_cost` the surplus cannot fund another change output, because the output costs 43 vB itself. `ExcessiveOverpayment` originally used `floor` alone and condemned rounds that had no better option; the threshold now includes the cost of the output it is recommending. Also sharpens `probe_value_constancy` to require solitary-within-round. A denomination is constant across rounds by design — that is the anonymity set — while a fee output is constant *and* shares its value with nothing. Flagging denominations would have made the probe cry wolf on a correct design; ignoring multiplicity let the fee output walk past. 9 tests, 203 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…uition
`select_inputs` was largest-first with a comment admitting the open half: which
coins you spend says something about what you hold, and a smarter selector
should prefer combinations many others could also have produced.
That intuition is wrong, and measurably so. Over 600 simulated wallets holding
realistic post-decomposition rung sets:
FewestInputs 0.8% of payers had a unique input set
CommonDenominations 9.0%
Preferring small, common denominations makes a payer **11x more identifiable**.
Set *length* dominates. Two coins drawn from sixteen rungs has few possible
combinations; eight coins has vastly more, so long sets are nearly always unique
and identify their owner outright — regardless of how ordinary each individual
coin looks. The reasoning that felt obvious (build from rungs everyone holds)
optimises the wrong variable.
So there is no trade-off here at all: fewest inputs is better for privacy **and**
cheaper in vbytes. `select_inputs` was already right; nobody had recorded why,
which is how a future "optimisation" would have quietly halved everyone's
anonymity while looking like an improvement.
`Selection::CommonDenominations` is kept solely as the comparison arm that
proves it, marked do-not-use. `fewer_inputs_is_measurably_less_identifying` pins
the direction, and says to go and look rather than assume if it ever inverts.
This closes the decomposer question flagged as open in the build plan: greedy
decomposition (canonical, maximally colliding) plus largest-first selection
(fewest inputs, least identifying). Both halves now have a measurement behind
them instead of an argument.
2 tests, 205 green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Nothing joined the decomposer, which works in values, to `LadderParticipant`, which needs coins and addresses. `plan_participation` closes that: holdings plus an amount in, a round contribution out. Three things it enforces that would otherwise be left to callers: **Fresh addresses, checked here.** One per output, all distinct. Only the wallet can derive them, but the requirement belongs at this boundary — by the time an on-chain probe notices reuse, the transaction has confirmed. Reuse is checked *first*, and across every address supplied rather than only the prefix consumed this round: a caller whose derivation repeats an address past the ones needed today will repeat it inside the window tomorrow, and "you gave me too few" is a far less serious complaint to return than "you gave me the same one twice". **A coin is never spent twice.** Values map back to specific outpoints, each consumed at most once — `a_coin_is_never_spent_twice` covers the case that would otherwise slip through, two held coins of equal value. **Recipient and change are not distinguished in the result.** An observer cannot tell them apart and neither should anything downstream. They are pooled before they leave the function. `a_planned_contribution_builds_into_a_round` runs it end to end — five participants planned independently, assembled, and probed — so the path from "I want to pay this much" to a broadcast-ready transaction is covered by one test rather than inferred from six. 6 tests, 211 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
… there
Phase 9. If round size is just "whoever turned up", padding stops being needed
once payers are plentiful and everyone supplying it stops earning — which makes
both provider roles temporary scaffolding rather than a market. Making the set
something a payer *buys* fixes that.
level set per payer on 500k on 1M
Basic 1 1,505 0.30% 0.15%
Enhanced 2 2,105 0.42% 0.21%
High 5 3,905 0.78% 0.39%
Maximum 20 12,905 2.58% 1.29%
(Within a few sats of the design note; the difference is integer-rounding the
measured 100.5 vB per seat.)
Two things this module exists to enforce rather than describe:
**A quote is a claim, and claims must not exceed construction.**
`check_quote_is_honest` takes measured dominance from `admission::dominance` and
refuses to sell a set the round cannot substantiate. A payer who bought fifty
seats and got a round where one entity holds 80% of them has an effective set of
ten, and `effective_set` is what belongs in a UI — never `nominal_set` alone.
Selling an unsubstantiated set is worse than selling nothing, because the buyer
changes their behaviour on the strength of it.
**Rounding direction is a privacy decision, not a numerical one.** The dominated
count rounds *up*, rather than the honest count rounding down. Those differ, and
floating point makes it visible: `(1.0 - 0.8) * 20` is `3.9999999999999996`, so
flooring the honest side reports 3 where the arithmetic says 4. Either direction
is defensible for a rounding error; only one is defensible for a number shown to
a user, so the adversary's share is what gets rounded up.
⚠ Module docs carry the gate: this must not ship before `admission`. Seat aging,
cluster diversity and peer dispersion are the prerequisites, not later hardening.
Also recorded in the build plan: wiring `LadderRoundBuilder` into the coordinator
is a **wire-protocol change, not an assembly change**. `assemble_round` assumes
one input and one output per participant, matched by index, and
`AcceptedInputs`/`AcceptedOutput` encode that shape. A ladder participant brings
many rungs and receives many, so `/inputs` and `/outputs` must accept a set per
participant first. That changes the coordinator's public API and every wallet
speaking it — a deliberate call, not something to improvise.
8 tests, 219 green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Phase 10, protocol side. Instant *alone* is free — a signed handoff, an ordinary transaction, no provider. Instant **and private** always needs someone to front, because a round is still a transaction that confirms in ten minutes and no cadence fixes that. That business is permanent; these are the two claims inside it that quietly become false. **The bond is the size of the tier.** Total liquidity-lane deposits must never exceed aggregate bond. Past that point "backed by a bond" is theatre: there is not enough staked to make good on what has been promised, and every depositor believes otherwise. `BondCeiling` refuses the crossing deposit rather than logging a warning, counts refusals so a demand-constrained pool is visible, and alarms at 80% while there is still headroom to act. `a_slashed_bond_shows_as_insolvent_rather_than_being_hidden` is the test worth keeping. `set_bond` deliberately permits lowering below current deposits — bonds get slashed and expire — and the type then reports `is_solvent() == false` and utilisation above 1.0 until it is restored. Clamping that away would hide precisely the state that matters most, and further deposits are refused while unbacked. **The spread is keyed to the tier, not the amount.** A proportional fee is a function of the exact payment, so publishing the price publishes the amount — the same leak as the pinned seat price, arriving through the pricing model instead of a constant. `standing_spread` keys off the largest rung in the decomposition, so 100,000 / 137,000 / 199,000 all quote identically and the price reveals only the tier the ladder already reveals. It is also quantised onto the ladder, which is not cosmetic: a payer contributes `amount + spread` in rungs, so a spread that is not itself a rung means they cannot fund the round at all. `a_payment_plus_its_spread_still_decomposes` covers that end to end. 8 tests, 227 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…ulative Phase 2's core algorithm, without the wallet integration around it. At each rollover the vault splits a budget into rungs maturing monthly; the phone spends a matured rung alone, while the vault behind them needs both devices. To a user it is a standing order from savings to a current account, and that framing is accurate rather than marketing. The finding worth the commit: **"A compromised phone reaches one month's money" is false.** Unspent rungs accumulate. A phone stolen in month nine reaches every rung matured since the rollover that has not been spent — `exposure_accumulates_and_ is_not_one_month` asserts exactly that, and by month twelve exposure equals the entire year's budget. That sentence is the one a design doc writes without checking, and someone then sizes their budget against it. `exposure_at` reports the true figure and `exposure_if_swept` reports what a wallet doing its job achieves — at most one tranche plus the discretionary reserve, measured at less than half the unswept figure by month nine. The lever is sweeping matured rungs into the hot lane promptly, not scheduling less. Discretionary rungs mature at the rollover and exist because a manual top-up otherwise needs both devices, which is the errand the standing order exists to avoid. They are also the part a stolen phone reaches on day one, so `nothing_but_the_discretionary_reserve_is_available_on_day_one` pins that nothing else leaks early, and `discretionary_never_exceeds_the_budget` stops a caller conjuring money that was never scheduled. Sub-floor budgets schedule nothing rather than minting dust. 8 tests, 235 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…onymity set
Phase 7. An instant payment hands the recipient a signed transaction; if they
are offline it has to wait somewhere, and the coordinator is the obvious place.
The obvious *design* is a social graph. Store each entry under
`recipient_ghost_id` and retrieval is trivial — and the coordinator now holds a
list of who receives from whom. Combined with what it already knows (who
deposited), that is the payment graph the blind signature exists to prevent. The
mailbox would hand back at rest exactly what the round protects in flight.
So entries are addressed by a **truncated tag** over the recipient's scan key,
and a fetch returns the whole bucket. The caller decrypts what is theirs and
learns nothing from the rest. Returning only your own entries would tell the
coordinator which were yours, which is the leak — `a_fetch_returns_the_whole_
bucket` pins that.
The tag width is the entire security parameter:
tag bits recipients per bucket at 1M users
8 ~3900
12 ~244
16 ~15
24 ~0.06 ← a social graph with extra steps
`MailboxPolicy::validate` refuses a configuration that would deanonymise
recipients, at startup rather than per request — a mailbox that identifies its
users should never accept a single entry.
`a_small_deployment_needs_a_shorter_tag` is the one I would not have thought to
write without computing it. The parameter is **population-relative**: 12 bits is
comfortable at a million users and gives a quarter of a recipient per bucket at
a thousand, which is precisely the state a new deployment launches in. A setting
chosen for the steady state deanonymises everybody on day one.
`an_emptied_bucket_leaves_no_trace` covers the smaller version of the same
mistake — a lingering empty bucket records that someone with that tag once had
mail.
8 tests, 243 green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Everything else in this crate constrains the coordinator. This is the part that makes it *untrusted*. Once a participant signs, the round leaves their hands, so every property they were promised must be verifiable from the transaction itself, by them, first — and a round missing one signature does not broadcast. There was no such check. A participant had to trust that the round they were handed was the round they agreed to. Refusals, not warnings, because the signature is the irreversible act: - **my input is absent** — the round does not spend the coin I registered - **my output is absent or short** — the simplest possible theft, and invisible unless each participant looks for their own - **fee exceeds what I accepted** — value vanishing into fees is value taken from participants - **the set is below my floor** - **an on-chain marker is present** - **my output is the only one of its value** That last one is the reason this is not just an accounting check. `an_output_unique_in_value_is_refused_even_in_a_full_round` builds a five-seat round — a healthy-looking set — where one output has a distinct value. Subtraction identifies its owner immediately. **A count of participants is not a measure of anonymity**, and a participant who only checked the set size would sign into a round that gave them nothing. Every reason is returned rather than the first, because a participant deciding whether to retry or walk wants the whole picture. This is also the non-Ghost client's entire safety story. An outside wallet joining over PSBT has no Lock, no quorum and no residency — it has the transaction. So the checks belong in the protocol crate rather than in any one client, and they work against the existing tier protocol, which means they are not behind the wire-protocol decision. 9 tests, 252 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Listed in the hazards and never built. A recipient is paid in rungs — 137,000 sats arrives as five coins — and every wallet in existence will, given the chance, tidy those into one UTXO. To cut fees, to simplify the balance, because that is what wallets do. Doing so publishes the payment. Spending all five together announces their sum, which is the amount the round went to some trouble to hide, and links the coins to one owner permanently. Nothing upstream can prevent it: the round is long confirmed, the ladder did its job, the coordinator never knew. It is undone afterwards by the recipient's own wallet, doing something reasonable. Two distinct harms, and the ordering matters: - **Revealing an amount** — several coins from one payment disclose at least their sum; all of them disclose it exactly. - **Linking payments** — coins from different payments prove one owner received both, merging two anonymity sets into one identity. The second is worse and less obvious, because each payment may be perfectly private on its own and the link is created entirely by the spend. `assess` reports it first so it cannot be buried under amount disclosures. `the_overnight_tidy_up_is_refused` is the scheduled consolidation itself: sweep everything into one UTXO, and it both links the payments and publishes both amounts exactly. `select_least_disclosing` gives the wallet a safe default — a single sufficient coin discloses nothing, and failing that it stays within one payment, because disclosing an amount is recoverable and linking payments is not. When no single payment covers the target it returns `None` rather than silently reaching across: at that point the choice belongs to the user. `is_safe_unattended` is the gate for anything running on a schedule. A warning would be worth nothing here — the dangerous behaviour is the default, and defaults are what run at 3am on a background thread. 8 tests, 260 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
… both causes The hot lane is `musig(owner, quorum)` with an `older(EXIT_DELAY)` leaf the owner can take unaided. That reads as an unconditional guarantee. It is not, and neither failure is visible in the script. **Remixing resets the clock.** `OP_CSV` is relative — every spend produces a new output whose delay starts again at confirmation. Resident coins remix continuously, because that is the entire cover-traffic design. A coin remixing every 144 blocks against a 1,008-block exit delay never reaches maturity. The owner cannot leave unaided at any point, and the script gives no hint of it. The fix is behavioural: on an exit request the wallet stops entering rounds and lets the coin sit still. The honest promise becomes "you can leave alone within EXIT_DELAY of asking", which is fine — but that stopping behaviour has to exist, and it does not yet. **Pre-signatures outlive the request.** Stopping only works if the quorum has nothing left to spend with. It needs the owner's pre-signature to complete a round, so an owner who pre-signed a hundred rounds ahead has handed over the means to keep resetting the clock after asking to leave — 4,608 blocks of delay in the modelled case, against 1,044 when one round is outstanding. `max_safe_presignatures` computes the bound a wallet must enforce, and `a_tolerance_below_the_delay_itself_permits_nothing` returns zero rather than something reassuring when the owner will not even wait the delay. Recorded in the build plan as needing a decision: the parameters themselves (exit delay, remix cadence, tolerated wait) are a product call, and the stopping behaviour is unbuilt. This is the third claim this session that had script behind it and no enforcement. Checking stated invariants against code is finding more than surveying by intuition did. 6 tests, 266 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…nything `pre_sign` lets a participant verify a round before signing — their output is present, the fee is sane, the set is large enough, no marker. **Every one of those checks is worthless unless the signature commits to the thing checked.** Verify the output set, then sign `SIGHASH_NONE`, and the coordinator may replace every output afterwards while the signature stays valid. The participant did the work, reached the right conclusion, and signed it away. The current signing path is correct — `wraith_signer.rs` uses `TapSighashType::Default` — so this pins an invariant rather than fixing a bug. Worth pinning because the failure is silent: a wrong sighash produces a perfectly valid transaction that simply is not the one anybody agreed to. And because the tempting alternative was mine. Earlier in this design I proposed `ANYONECANPAY|ALL` so signatures could be collected asynchronously, which would be a real UX gain. `anyonecanpay_leaves_the_anonymity_set_editable` is why not: outputs stay bound, but inputs do not, so the set a participant verified is not the set that broadcasts. Someone can be added or removed after they checked. `SIGHASH_SINGLE` is the other trap, and it looks the most reasonable of all — sign my own input and my own output, self-contained. It binds input *i* to output *i*, which is the input-to-output mapping written into the transaction by construction. `only_two_types_are_ever_safe` asserts the whole classification rather than spot-checking, and `every_sighash_type_is_classified` fails if a variant is ever added that silently defaults to safe. 7 tests, 273 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Checking my own modules against each other rather than against the design, four
independent copies of the same arithmetic had accumulated, and two disagreed:
tier.rs 58 in + 43 out = 101/seat canonical
privacy_level.rs VBYTES_PER_SEAT = 100 disagrees
PAYER_VBYTES = 301 should be 303
ladder_round.rs 11 + ins*58 + outs*43 magic numbers
ladder_round.rs 43 * fee_rate, twice magic numbers
This is #698 verbatim. That bug was the coordinator and the round builder each
computing the required input independently and never matching — 102,026 against
101,440 — invisible because both only checked `>=`, so the larger won and the
difference went to miners. I wrote that warning into the build plan, cited it in
the seat-price commit, and then did it four times in one night.
Everything now derives from `tier`, which also publishes `TX_OVERHEAD_VBYTES` so
nobody re-derives the 11 either.
`no_module_keeps_its_own_copy_of_the_vbyte_arithmetic` fails loudly if a copy
reappears — the original was silent, which is what made it expensive.
The published prices move slightly as a result, because the correct figure is
101 per seat rather than 100: Basic 1,505 -> 1,515, Maximum 12,905 -> 13,010.
Percentages are unchanged to two places. `tier` rounds the input up from the
measured 57.5, which is the right direction for a fee estimate.
Worth recording how this was found. Every previous gap this session came from
checking stated claims against code. This one came from the opposite direction —
checking the code against *itself*, on the assumption that a dozen modules
written in a few hours would have drifted. They had.
1 test, 274 green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
…d past it
Continuing the internal-consistency sweep. Three different answers existed for
how small a round may be, and none was enforced on the ladder path:
design note MIN_ROUND_SET = 10
tier::min_participants 5, for every tier
LadderRoundBuilder whatever the caller passed, including 1
`MIN_ROUND_PARTICIPANTS` is now published next to the vbyte constants — 5,
Whirlpool's number, already what every tier returned — and the builder refuses a
floor below it at build time rather than letting a misconfiguration sit dormant
until the first round.
The part worth recording is why this drifted. I modelled the round floor as a
launch lever and recommended starting at 3, because a lone payer pays 2,765 sats
there against 6,982 at floor 10. That analysis was right and the recommendation
was wrong: **a round of three gives 1-in-3**, which is barely distinguishable
from no round at all, and I never weighed that against the saving. A cost
argument will reason past a privacy floor every time unless the floor is in code
rather than in prose.
So the lever is real and it bottoms out at 5. Going lower is now a deliberate
decision to sell weak privacy cheaply, which is a legitimate thing to choose and
not something to arrive at by optimising fees. The build plan is corrected to
say so.
`the_builder_agrees_with_the_tier_minimum` ties the ladder and tier paths to one
number so they cannot drift apart again — the same failure as the vbyte
constants, one layer up and with a privacy consequence instead of a fee one.
2 tests, 276 green, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Third drift from the internal-consistency sweep, and the first that crosses a crate boundary — which makes it the one a grep inside either crate cannot find. `ghost-lock` defines the hot lane's escape leaf, `older(HOT_EXIT_BLOCKS)` at 1,008. `wraith-protocol::exit_availability` reasons about exactly that leaf and took the delay as a caller parameter, with 1,008 restated in its fixtures. The crates did not depend on each other, so changing the leaf would have left the analysis quietly reasoning about a delay that no longer exists — and this is the analysis that decides whether "you can always leave alone" holds. `wraith-protocol` now depends on `ghost-lock` (no cycle; `ghost-lock` pulls nothing of ours) and `ExitConfig::for_hot_lane` reads the constant rather than restating it. `the_analysis_reads_the_delay_from_the_crate_that_defines_it` covers both the constructor and the fixtures. Verified it fires: changing `HOT_EXIT_BLOCKS` to 2,016 fails with "the fixture has drifted from the leaf it claims to model", restored after. A guard that cannot fail is not a guard, and this one had no prior opportunity to be wrong. Fee rates were also swept and are clean — every rate is a parameter, with the only constant being `DEFAULT_FEE_RATE_SATS_PER_VB` used as an explicit default. 1 test, 277 green, clippy and fmt clean. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Two leftovers from the sweep, one of them mine. **I missed a copy in my own fix.** Consolidating the vbyte arithmetic two commits ago, I corrected `ladder_round` and `privacy_level` and left `ladder.rs:399` computing `mean * 43.0`. So the commit that fixed four copies of a constant left a fifth, in the module whose own report table it feeds. Now derived from `tier` like the rest — and worth noting that a fix applied by grepping for one spelling misses the ones spelled differently. **A claim nothing checked.** `Ladder::standard`'s docs said the floor "sits comfortably above the P2TR dust limit". Below dust, every rung becomes an output no node will relay — the ladder would mint unspendable money, which is a far worse failure than an expensive one. It is now asserted, with a 2x margin so a future floor reduction does not quietly land on the limit. The dust figure is computed from rust-bitcoin's `minimal_non_dust` rather than restating 330, because restating constants is the exact failure this sweep has found four times. 2 tests, 278 green, clippy and fmt clean. A grep for hardcoded vbyte or amount constants outside `tier` now returns nothing. Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
~130 tests were written tonight and none had been checked for the failure this
repo already has a name for: a check whose failure produces no observable
output. A test that passes with the logic removed is not a test.
`scripts/mutate-wraith.sh` gutts each safety-critical refusal in turn and
asserts the suite notices. All 12 are caught:
once-per-coin never refuses admission ignores clusters
pre-sign approves everything amount attack goes blind
consolidation sees no risk marker scan finds nothing
admission accepts any age bond ceiling never refuses
effective set ignores dominance mailbox accepts any tag width
exit analysis sees no risk ladder accepts non-rungs
Not `cargo-mutants`: this targets the specific refusals the design rests on and
runs in seconds rather than hours.
Three bugs in the harness itself, all of which made it lie:
- **Classified on a grepped summary line.** `cargo test` prints several and none
at all when the build fails, so the grep picked the wrong one. A control
mutation — appending a comment, which must survive — reported "caught", which
is how it was noticed. Now classified on exit code.
- **No check that the mutation applied.** A harness that silently fails to
mutate reports everything caught and looks like success. It now greps for the
replacement before running anything.
- **The trap restored a backup whose name did not map to a real file**, leaving
a stray `crates/wraith-protocol/src/control` in the tree. It now refuses to
write over a path that does not already exist.
The control is the load-bearing part: a no-op mutation must SURVIVE, and the
script exits 3 if it does not, because every other result is then untrustworthy.
Without it the harness read as twelve green ticks while measuring nothing.
Refuses to run against a dirty tree — a mutation harness and uncommitted work
are a bad combination, and that is written down here because it was learnt the
expensive way.
278 tests green, tree clean after every run.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
Fourth audit pass, against two rules this codebase already holds: *money math is
integer sats* from the design law, and the memory of one panic silencing an
entire node.
**One panic, in coordinator-reachable code.** `plan_participation` mapped
planned values back to coins with `.expect("plan only selects values present in
the pool")`. The invariant does hold — `Ladder::plan` selects from the pool it
was handed — which is precisely why it was written as an assertion. An
invariant that cannot break is the one worth returning rather than asserting
when a coordinator runs the code. Now `PlanError::PlannedCoinNotHeld`. No
`unwrap`, `expect` or `panic!` remains in non-test code across either crate.
**Three unchecked additions on satoshi values**, and the failure mode is the
problem rather than the likelihood:
u64::MAX - 500 + 2_000 wraps to 1,499 in release
A wrapped total is small and entirely plausible. In `Ladder::plan` it becomes
the selection target, so the round would fund itself with almost nothing and
look fine doing it. That is now `LadderError::Overflow`, as is
`liquidity::total_with_spread`.
`privacy_level::quote` is saturating rather than checked, deliberately: a quote
is a price shown to someone, not a spend. A saturated figure is absurd on its
face and gets rejected; a wrapped one looks like a bargain. Where the arithmetic
actually moves coins it errors instead — the distinction is which direction a
wrong number sends a human.
Floats were swept too and are clean: `f64` appears only in ratios and display
percentages, never producing a satoshi value. `quote` computes entirely in u64.
280 tests green, all 12 mutations still caught, clippy and fmt clean.
Claude-Session: https://claude.ai/code/session_01R3zCyPaMpt4tTW8ybbazHp
The wallet stopped talking to these last commit. This removes them:
`bins/ghost-pay`, `crates/ghost-gsp`, `bins/ghost-gsp` and
`ghost-gsp-proto` — and, finally unblocked because ghost-pay was the last
thing importing it, `crates/ghost-locks`, the plural crate superseded by
`ghost-lock`. Roughly 40,000 lines.
Their tests, fuzz targets and integration suites go with them, along with
fifteen scripts whose whole subject was the retired services: the L2 soak
suite, the completed `mainnet-observe-*` migration, the ghost-pay bridge
and UTXO-scan contract tests, and the recovery demo whose premise was
"kill the operator and prove the Lock still opens" — now structurally
true rather than something to demonstrate.
What was rewired rather than removed:
- `release.sh` and the release workflow no longer build or ship either
binary. `PRODUCTION_ONLY` is empty and pass 2 of the production roll
short-circuits; the two-pass shape stays, because rebuilding it under
a deadline is how the v1.11.37 split happened.
- `deploy-node.sh` drops both from its accepted binaries, service map,
ready-ports and source paths.
- `install-node.sh` no longer installs a binary the tarball does not
carry, and `--wraith` no longer drags Ghost Pay in behind it — the
coordinator needed it for a bond escrow that no longer exists.
- the dev stack and the wallet smoke test run against ghostd alone. The
smoke test's flow 8 now makes a real on-chain payment and checks both
that the node holds the txid and that the wallet wrote it to its own
history — a txid the wallet invented would pass a check that only
asked the wallet.
Two judgement calls worth stating.
The deploy gate's production-node canary relaxation (#759/#808) had these
two as its only users. Emptying the list would have left five tests that
could no longer fail, which is the exact shape of the bug this repo keeps
re-learning. So the mechanism stays, the list is empty, and the test now
asserts that it is empty: adding a name fails the gate until the cases
covering the relaxed path come back with it.
`ghost-auto-update.sh`, `backup-databases.sh` and `ops/health-check.sh`
are deliberately untouched. Each already no-ops when ghost-pay is absent,
and all three are what keep the still-deployed service managed, backed up
and watched until a retirement roll removes it. Stripping them now would
leave a running service unattended.
The pool is unchanged and still builds: it reaches ghost-pay over HTTP,
never as a dependency, and its self-check is config-driven, so it simply
reports the capability unclaimed. `CapabilityType::GhostPay` is a wire
value with challenge verification, per-challenger verdicts and fee
distribution behind it — removing it from the 5-4-3-2-1 weighting is a
consensus change needing its own height gate. The README says so rather
than quietly restating numbers the fleet still computes.
Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
History was send-only. The wallet recorded what it broadcast and nothing else, because nothing read the chain: the operator's GSP used to watch it and push what it found, which meant handing somebody a scan key and believing the answer. This makes the wallet look for itself, through its own node. `getblock <hash> 3` is what makes that possible without an indexer — the node resolves each input's previous output from its undo data, so the scanner can tell which inputs were the wallet's and therefore what it *spent*, with no `txindex` and no lookup per input. Verbosity 2 would find money arriving but not money leaving, and a history of credits with no debits reads like a balance that only ever grows. `block_scan` is the pure half: a block plus the wallet's scripts in, what moved out. No I/O, no keys, so the arithmetic that decides what a history says is testable against hand-built blocks — a coinbase reports no fee because it has no inputs to compare against, a self-send nets the fee rather than reading as a zero-value event, and one unresolved input poisons the fee rather than yielding a figure derived from the inputs that happened to be present. Two writers now meet on the txid, and neither knows what the other knows: a broadcast has the memo and the exact fee, the scanner has the height and the block time. So `record` merges instead of replacing. This is not an abstract concern — the scanner passes over every payment the wallet makes, minutes after making it, and a replacing write would erase the memo and the fee on all of them. Reorgs are handled by remembering the hash of the last block scanned, not just its height: after a reorg the same height holds a different block, and a scanner trusting the number would carry on from a fork it had already left. On a mismatch it walks back to a height both chains agree on, drops the confirmations of everything above it — the entries stay, because a reorg does not unmake a broadcast — and rescans. Confirmations now come from the recorded height against one tip read, so a settled history costs a single round trip rather than one per row, and they count the block the transaction landed in: an entry in the tip block has one confirmation, not zero. An entry the scanner has not seen mined does not borrow the tip to infer a depth. The scanner starts at the tip on first run rather than at genesis: reading the whole chain to find a wallet that usually has no history is hours of work for nothing. What that leaves out is stated — a restored wallet's older payments are absent from the *history*, not from the wallet, since the balance and UTXO list scan the entire UTXO set. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
`candidate_scan` was kept when the GSP went, whole and with its tests, but nothing fed it. This connects it to the block scanner, so the wallet finds silent payments by reading its own node instead of being told by an operator it had handed a scan key to. The announcement format was recovered from the retired service rather than guessed: an `OP_RETURN` output holding exactly one 33-byte compressed pubkey (`6a 21 …`), with the payment on a taproot output (`51 20 …`). Both are matched on the exact script shape — an `OP_RETURN` of some other length is somebody else's data, and feeding it to the scanner as a pubkey would cost an ECDH per block for noise. ## The bug the round-trip test found Writing an end-to-end test — real `ghost-keys` sender, real block encoding, real scanner — failed on its first run, and the failure was in inherited code rather than in the new code. `scan_candidate` builds its scan inputs by lifting each x-only output key to a full point under both parities, skipping any that is off-curve, and then mapped the scanner's hit back to an output by using the hit's slice index as an index into the *candidate* list. Those are not the same index. A 32-byte value on a taproot-shaped output need not be a valid curve x-coordinate, and each one skipped shifts every later entry down by one, so a hit was attributed to the wrong output: wrong vout, wrong amount, and a spend key derived for a coin that was never ours. The old comment called the skip a parse failure that "should never happen" — it happens whenever the transaction also pays somebody else. Fixed by recording where each scan input came from and mapping back through that. Pinned at both levels: a unit test with a deliberately off-curve decoy ahead of the real payment, and the round-trip that caught it. ## Where detections live Not in the history alone. A silent payment lands on a key built from the sender's ephemeral key and the receiver's Ghost ID at an index `k` only the scan recovers — lose `k` and the coin is yours in principle and unspendable in practice. So `detection_store` keeps them, keyed by outpoint so a rescan after a reorg or a restart finds the same coins without duplicating them, and refusing a corrupt file rather than starting empty: silence there would hide coins nothing else can find. `LightDetected` comes back on the IPC, with `wraith light detected` and a GUI binding, because a store nothing can read is a store that hides coins just as effectively. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Restoring from a seed produced a wallet with no history. The scanner started at the tip because it had nothing better to go on, so everything that seed had ever done was invisible — the coins were all still found, since the balance and UTXO list scan the whole UTXO set, but what they did was not. A wallet now records the tip when it is created: it cannot have been paid before it existed, so that is the earliest block worth reading. A restore takes the height from whoever is restoring — `--birth-height` on the CLI, a field on `WalletImport`, threaded through the GUI — and the scanner reads forward from there instead of from the tip. Omitting it still starts at the tip, and the log says so in as many words rather than leaving the gap to be discovered. Guessing low is safe and slow; guessing high loses history silently. The docs say that at every layer that takes the value, because it is the one way to use this and get a quietly wrong answer. Catch-up had to change with it. At fifty blocks per twenty-second tick a wallet restored from a year ago would take most of a day, so a full batch now goes straight round again instead of sleeping. Idle it still costs nothing: the first pass returns zero and the loop ends. ## The bug found on the way History, detections and the scan bookmark were written beside `node.json` — one set of files for the whole daemon rather than one per wallet. That is wrong in both directions at once: one wallet's payments appear in another's history, and the shared bookmark tells the scanner those blocks are already read, so a wallet switched to would never build a history at all. It would have looked like the scanner was broken. They now live in `<wallets_dir>/<name>/`, beside the keystore and the descriptors that were already there — which also means `WalletDelete`, which already removes that directory, takes a deleted wallet's records with it. Opening one without an active wallet is an error rather than a fallback to a shared file. Tests pin both: two wallets do not share a history, and a store with no active wallet refuses. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
The Receive screen has been advertising a Ghost ID in its BIP-21 URI that no wallet in the tree could pay. The encoder lived in the operator's service and went with it, leaving a receive path with no sender. A silent payment is two outputs that only work as a pair: a taproot output at a key derived from the sender's one-shot ephemeral key and the recipient's published Ghost ID, and an `OP_RETURN` carrying that ephemeral key so the recipient can run the ECDH that finds it. Neither half is any use alone — without the announcement the coin is unfindable, without the output the announcement pays nobody. `L1Send` routes on the recipient's form rather than on a flag, so a caller cannot ask for one kind of payment and get the other. A Ghost ID from another network is refused rather than defaulted: paying a mainnet ID from a signet wallet would put money on a chain whose key nobody holds. `create_psbt` grew a sibling taking raw scripts and extra zero-value outputs. The announcement is counted in the fee estimate before coin selection, because an estimate that ignores it under-funds the transaction and the node rejects it *after* the wallet has told the user it sent. ## What it costs, said out loud An `OP_RETURN` is a marker anyone can read. A transaction shaped like this tells every observer that a silent payment happened here; what it hides is *who was paid*, not *that a payment occurred*. That trade is in the module docs, the CLI help and the Send screen's mode card, because it is the thing someone needs to know before choosing this over an ordinary address. ## Tests The decoder landed first, which makes the sender provable rather than merely plausible: real `ghost-keys` sender, real block encoding, real scanner, and the assertion that what one writes the other finds. Then the same again through the actual PSBT builder — a payment whose announcement was dropped during coin selection would pass every script-level test and be unspendable on chain. Negative cases both ways: a payment to another Ghost ID is not detected as ours, and two payments to one recipient share no output key, or their transactions would be linkable by anyone. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Discovery has returned "no answer" since Ghost Pay went, leaving mixing to a coordinator URL supplied per round. That works and never rotates, which defeats what the election is for: coordination moving across the qualified set instead of settling on whoever the wallet shipped pointing at. A pool node publishes the draw at `/api/v1/pool/coordinator`, and it publishes the draw's *inputs* — beacon, anchor height, roster, seats — so a consumer can recompute rather than believe. `coordinator_resolve` was kept for exactly this and is now reachable: the seat list is recomputed, and the beacon is re-derived from the anchor block's hash as the wallet's own node reports it. A pool that names itself every seat is refused (#697); its tests already covered that and now guard something live. ## What asking costs, and what was done about it The wallet used to reach this through Ghost Pay so it never spoke to the pool. With the operator gone, the choice is asking a pool directly or not rotating at all. Two costs, both named: - The pool learns this address asked. The request goes through the configured Tor proxy, and if that proxy is set but unusable the request is abandoned rather than sent directly — quietly revealing the IP is the one outcome the setting exists to prevent. - The pool could learn *when* somebody is about to mix, if the ask happened per mix. It does not: the result is cached for the whole epoch, 144 blocks and about a day, so the number of asks stops tracking the number of mixes. Changing the pool drops the cache, or a day-old entry would keep sending rounds to the previous pool's seat. ## What is still trusted, and why it could not be fixed here The roster. The seat list must follow from it, but nothing proves it is the real qualified set — a pool omitting honest candidates produces a self-consistent election over a subset it prefers. I went looking for the consensus artifact that would close it. The mesh node-list checkpoint is the right shape — BFT-finalised, quorum-signed, verifiable offline by an untrusted shim, and `ghost-miner-proxy` already implements that verifier — but it carries the wrong set: *public-mining* nodes and their stratum ports, where the coordinator draw needs *coordinator*-opted-in nodes and their coordinator endpoints. Different opt-in, different endpoint, different membership. Using it would compute a different election from the pool's and send rounds to the wrong seat, which is worse than not resolving. ghost-pool builds the coordinator roster from live mesh state and says so in its own comment — "the roster comes from live mesh state, which is the defect this value exposes rather than repairs" — which is why two nodes can legitimately disagree and why `roster_commitment` exists to make a split visible. Closing it needs a coordinator-roster checkpoint of its own on the pool side, with a height gate and a fleet roll. That is protocol work, not wallet work, and inventing a half-measure here would have looked like trustlessness without being it. So: chain-anchored, not trustless, and the difference is the roster. That sentence is in the code at the point where someone would otherwise assume otherwise. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Ten participants mixing through one `wraithd` failed every run. Some got an empty IPC response, the rest timed out waiting for the round to complete. Two separate bugs, one of them silent. `FileSignatureStore::flush` staged every write through a single fixed `.tmp` sibling, shared by all concurrent writers on that ledger. Racing writers truncate each other's staging file, and once the winner renames it away the loser's `rename` fails with `ENOENT` — which `record` turns into a panic by design. The panic killed the request handler, so the daemon dropped that connection and the CLI read zero bytes. Each write now stages through a path private to it, and a failed write cleans up after itself. The quieter bug is the one that mattered. `signing_ledger_for` opens a fresh store per request, and `record` rewrites the whole table from the snapshot taken at `open`, so two mixes that both opened before either wrote each persisted a table missing the other's coin. That drops the authorisations which stop a coin entering a second round — the single thing the ledger exists to do — and it does so with every flow still green. Open-inspect-record now runs under a daemon-wide lock, released before any signing or network work so a round whose participants share one daemon cannot deadlock. Measured, not argued: with the lock removed but the staging fix in place, the ten-participant round completes, broadcasts one CoinJoin, and leaves **6 of 10** rows in the ledger. The smoke test now asserts the row count, so that failure cannot pass as green again. Also: the CLI reported a dropped connection as "malformed response: EOF while parsing a value at line 1 column 0", which sends the reader looking for a parse bug in a message that was never written. Zero bytes now says the daemon closed the connection without answering and points at its log, where the panic was sitting the whole time. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
The signing-ledger fix in the previous commit was one instance of a pattern. An audit of every store the wallet daemon opens per request found the same two defects in all of them. Every store staged its atomic write through a fixed `.tmp` sibling of the target — a path shared by all concurrent writers of that file. Extracted the one correct implementation as `ghost_lock::atomic_file::write_atomic` (unique staging path, cleanup on failure, optional 0600 before the rename, fsync of file and directory) and moved all seven onto it: the MuSig2 nonce ledger, the signing ledger, the spend log, the Lock store, history, detections, scan state and wallet metadata. There is no eighth copy left to get wrong. `node.json` was worse than the rest: it staged with a plain `fs::write` and fsynced neither the file nor its directory, so a power loss could lose a node endpoint that `SetNode` had already reported as saved. It goes through the same helper now. The lost-update half needed more than a helper. `DaemonState` now carries a path-keyed lock registry, and open-modify-write runs under it: - **The nonce ledger.** A MuSig2 secret nonce used twice publishes the signer's key. Two concurrent Lock cosigns each persisted a burn list missing the other's nonce, re-permitting exactly the reuse the ledger exists to refuse. This was the most serious instance of the pattern. - **History.** The block scanner held one store open across an entire batch of block fetches while `l1_send` wrote broadcasts into the same file, so a payment made during a catch-up scan was erased when the scanner flushed. The scanner now opens per write; nothing holds a store across an await. - **The Lock store**, on save and forget. Measured, not argued. Removing the history lock and leaving everything else in place loses **18 of 24** concurrent entries, reproducibly across three runs. Both regression tests were watched failing before they were made to pass, and the full regtest smoke test is green end to end. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
The protocol assembles rounds at five participants. The wallet's default anonymity floor is ten. Landing in a legal round that is too small is therefore an ordinary event — and it was not free. The floor was only checked by `inspect`, which runs on the assembled transaction. By then `/inputs` has committed the outpoint to the round, so a wallet that enforced its own privacy policy declined to sign, was swept as a non-signer, and had its outpoint banned for a cooldown. The wallet was punished for the policy working. `prepare_mix` now checks at the last moment leaving is free: after the round locks, before the coin is committed. Judged on seats, not entities, and that distinction is the whole subtlety. The coordinator does serve an entity count, and reading it here looks right — but `session_set_report` derives it from committed inputs, so before anyone commits it is zero. A check against it refuses every round while appearing to work. Seats bound entities from above (clustering collapses seats, never splits one), so too few seats proves the floor is unreachable; enough seats proves nothing and is left to `inspect`, which recounts from the chain once a transaction exists. I caught that only because three existing tests failed. The regression test now asserts the observed seat count equals the number enrolled, so a figure that is zero before commitment cannot make it pass for the wrong reason again. Verified by removing the check: `prepare_mix` returns a fully assembled round instead of an error, meaning all five coins were committed to a round every wallet would go on to refuse. Regtest smoke is green end to end. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Startup removed a stale socket file unconditionally. It never checked whether anything was listening on it, so a second daemon took the endpoint away from a running one and both then served the same wallets directory. That is exactly the arrangement the store locks cannot protect. They serialise read-modify-write within one process; across two processes every race they exist to stop comes back — including the nonce-ledger one, where a lost burn means a reused MuSig2 nonce. The displaced daemon is also left holding a listener nothing will ever connect to again. The socket is now only removed if a connection attempt fails. If a daemon answers, startup refuses with AddrInUse and says why. Verified both directions: with a daemon running, a second exits 1 with the message and the first survives; with a plain file planted at the socket path, the daemon logs "nothing answered on it", removes it and starts. Regtest smoke is green. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
… rules
The Cash lane was byte-identical to the wallet's default receive address.
`lock_owner_seckey` derived the Lock owner from account 0' — the account
`light receive` uses — and Cash is a bare key-path output for the owner key,
so both landed on the same address. One coin appeared in the wallet balance
and in the Lock total; regtest showed 1,000,000 sats twice for one UTXO.
Lock owner keys move to account 1'. A Lock coin and a loose coin are now
different coins.
That collision was also why two of the three compartment rules could not be
enforced. Of the rules the Lock's whole value rests on, only rule 2 had a
caller:
- **Rule 1** ("a Cash coin must never enter a round") was computed only to
colour a lane in the UI. Now enforced at both mix entry points, before the
coin is offered to anyone. It was unenforceable while Cash was the wallet's
receive address, because refusing Cash would have refused every ordinary
coin.
- **Rule 3** (`check_spend_together`) had no callers at all outside its own
tests. It is reachable because `GhostLockQuorumSign` and
`GhostLockSignBegin` sign one input of a PSBT the CALLER supplies, so the
caller picks the other inputs: hand the wallet a transaction spending a Cash
coin beside a Savings coin and it signed, publishing the link between two
lanes the Lock exists to separate. Now refused before any signature exists.
Each fix has a test that was watched failing first, and the smoke test asserts
the lane address differs from the receive address and that a fresh lane holds
nothing — the earlier runs, where the two addresses matched, are the negative
control for that one.
Known gap, deliberately not closed here: rule 3 counts only coins that sit at
a Lock lane. Spending a Savings coin beside an ordinary account-0' coin still
links Savings to the public wallet. Closing it means ruling that non-Lock
coins are Cash-like, which is a policy choice rather than a bug fix.
Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Closing the gap left in the previous commit, and it turned out to be wider than the gap I named. `check_spend_together` only states the Cash boundary. Even with a caller it says nothing about a Savings coin spent beside a Spending coin — both are `Compartment::Private` — or beside an ordinary wallet coin in no lane at all. Every one of those proves a single owner by the common-input heuristic, which is the sentence the compartment rule is itself built on. Grouping three lanes into one compartment was right for the Cash rule and is not a spending policy. The wallet now requires that every input of a Lock spend sits in the same lane of the same Lock as the input being signed. An input whose previous output the PSBT does not carry is refused too: it cannot be shown to be in the lane, and for a rule about what a signature reveals, unproven is not good enough. I had also guarded only two of the three handlers that sign a caller-supplied PSBT. `GhostLockEscapeSign` takes one as well, and was unguarded. All three now go through one function, because a rule applied to two of three is a rule with a way around it. The test covers a Savings coin spent beside a Cash coin, beside another lane of the same Lock, and beside the wallet's own receive address — and, so the rule cannot pass by refusing everything, that two coins in the SAME lane are still spendable together. Verified by disabling the lane rule: the Cash case is still caught by the compartment rule, and the other two slip straight through. Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
Every flow up to now funds a lane; none ever spent one. The three handlers that sign a Lock spend were unit-tested only, so no lane coin had moved on any chain and no escape leaf had ever been executed by a node — the part that is judged by consensus rather than by our own assertions. The Spending lane's exit is 1,008 blocks, which regtest reaches in seconds, and it needs no quorum and no backup device: a key, a delay, a transaction. That makes it the one escape drivable end to end here, and it exercises a taproot script-path spend, the CSV delay, and the nSequence the leaf demands. Flow 10 funds the lane, asserts the wallet refuses while the coin is immature (1,007 blocks to wait), mines past the delay, builds the spend with `createpsbt` + `utxoupdatepsbt`, signs it with `lock escape`, and broadcasts. A wrong witness, a wrong sequence or an immature CSV are all rejected by the node and nowhere earlier, so the broadcast is the assertion. It then checks the lane is empty, because a signature that confirms is not yet proof the coin left where it was supposed to leave. Passes: escape spend confirmed, spending lane back to zero. Still not covered on chain: quorum co-sign (needs a coordinator started with a lock seed) and the air-gapped key-path spend (needs the harness to act as the backup device). Both are reachable from here; neither is covered yet.
`GhostLockSignBegin` / `SignNonce` / `SignComplete` had no test of any kind. They appear only in the IPC definition, the CLI, the GUI and the daemon — no test file referenced them. That is the MuSig2(owner, backup) key path: the ordinary way money leaves a Ghost Lock. The device half was already covered by ghost-lock-signer's own round trip. The wallet's half was not, and the two had never been run against each other. Flow 11 builds a Lock whose backup key belongs to a real `ghost-lock-signer` instance with its own generated seed — a key this wallet does not hold, so the ceremony cannot degenerate into the wallet signing with itself twice. It then drives all three rounds, with the device held open across both of its rounds on a FIFO, and broadcasts. The node finalises the PSBT rather than the script doing it, which is a second check: a key-path signature it cannot finalise was never going to spend the coin. Then the lane is asserted empty, because a confirmed transaction is not yet proof the coin left where it was meant to. Also corrected an assumption in flow 10's neighbour: the escape path returns `tx_hex`, but this ceremony returns a signed PSBT and nothing else. The first run failed on that and reported "no transaction" while the signature it had just produced was sitting in the response. Wallet smoke is now 11/11: create, select, derive, balance, fund, scan, Lock lanes, payment, mix, escape spend, air-gapped spend. Still uncovered on chain: the quorum co-sign path, which needs a coordinator started with a lock seed.
`SMOKE_NETWORK=signet` runs all eleven flows on a private signet. Everything network-shaped that regtest never exercises now gets exercised: address prefixes (tb1p rather than bcrt1p), the network each component is told it is on, and every guard that branches on it. Private rather than public signet, because the flows need to mine. Ageing a Spending lane past its 1,008-block exit delay would take a week on a chain somebody else mines. A signet of our own with a trivial block challenge keeps `generatetoaddress` while leaving the rest of signet intact. The trivial challenge removes the block SIGNATURE, not the proof-of-work, and that distinction cost a run. Signet keeps real PoW — about two seconds a block here — and Core's default `maxtries` of 1,000,000 gives up before finding one. It does not report that: `generatetoaddress` returns an empty array and exit 0, so the chain silently stops advancing and the first symptom is "Insufficient funds" in a later flow, nowhere near the cause. All mining now goes through one `mine` helper that raises `maxtries` and asserts it got the blocks it asked for, so a chain that fails to advance says so where it happens. Regtest stays the default and is unchanged: 11/11 green.
Two failures in the signet runs, both from the same wrong assumption: that `generatetoaddress` mines what you ask for. `maxtries` is a budget for the whole call, not per block. One billion tries buys about 200 signet blocks and the call then returns the blocks it did find with exit 0 — so a request for 1,010 came back with 206 and the run continued on a chain 800 blocks short. `mine` now loops to a target HEIGHT instead of trusting one answer. Then the looping version died silently mid-mine. A single call grinding for a thousand blocks runs past the RPC timeout, and under `set -euo pipefail` that non-zero exit kills the script before the stall guard can say anything: no message, just the cleanup trap, reading as though the run simply stopped. Mining now goes in bounded chunks and tolerates a failed call, because the loop already detects a chain that is not advancing and says so. Regtest unaffected and green.
Three signet runs died with no message. `set -euo pipefail` aborts silently and the cleanup trap then prints its usual shutdown line, so an aborted run reads exactly like a finished one — and I misdiagnosed it three times, each time "fixing" the mining loop it appeared to die in. An ERR trap now names the line and the command. It found the real cause on the first run: mining had completed all 1,010 blocks, and `lock escape-plan` was failing afterwards. The cause is behaviour worth keeping, not a bug. Mining a thousand signet blocks takes about twenty-five minutes with no wallet activity, so wraithd's idle auto-lock fires — exactly as it would for someone actually waiting out a seven-day exit delay. Unlocking again is part of that flow, so the suite now does it and asserts it worked, which also covers a path nothing else did: recovering from an auto-lock and then spending. Also hardened the mining loop's height read. A bare `$(cli getblockcount)` is fatal under `set -e` the moment the node is too busy to answer, and an empty result is worse — the next integer test fails with "integer expression expected" and takes the run with it. Regtest green throughout, now 11/11 plus the unlock assertion.
…can spend The Spending lane's key path is MuSig2(owner, quorum) — the lane's fast path, and it could not work at all. `lock_id` is a hash over the Lock's `quorum_pubkey`. The quorum derives its key from the id it is handed. So each needed the other to exist first: no wallet-built Lock could ever carry the key the coordinator would go on to sign with, and the lane was reachable only through its escape leaf, 1,008 blocks later. Measured before the fix — key in the Lock `990b3648…`, key the coordinator derives `062a9412…`, for the same Lock. It went unnoticed because the two halves had never met. The coordinator's live tests pass a plain string as the id and never go near the wallet's `derive_id`, so both sides passed their own tests. The quorum now derives from a **binding id**: the same tagged hash as `lock_id` minus the quorum key, which is precisely the part that has to be known first. Everything else is still committed to, so two Locks differing in any other field still get different quorum keys. Two differing only in the quorum key would share a binding id and cannot exist, because that key is the derivation's own output. The wire field is renamed `lock_id` -> `binding_id` rather than quietly changing what goes in it: the two are different identifiers, and a name that lies about which one is expected is how this went unnoticed for so long. They carry different domain tags so neither can be substituted for the other and still produce a working key. An operator needs the id BEFORE the Lock exists, so `wraith lock quorum-id` prints it along with the exact `ghost-lock-signer` command to turn it into a key. That ordering is the whole shape of the fix and there was no way to express it before. Proof is flow 12, end to end on chain: derive the key from a binding id before the Lock exists, build the Lock with it, fund the Spending lane, co-sign with a coordinator holding the quorum seed, broadcast, and assert the lane emptied with no timelock involved. Also asserts the binding id and lock id are different values, so the cycle cannot quietly return. Two things the flow surfaced that are worth knowing: co-signing defaults to STANDBY and an operator must pass `--lock-cosign-role active`, and a standby refuses in a way that reads like a signing bug.
My previous commit swept up `lock-cosigned-coins.json` — runtime state the smoke test generated — because `git add -A` found it in the repo root. Removed from tracking and ignored. It was there because `--lock-ledger-dir` defaults to the working directory, so a coordinator started from the repo root writes its Lock co-signing ledger beside the source. The smoke test now passes its own datadir. Worth knowing beyond the tidy-up: that file is the once-per-coin ledger for Lock co-signing, and its own module says a forgetful ledger is worse than none because it reports a guarantee it stops providing. Defaulting its location to wherever the process happened to start means a coordinator relaunched from a different directory silently begins with an empty one. I have not changed the default — it is an operator-facing flag and deployments may rely on it — but it deserves a decision.
Nearly every call site is `X=$(WRAITH ...)`, and under `set -e` a non-zero exit
aborts AT THE ASSIGNMENT — so the captured output, which is exactly where the
daemon's reason lives, was discarded unread.
That cost real time and produced a wrong conclusion. When the co-sign flow hit
a standby coordinator I reported that a standby "refuses in a way that reads
like a signing bug". It does not. The wallet was reporting it precisely all
along:
the quorum refused: this coordinator is on standby and does not co-sign
Locks; only the active one does, because two ledgers can be asked to sign
two different spends of one coin
The harness threw that away and showed an unexplained exit 1.
The wrapper now dumps stdout and stderr when a call fails, and passes stdout
through untouched when it succeeds. The two streams stay separate so a warning
can never corrupt JSON a caller is about to parse.
Verified by putting the coordinator back on standby: the refusal above now
appears in full, where before there was nothing.
`--lock-ledger-dir` defaults to the working directory, so a coordinator started from anywhere writes its Lock co-signing state beside wherever it happened to be launched. That is how `lock-cosigned-coins.json` ended up in this repo. The file is the once-per-coin record for co-signing, and that module is explicit that a forgetful ledger is worse than none: it reports a guarantee it has stopped providing. A coordinator relaunched from a different directory silently starts with an empty one and will co-sign a coin it has already co-signed — the exact double-signing the ledger exists to refuse. Startup now always reports the resolved absolute path, and WARNS when the directory was defaulted rather than chosen. The default is unchanged: it is operator-facing and deployments may depend on it, so that is a decision to take deliberately rather than a side effect of this fix. Verified both paths: defaulted logs the warning naming the working directory, pinned logs the resolved path at info.
CI caught what I did not: `lock_cosign_mock.rs` deserialises the nonce request by name and still named `lock_id`, so all four tests failed against the renamed field. The reason I missed it is worth recording. `cargo test` stops at the first failing test BINARY. My earlier run aborted on the coordinator's router test, so this binary never ran; I then fixed router and re-ran only that package, and this one stayed unrun through several green-looking checks. Verifying with `--no-fail-fast` is the difference between "the suite passed" and "the suite got as far as the first failure". The mock now names `binding_id`, and deliberately keeps deserialising by name: if the wallet ever goes back to sending a lock id, these tests fail loudly rather than the quorum deriving a key nobody can produce.
…ger dir ## Cash was stranded Moving Lock owner keys to account 1' broke the Cash lane. Cash is a bare key-path output for the owner key and its design is that it "spends with your key alone, as an ordinary single-sig input" — but the ordinary signer walks only the receive chain on account 0', so it could no longer derive the key for a Cash output. Before the account move Cash WAS receive address 0, so this worked by the same accident that made the two indistinguishable. Nothing caught it because every flow funded Cash and left it there. Flow 13 now spends it, and it fails without the fix: "the signer produced nothing usable". `find_owned_key_path` walks both families and returns the path that matched. The other three lanes are unaffected — they commit to a script tree and an aggregate internal key, so they never match a bare key-path address. Both path builders live in `light` now, and the daemon uses the shared one. That work also surfaced a gap: `PsbtSign` existed in the IPC and the GUI used it, but the CLI never exposed it, so from a terminal there was no way to sign a PSBT at all. `wraith psbt sign` now does. ## --lock-ledger-dir is now required Recommended and taken: co-signing refuses to start without it rather than defaulting to the working directory. These ledgers record which coins the quorum has already signed for, and a coordinator relaunched from elsewhere would silently begin with an empty one and sign a coin twice. Requiring it is free precisely because the path it guards could not work before: Lock co-signing was unreachable until the quorum key's derivation was un-cycled, so no deployment can depend on the old default. Regtest 13/13. 1,241 tests pass under --no-fail-fast across the touched crates.
This was referenced Sep 8, 2026
defenwycke
added a commit
that referenced
this pull request
Sep 8, 2026
* chore: one atomic-file implementation, not two #853 and #863 each carried a copy of the same durable-write helper, because the wallet crates and the consensus/MPC crates could not reach each other's homes for it. Both have now landed, so the duplication is real rather than prospective, and it is exactly the shape of defect that produced them: the fixed `.tmp` staging path was duplicated across seven stores and every copy carried the same race. `ghost_common::atomic_file` is the implementation. `ghost_lock::atomic_file` becomes a re-export of it, which is the route the wallet crates take — they all depend on `ghost-lock` already and none depended on `ghost-common`, so this collapses the code without threading a new dependency through four crates. `ghost-lock` takes `ghost-common` with `default-features = false`, dropping its UNIX-only `zmq`/`tmq` feature, which the wallet cannot build for Windows. `grep -rl "pub fn write_atomic"` now returns exactly one file. Workspace builds, clippy clean under the CI lint set, machete clean, 1,242 tests pass under --no-fail-fast, regtest smoke 13/13. * chore: refresh the fuzz lockfile for the new dependency edge CI's Format job builds the fuzz crate with `--locked`, and adding `ghost-common` to `ghost-lock` changed the dependency graph, so `fuzz/Cargo.lock` no longer matched: "cannot update the lock file because --locked was passed". One line, `ghost-common` under ghost-lock's dependencies. Worth noting where the blind spot was: workspace-level fmt, clippy and machete all passed, because none of them look at the fuzz crate's lockfile. The seven shell checks in that job are the ones that would have caught it, and I had not run them. All seven pass now.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The Wraith privacy round and the four-lane Ghost Lock, and the retirement of
the Ghost Pay / GSP layer they replace.
135 commits, 220 files, +35,525 / −52,610. It is large, and I tested
whether it splits: it does not, cleanly. See "Why this is one PR" at the end.
Read it in two halves
The line count is dominated by deletion. 78 of the 220 files are deletions
carrying 45,588 of the removed lines —
bins/ghost-pay,bins/ghost-gsp,crates/ghost-gsp,crates/ghost-gsp-proto,crates/ghost-locksand theirscripts. That half reviews fast.
The other 139 files are the new wallet, the Lock, and the round work.
Suggested order
crates/ghost-lock— custody. Taproot, MuSig2, the compartment rules.crates/wraith-protocol— the round, the anonymity-set accounting, thesigning and nonce ledgers.
apps/wraith-wallet— daemon, CLI, core.bins/wraith-coordinator— the co-signing and round-assembly surface.The Ghost Lock
One account with four compartments, not four accounts. Each lane makes a
different promise, and the wallet says so rather than presenting one balance:
Investments deserves the closest reading: it is the one lane the quorum moves
without the owner, which is what lets a liquidity provider supply while the
owner is offline. That is a genuinely different risk from the other three.
Wraith rounds
Mostly about making dishonest behaviour unrepresentable rather than merely
detected. Anonymity sets are counted in entities, not seats; the client
recounts the set from the chain rather than believing the coordinator, and
refuses to sign a set it has not verified.
Correctness work, with evidence
Each of these was found by running the thing, and each has a test I watched
fail before making it pass.
stores shared one fixed
.tmpstaging path, so racing writers destroyed eachother's file; worse, each was opened per request and persisted by whole-file
rewrite, losing updates. Measured: with the staging fix alone, a
ten-participant round completes and leaves 6 of 10 rows in the ledger.
The nonce ledger had the same shape, where a lost row means a reused MuSig2
nonce and a published key.
checked after
/inputshad committed the outpoint, so a wallet enforcing itsown privacy policy was swept as a non-signer and had its coin banned.
was byte-identical to the wallet's BIP86 receive address, so one coin
appeared in both balances. Lock owner keys moved to account
1'.check_round_eligibleonly coloured a lane in the UI;check_spend_togetherhad no callers at all outside its own tests. Both are enforced now, and the
spend rule is stricter than the original: every input of a Lock spend must
sit in the same lane of the same Lock.
lock_idis a hash overquorum_pubkeywhile the quorum derives its keyfrom the id it is given, so each needed the other first — the Spending lane's
fast path could not work at all and the lane was reachable only through its
escape leaf. Fixed with a distinct binding id.
wraithdcould take a running one's socket, putting two daemonson one wallets directory.
Testing
scripts/smoke-test-wallet-e2e.shruns 13 flows against a real node, onregtest by default and on a private signet with
SMOKE_NETWORK=signet. Bothgreen.
It covers wallet creation, address derivation, funding, the wallet's own
scanner, Lock lane derivation and funding, an on-chain payment, a
ten-participant CoinJoin, and every way money leaves a Lock: the escape
leaf after its CSV delay, the air-gapped MuSig2 spend driven against a real
ghost-lock-signerprocess holding a key the wallet does not have, and thequorum co-signed spend against a coordinator holding the quorum seed, and the
Cash lane spent with the owner's key alone.
wallet's account stranded them: Cash is a bare key-path output for the owner
key and spends as ordinary single-sig, but the signer walked only the receive
chain. Every flow funded Cash and left it there, so nothing noticed. The
signer now walks both derivation families, and flow 13 spends it.
PsbtSignwas unreachable from the CLI. It existed in the IPC and theGUI used it; there was no terminal path to sign a PSBT.
No linkage
A Lock spend must have every input in the same lane of the same Lock. That
covers a Cash coin beside a private one (the original compartment rule), two
different lanes of one Lock, two different Locks, and a lane coin beside an
ordinary wallet coin — all of which prove one owner by the common-input
heuristic. An input whose previous output the PSBT does not carry is refused
too: it cannot be shown to be in the lane, and for a rule about what a
signature reveals, unproven is not good enough.
--lock-ledger-diris required rather than defaulted: co-signing refuses tostart without it, so a coordinator can never begin with an empty
double-signing ledger because of where it happened to be launched.
Known gaps
ghost_lock::atomic_fileduplicatesghost_common::atomic_filefrom fix: atomic file writes in ghost-consensus and ghost-mpc #863.Whichever lands second should collapse them.
Why this is one PR
I tried the obvious split — deletions first, then the new work — by building a
deletions-only branch off main. It does not compile, and not for a fixable
reason: the workspace manifest simultaneously drops the retired crates and adds
the new ones, and 56 files on main reference the retired crates. A standalone
retirement PR means hand-writing an intermediate manifest and editing those
references: a third changeset that never existed as a working state, written
only to be reviewed.
CI is green on the branch head.