Skip to content

Tracking: chain-observable transaction-level fingerprinting #1597

Description

@bc1cindy

payjoin's privacy collapses when sender's and receiver's wallets produce divergent transaction-level fingerprints. a chain analyst then partitions the merged tx back into per-owner inputs. primarily through the backward channel, since the lib already coerces intra-tx uniformity for nSequence (see receive/common/mod.rs:280-317 and send/mod.rs:391,1295).

complements #1586 (network-level): this covers nSequence divergence in prior transactions feeding a payjoin, what any chain observer sees after broadcast.

verified across the same six integrations. each wallet's own nSequence policy (used in its non-payjoin transactions, which become payjoin inputs) splits into two groups (the second a bug):

group A - uniformly 0xFFFFFFFD (ENABLE_RBF_NO_LOCKTIME):

group C - mixed [0x01, 0xFFFFFFFF, …] (bug):

  • Cake Wallet transaction_builder.dart:402-406 applies replaceByFeeSequence only to inputs[0]; remaining inputs stay at defaultTxSequence. Combined with electrum_wallet.dart:1516, every multi-input cake tx with confirmed UTXOs has the deterministic pattern [0x01, MAX, …, MAX], propagating through the spending graph.

empirical confirmation in How Fingerprints Damage PayJoin Privacy @arminsabouri: a real Cake → BBM payjoin is intra-uniform (the lib coerces receiver to match sender's nSequence), but Cake's prior tx carries the [1, MAX] pattern from the group C bug, propagating Cake-ownership via the backward channel and recovering the partition.

the lib's intra-tx coerce (good!) doesn't reach into transactions that predate the payjoin. prior txs were built with each wallet's standard tx-construction code - the leak lives there.

should I open an issue on cake to fix group C (apply replaceByFeeSequence to all inputs, not just inputs[0])?

zooming out, same family as #1586: would an "integration conformance policy" make sense? lib defines required tx-construction behaviors (uniform nSequence, canonical sighash, etc.), enforced via sender-side validation or documented as protocol requirement, closing the divergence category at the source instead of patching wallet-by-wallet.

the target end-state is only one group. with group B dissolved, 5 of 6 already converge on 0xFFFFFFFD; only Cake's group-C bug diverges. fixing group C therefore closes the nSequence divergence entirely - there's no residual A↔B split (a payjoin-cli ↔ Boltz payjoin is already uniform). canonical convergence on 0xFFFFFFFD (Bitcoin Core default since v0.16, now used by 5 of 6) plus sender-side conformance validation is the remaining policy question.

EDIT: group B - uniformly 0xFFFFFFFF (Sequence::MAX) corrected, dissolves into group A. the old Boltz/BBM MAX was their payjoin-contributed input, which the lib overwrites with the sender's sequence at receive/common/mod.rs:285-317 it never reaches the chain. their own txs use 0xFFFFFFFD

Activity

  1. arminsabouri commented on Jun 2, 2026

    @arminsabouri
    Contributor

    group C - mixed [0x01, 0xFFFFFFFF, …] (bug):

    Good find. I would just upstream a fix to this file: https://github.com/cake-tech/bitcoin_base/blob/41091028100cf0e6756ed9ed73d7092fe7663d34/lib/src/bitcoin/script/op_code/constant.dart#L153

    should I open an issue on cake to fix group C (apply replaceByFeeSequence to all inputs, not just inputs[0])?

    and yes.

  2. bc1cindy commented on Jun 4, 2026

    @bc1cindy
    ContributorAuthor

    nLockTime across the integrations: #1676

    Randomized backdating (≠ exact tip):

    by default, Bitcoin Core's anti-fee-sniping sets nLockTime to the current block height but ~10% of the time picks a height up to 100 blocks back, to improve privacy for transactions broadcast some time after signing (e.g. high-latency mix networks, CoinJoin) spend.cpp:1029-1031.

    • Liana - reimplements Core's anti-fee-sniping: create_spend computes locktime = anti_fee_sniping_locktime() commands/mod.rs:302-313 and passes it into the tx commands/mod.rs:746. The function spend.rs:485-517 returns the current height, but ~10% of the time (clock precision permitting) subtracts 1-100 blocks; it returns 0 if the tip is >8h old spend.rs:45 or its timestamp is unknown. So ~10% of Liana prior txs sit below the tip.

    Exact-tip (locktime = current block height, no randomization):

    • payjoin-cli opts out of this: it sets an explicit locktime = get_block_count() wallet.rs:54-59, passed to walletcreatefundedpsbt. Core writes it into rawTx.nLockTime via ConstructTransaction rawtransaction_util.cpp:151-155, then FundTransaction copies it into coin_control.m_locktime spend.cpp:1505-1512, and a set m_locktime disables anti-fee-sniping spend.cpp:1324-1327 so ~10% randomization never runs. The code comment calls this "an opinionated default for external wallet integrations to follow."
    • ldk-node - wraps bdk_wallet 2.3.0. Its on-chain send (send_to_address, wallet/mod.rs:710-769) builds via build_tx without calling .nlocktime() in any branch, so bdk's no-locktime arm applies: fee_sniping_height = current_height, no subtraction mod.rs:1433-1452. Its channel-funding path sets an explicit locktime, but also to the exact current height event.rs:628-629, so every ldk-node tx is exact-tip.
    • Bull Bitcoin - wraps bdk_wallet 2.3.0 via bdk-flutter. Its builder bdk_wallet_datasource.dart:168-244 sets only sequence (setExactSequence) and fee, never a locktime, so the same bdk no-locktime arm applies → exact current height.

    these three converge on exact-tip, distinguishable from core-standard wallets, but not from each other.

    Protocol-driven

    No anti-fee-sniping

    Backward-channel consequence:

    four groups - exact-tip {payjoin-cli, ldk-node, BBM}, backdate {Liana}, protocol {Boltz}, zero {Cake}.

    tip is shared by the exact-tip cluster and Liana ~90% of the time; 0 is emitted not only by Cake but by any anti-fee-sniping wallet on a stale/unknown tip or lagging chain. The distinctive signals are Boltz's swap-timeout height (a specific non-tip value), Cake's 0 across its entire history, and Liana's ~10% below-tip tail over several txs. Only the exact-tip cluster {payjoin-cli, ldk-node, BBM} fully converges -- the value payjoin-cli explicitly proposes as a default.

  3. bc1cindy commented on Jun 4, 2026

    @bc1cindy
    ContributorAuthor

    following the nLockTime analysis, the same cluster {payjoin-cli, ldk-node, BBM} converges on the privacy-preserving default on a third construction axis, change position, while Liana diverges

    Output ordering (change position) across the integrations

    Randomized (change index shuffled, hides which output is change):

    Deterministic position (insertion order, not shuffled - change identifiable by position):

    Single-output sweep (no change) - Boltz: claim/refund txs have one output (the destination), no change to place Claim.ts:77-93.

    Backward/forward-channel consequence:

    change position is a per-output property and targets the analyst's central goal: change identification. Liana places its change deterministically last, so its prior (backward) and future (forward) txs expose it by position; the randomizing cluster {payjoin-cli, ldk-node, BBM, now Cake} hides it. But the signal is distributional: in a 2-output payment a shuffling wallet also lands change last ~50% of the time, so it firms up only over several of a wallet's txs and is decisive on a single tx only for 3+ outputs.

  4. bc1cindy commented on Jun 4, 2026

    @bc1cindy
    ContributorAuthor

    Input ordering across the integrations

    the order of inputs in each wallet's standard tx (BIP-69 sorted vs shuffled vs selection order):

    Shuffle (randomized input order):

    BIP-69 lexicographic (sorted by txid, then vout):

    Selection order (no sort, no shuffle):

    • Liana pushes inputs in coin-selection order, iterating the selected coins directly with no sort or shuffle spend.rs:767-776.

    Backward/forward-channel consequence:

    input order is checkable from the tx alone (are the n inputs sorted by txid/vout?), no change-ID needed. A shuffling wallet lands on that exact order only by coincidence, so a multi-input prior tx in BIP-69 order is a strong Boltz/Cake Boltz tell. The shuffle cluster {payjoin-cli, ldk-node, BBM, Cake} hides it; Liana's selection order falls in the same not-BIP-69 bucket as the shufflers. Convergent set: {payjoin-cli, ldk-node, BBM, Cake}.

  5. bc1cindy commented on Jun 6, 2026

    @bc1cindy
    ContributorAuthor

    receiver input selection (UIH2 avoidance) across the integrations:

    unlike the deterministic axes (nSequence, nLockTime, input type), this one isn't a per-wallet value that convergence eliminates

    • UIH2 is a collaboration artifact: the observable is whether the receiver's contributed input leaves the tx with an input smaller than its smallest output ("unnecessary input"), a property of the values, so it's probabilistic and not closeable by making wallets uniform.

    the lib's best-effort mitigation try_preserving_privacy (receive/common/mod.rs:207-275) picks, for a 2-output tx, a contributed input keeping min(in) > min(out); otherwise it silently falls back to the first candidate. so the per-wallet difference is just whether the receiver even attempts avoidance:

    routes through it - single input, no self-added outputs:

    skips it (bug):

    • Cake Wallet - the receiver creates the WantsInputs (pj5) via commitOutputs and, in the same contiguous block, picks unspent[0] and contributes it via pj5.contributeInputs - pj5's whole lifecycle (create → contribute) is here, with no tryPreservingPrivacy call payjoin_receive_worker.dart:175-183. the selector isn't missing - it's defined on that same WantsInputs in the payjoin_flutter ref cake pins receive.dart:363; cake just skips it

    why universal adoption doesn't close it: the selector is best-effort by construction. it only acts on 2-output txs, otherwise returns the first candidate, and even then can only pick among the UTXOs the receiver holds (receive/common/mod.rs:207-275). so calling it lowers the leak rate but can't guarantee UIH2 avoidance. Example 3 (Cake → BBM) in How Fingerprints Damage PayJoin Privacy by @arminsabouri illustrates a real payjoin that still decomposed - the receiver's 19,358-sat input read as UIH2/peel-chain, value conservation + a round 10k payment fixed the output partition, and the prior-tx nSequence (backward channel) completed it. That example predates #3304: Cake then skipped the selector (strictly worse), now fixed - but even with the selector, none are immune.

    cake should route its receiver through tryPreservingPrivacy, I'll open an issue and PR there

  6. bc1cindy commented on Jun 6, 2026

    @bc1cindy
    ContributorAuthor

    input script type across the integrations: #1722

    each wallet's UTXOs carry a script type fixed by the UTXO being spent. the lib's input normalization is field-level. contribute_inputs overwrites each contributed input's sequence receive/common/mod.rs:285-317 but script type isn't a rewritable field, so a sender/receiver type mismatch can't be normalized away (only matched-when-available or flagged). the type then partitions the inputs.

    P2WPKH (uniform - no leak among them):

    distinctive type (partition signal vs the cluster):

    • Liana - descriptor is only ever wsh() or tr() miniscript descriptors/mod.rs:792-819 carrying an older() timelock branch (test: wsh(or_d(pk,and_v(v:pkh,older(52560)))) :862) a witness no P2WPKH wallet produces under wsh() (or a tr() recovery/script-path spend)
    • Cake Wallet - holds 5 types electrum_wallet_addresses.dart:25-30; coin selection filters only coin-control (sending/frozen) and an mweb-vs-non-mweb check, never among bitcoin script types, and the default any passes every type electrum_wallet.dart:877-902, tagging each input's own type :970 → can mix types in one tx
    • Boltz (draft, #1430) - receiver lists its wallet UTXOs, sorts them by amount, and contributes the smallest one that fits payjoin/mod.rs:518-542; the input type is re-derived from the UTXO's address (is_p2wpkh → new_p2wpkh, is_p2tr → new_p2tr_keyspend, else generic new) payjoin/mod.rs:544-575, never matched to the sender. The UnspentOutput struct carries only txid/vout/address/amount/asset (no redeem_script/witness_script) → whatever type the wallet holds (Core wallet: default P2WPKH, taproot supported)

    so a payjoin pairing the P2WPKH cluster with Liana/Cake/Boltz carries mixed input types, so the type cleanly partitions sender vs receiver inputs. unlike the probabilistic axes, this is deterministic per wallet and visible directly on-chain.

  7. bc1cindy commented on Jun 6, 2026

    @bc1cindy
    ContributorAuthor

    sighash serialization (taproot 64 vs 65-byte) across the integrations:

    only exists for taproot key-path (Schnorr) spends: SIGHASH_DEFAULT (0x00) omits the sighash byte → 64-byte sig (canonical); an explicit SIGHASH_ALL (0x01) appends it → 65-byte (Example 2's "bug"). for ECDSA (P2WPKH) the byte is always present, so there's no 64-vs-65 choice. it's a signing-side decision, the lib only predicts/preserves it (new_p2tr_keyspend assumes 64-byte default receive/mod.rs:34-36).

    all converge on 64-byte SIGHASH_DEFAULT (uniform, no leak):

    ECDSA P2WPKH → no taproot key-path, so no 64-vs-65 choice (N/A). proof each is P2WPKH:

    taproot signers, each → 64-byte SIGHASH_DEFAULT:

    so sighash is uniform here (all 64-byte), not a partition signal

  8. bc1cindy commented on Jun 6, 2026

    @bc1cindy
    ContributorAuthor

    low-R grinding (ECDSA 71 vs 72-byte) across the integrations:

    ECDSA only (Schnorr/taproot sigs are fixed 64-byte, no low-R notion). a signer that grinds loops the nonce until r < 2^255 → 71-byte DER sig (low-R); without grinding ~50% are high-R (72-byte). it's probabilistic but works by elimination: a single high-R sig rules out every grinding wallet. chain-proven in Example 1 (8dba6657…, testnet): in_0 = 71-byte (low-R), in_1 = 72-byte (high-R) → two different signers.

    all grind low-R → 71-byte (uniform among the six):

    so all six grind → 71-byte uniform → no divergence between them, but because they all grind, a high-R (72-byte) input is a clean elimination, it can't be from any of the six. so in a payjoin a high-R input partitions out a non-grinding counterparty (exactly Example 1: low-R vs high-R = grinder vs non-grinder). uniform among these, but the axis still cuts when one side is a non-grinding wallet outside the set

  9. bc1cindy commented on Jun 6, 2026

    @bc1cindy
    ContributorAuthor

    transaction version (nVersion 1 vs 2) across the integrations:

    in a payjoin the nVersion is the sender's: a rust-payjoin/PDK sender refuses to finalize any proposal whose version differs from its original PSBT, enforcing proposal.version == original.version send/mod.rs:257 → basic_checks/VersionsDontMatch (a receiver that altered it would just abort the payjoin). so version can never be an intra-tx asymmetry the way the other axes can.

    it's an Independent fingerprint (Ishaana), observable in one tx, and these six all sit on the modern side:

    all stamp nVersion = 2:

    so the axis is uniform among the six, all v2, no divergence between them, but since some wallets still emit v1 (Ishaana), it cuts by elimination. a v1 payjoin, or a v1 parent in the backward channel, rules out all six. Armin's Example 3 shows exactly this: the prior tx 3fbe1713… is v1, the payjoin 8fb80573… v2. uniform here, but it partitions once a v1 wallet is on one side.

  10. bc1cindy commented on Jun 8, 2026

    @bc1cindy
    ContributorAuthor

    spending unconfirmed inputs (temporal) across the integrations:

    each integration's coin-selection either allows or forbids 0-conf UTXOs as contributed inputs. the code below proves the policy; the on-chain leak is the standard temporal heuristic (Ishaana): a same-block parent→child (or unconfirmed-ancestor) spend is observable and partitions by elimination. a wallet that's confirmed-only on this path won't contribute one, so a 0-conf-ancestor input rules out the confirmed-only group (same logic as low-R).

    confirmed-only (0-conf excluded):

    includes unconfirmed (0-conf selectable):

    so the axis diverges 3/3: payjoin-cli / Liana / Boltz are confirmed-only; ldk-node / BBM / Cake can contribute 0-conf inputs. now only BBM includes unconfirmed; the other five (payjoin-cli, Liana, Boltz, ldk-node lightningdevkit/ldk-node#746 (lightningdevkit/ldk-node#746), Cake cake-tech/cake_wallet#3389 (cake-tech/cake_wallet#3389)) are confirmed-only.

    the proof strength also differs: Liana forbids it explicitly, payjoin-cli via a pinned-dependency default, Boltz only by inheriting Core's minconf=1 with no guard of its own. ldk-node and Cake now filter explicitly (chain_position.is_confirmed() / confirmedOnly: true).

  11. bc1cindy commented on Jun 8, 2026

    @bc1cindy
    ContributorAuthor

    inputs of multiple types (does a wallet co-spend mixed input script types?) across the integrations:

    this modulates the standard "mixed input types ⇒ multi-party (payjoin/coinjoin)" heuristic. it's a clean multi-party tell only for structurally single-type wallets; a wallet that co-spends mixed types itself produces mixed-input single-party txs, so the heuristic doesn't hold for it.

    single-type: never self-mix (mixed inputs ⇒ multi-party is clean):

    multi-type - can self-mix (mixed inputs is NOT a clean payjoin tell for them):

    • Cake Wallet - coin selection's only type filter is MWEB (UnspentCoinType), never bitcoin script type (electrum_wallet.dart:888-902); the selection loop then accumulates inputs by amount with no script-type segregation, the only type-conditional is a silent-payment guard that aborts on p2wsh inputs (:908-984) → co-spends P2WPKH + P2TR + P2SH + legacy in one tx
    • payjoin-cli - funds its original PSBT via Core's walletcreatefundedpsbt (wallet.rs:create_psbt), which has no input-type restriction; Core's coin selection prefers a single OutputType but falls back to mixing types when no single type funds the target. AttemptSelection selects over all_groups when allow_mixed_output_types && TypesCount() > 1 (Core spend.cpp:702-723), that flag defaulting true for every filter but the strictest (spend.h:114 +spend.cpp:898-901) → can co-spend mixed input types
    • Boltz (swap server) - its wallet funding/sweeps delegate coin-selection to the node: CoreWalletProvider.sendToAddress/sweepWallet (CoreWalletProvider.ts:53-75) call ChainClient's sendtoaddress (ChainClient.ts:316-324), with no custom selector (no fundrawtransaction/coinselect in lib/); sendtoaddress runs Core's coin selection, which mixes types as a fallback. the same AttemptSelection as payjoin-cli (spend.cpp:702-723) → can co-spend mixed input types. (its boltzr payjoin contribution is a single input, so it adds no mixing within a payjoin; swap claim/refund txs spend specific swap UTXOs.)

    so "mixed inputs ⇒ payjoin" is reliable only when a single-type wallet (ldk-node / BBM / Liana) is involved. they can't self-mix, so a mixed-input tx touching them is genuinely multi-party. Cake (free mixing) and the Core-backed payjoin-cli / Boltz (mixing as a fallback) can each emit mixed-input single-party txs, providing cover that muddies the tell. in a payjoin the receiver contributes one input, so a cross-type payjoin (e.g. P2WPKH sender + Liana wsh receiver) does create mixed inputs, but against the backdrop of Core/Cake users' own mixed-input txs, that alone isn't a clean signal.

  12. bc1cindy commented on Jun 8, 2026

    @bc1cindy
    ContributorAuthor

    feerate source (probabilistic) across the integrations, where each wallet gets its sat/vB:

    user-input (no recommendation source to fingerprint):

    mempool.space-style /fees/recommended API:

    • Bull Bitcoin Mobile - mempool.space-style API: path /api/v1/fees/recommended, fields fastestFee/economyFee/minimumFee (fees_datasource.dart:30-54); default mainnet host mempool.bullbitcoin.com (fallback when no custom server; testnet → mempool.space) (constants.dart:85)
    • Cake Wallet - mempool.cakewallet.com/api/v1/fees/recommended (electrum_wallet.dart:760); fallback electrumClient.feeRates (:780) → Electrum blockchain.estimatefee (electrum.dart:545-546)
    • Boltz - mempool.space /v1/fees/recommended fastestFee × 1.1 (MempoolSpace.ts:54-72; factor = 1.1 :19) as primary, Core estimatesmartfee fallback, floor 2 (ChainClient.ts:368-390 + :97) for its own txs; the payjoin uses the sender's PSBT feerate

    backend-dependent:

    in a payjoin the feerate is the sender's (the payjoin inherits the sender's PSBT feerate), so it's a weak, sender-side Temporal signal, not an intra-tx tell.

  13. arminsabouri commented on Jun 9, 2026

    @arminsabouri
    Contributor

    Liana - no estimator - create_spend receives feerate_vb as a parameter (commands/mod.rs:782-788), and the GUI fills it from a user-typed field (step.rs:419)

    I want to double check that one. Liana uses bitcoind, perhaps they are calling estimatesmartfee

  14. bc1cindy commented on Jun 9, 2026

    @bc1cindy
    ContributorAuthor

    I want to double check that one. Liana uses bitcoind, perhaps they are calling estimatesmartfee

    checked again @arminsabouri

    Liana uses bitcoind for blocks/coins/broadcast/mempool, but it does not call estimatesmartfee for the spend feerate (a repo-wide grep for estimatesmartfee/estimate_fee/estimatefee returns zero hits across lianad and liana-gui)

    the daemon's create_spend takes feerate_vb as a caller-supplied parameter (commands/mod.rs#L782-L788); the GUI feerate field starts empty (form::Value::default(), step.rs#L203) and is filled only by user edits (FeerateEdited → self.feerate.value = s, validated value != 0 && value <= MAX_FEERATE, step.rs#L660-L668)

    the module doc-comment in lianad/src/bitcoin/mod.rs says "gather fee estimates", but the only fee data actually pulled from the backend is MempoolEntryFees — the base/ancestor/descendant fees of existing mempool txs (d/mod.rs#L1192-L1196). It's consumed to build AncestorInfo (commands/mod.rs#L839-L840) → fee_for_ancestors, "Fee added to pay for ancestors at the target feerate" (spend.rs#L213-L214) , paying for unconfirmed in-mempool ancestors when spending 0-conf inputs, relative to the user's target feerate. Not feerate estimation. So the spend feerate is genuinely user-input, with no estimator

  15. bc1cindy commented on Jun 9, 2026

    @bc1cindy
    ContributorAuthor

    change type (Dependent) across the integrations, what script type each wallet assigns to its change output, and whether it can diverge from the input/payment type:

    change type == input/receive type (single descriptor, structurally cannot diverge):

    change type matches the payment/destination type (Bitcoin Core policy):

    change type FIXED to P2WPKH regardless of input/payment/wallet type:

    Dependent fingerprint (needs the change output identified first). 5 of 6 follow a conventional change-type policy that doesn't single out the wallet. change matches the input type (ldk-node, BBM, Liana, single descriptor) or the payment type (payjoin-cli, Boltz via Core; matching the payment also hides the change).

    only Cake hard-codes P2WPKH change regardless of input/payment type, so any non-P2WPKH Cake tx exposes its change by type mismatch and points to Cake; it propagates into payjoins via the backward channel

  16. 14 remaining items

  17. bc1cindy commented on Jun 24, 2026

    @bc1cindy
    ContributorAuthor

    Low-S, across signing libs.

    all produce canonical low-S (BIP146):

    high-S is consensus-valid but non-standard (BIP146/policy) → not relayed. Uniform/inert.

  18. bc1cindy commented on Jun 24, 2026

    @bc1cindy
    ContributorAuthor

    SIGHASH optionality across integrations:

    default (uniform) - ECDSA SIGHASH_ALL, taproot SIGHASH_DEFAULT: Core
    sign.cpp#L55 · Electrum
    transaction.py#L1069 · Liana
    signer.rs#L256 · Boltz
    Claim.ts#L16,#L148 · Cake transaction_builder.dart#L516-L518 · ldk-node/BBM via BDK allow_all_sighashes: false (signer.rs#L822; BBM datasource#L296)

    Non-ALL (SINGLE/NONE/ANYONECANPAY), only Bitcoin Core exposes it: sighashtype on

    signrawtransactionwithwallet/walletprocesspsbt (spend.cpp#L865-L872,#L1583-L1590). Electrum honors it only from an incoming PSBT (transaction.py#L1825); NONE rejected / SINGLE/ANYONECANPAY warned (wallet.py#L3371-L3374). ldk-node/BBM reject via BDK NonStandardSighash (signer.rs#L156-L161); Liana/Boltz/Cake hardcode ALL/DEFAULT.

    → default uniform; the only tell is a non-ALL sighash → Bitcoin Core power-user (by elimination). None of the payjoin wallets emit it.

  19. added this to the payjoin-cli-1.1 milestone on Jun 29, 2026
  20. pythcoiner commented on Jul 8, 2026

    @pythcoiner
    • Liana - reimplements Core's anti-fee-sniping: create_spend computes locktime = anti_fee_sniping_locktime() commands/mod.rs:302-313 and passes it into the tx commands/mod.rs:746. The function spend.rs:485-517 returns the current height, but ~10% of the time (clock precision permitting) subtracts 1-100 blocks; it returns 0 if the tip is >8h old spend.rs:45 or its timestamp is unknown. So ~10% of Liana prior txs sit below the tip.

    @bc1cindy I think this worth opening an issue on Liana repo

  21. pythcoiner commented on Jul 8, 2026

    @pythcoiner

    Liana - no estimator - create_spend receives feerate_vb as a parameter (commands/mod.rs:782-788), and the GUI fills it from a user-typed field (step.rs:419)

    I want to double check that one. Liana uses bitcoind, perhaps they are calling estimatesmartfee

    we dont, only manual input for now, fee estimation will likely land soon on liana-business

  22. pythcoiner commented on Jul 8, 2026

    @pythcoiner

    Liana - descriptor is only ever wsh() or tr() miniscript descriptors/mod.rs:792-819 carrying an older() timelock branch (test: wsh(or_d(pk,and_v(v:pkh,older(52560)))) :862) a witness no P2WPKH wallet produces

    Note: under taproot, if the user created his wallet following the Simple Inheritance template, normal spends will be simple taproot key spend, only a recovery tx will spend by a taptree path

    (in fact this is true for all wallets where the spending path is a single sig under taproot)

  23. pythcoiner commented on Jul 8, 2026

    @pythcoiner

    only Liana's std::collections::HashMap is unordered, and incidentally (per-process RandomState), not by design. Determinism here is a tradeoff, not just a bug: a stable per-wallet selection is what BIP 78/79 recommend against UTXO probing (inherited by BIP 77), re-probing a consistent receiver leaks one UTXO, a randomizing one leaks a fresh UTXO per probe (its cluster). The only cost is a predictable contributed input, a weak fingerprint.

    this also worth an issue imo

  24. bc1cindy commented on Jul 8, 2026

    @bc1cindy
    ContributorAuthor
    • Liana - reimplements Core's anti-fee-sniping: create_spend computes locktime = anti_fee_sniping_locktime() commands/mod.rs:302-313 and passes it into the tx commands/mod.rs:746. The function spend.rs:485-517 returns the current height, but ~10% of the time (clock precision permitting) subtracts 1-100 blocks; it returns 0 if the tip is >8h old spend.rs:45 or its timestamp is unknown. So ~10% of Liana prior txs sit below the tip.

    @bc1cindy I think this worth opening an issue on Liana repo

    this is bitcoin core/electrum behavior and liana already matches it

  25. bc1cindy commented on Jul 8, 2026

    @bc1cindy
    ContributorAuthor

    only Liana's std::collections::HashMap is unordered, and incidentally (per-process RandomState), not by design. Determinism here is a tradeoff, not just a bug: a stable per-wallet selection is what BIP 78/79 recommend against UTXO probing (inherited by BIP 77), re-probing a consistent receiver leaks one UTXO, a randomizing one leaks a fresh UTXO per probe (its cluster). The only cost is a predictable contributed input, a weak fingerprint.

    this also worth an issue imo

    thanks! since this is part of wizardsardine/liana#2011, I left a review

  26. pythcoiner commented on Jul 9, 2026

    @pythcoiner

    this is bitcoin core/electrum behavior and liana already matches it

    but it seems a majority of others implem uses exact tip, so a Lian PJ could be fingerprinted?
    btw we already talked about potentially dropping anti fee snipping, as if an user want to broadcast later it leaks the time the tx has been crafted, but my guess is a broadcast later dont make any sense in PJ context...

  27. bc1cindy commented on Jul 28, 2026

    @bc1cindy
    ContributorAuthor

    BTCPay Server across the construction axes

    BTCPay (the most widely-deployed BIP 78 receiver; BIP 77 is the demo-stage plugin ValeraFinebits/btcpayserver-payjoin-plugin) builds no transactions itself, it delegates coin selection + PSBT to NBXplorer and primitives + signing to NBitcoin. Pinned at btcpayserver@8b71fb9, NBXplorer@f3da40c, NBitcoin@2d08d2a, plugin @383e20e (rust-payjoin @f4a6008)

    unlike the wallets above, several axes are not fixed. NBXplorer runs an explicit anti-fingerprinting layer: a 5-block sliding window of the joint fingerprint distribution over every tx in each indexed block (tally), then at build time it conditions on what it knows (script type, non-mixed inputs/sequence, RBF, which BTCPay fixes on) and samples fee-sniping, timelock-zero, version and low-R to fill any field the caller left unset (FingerprintDistribution.cs#L127-L147) (MainController.PSBT.cs#L44-L100). Active on normal sends; off only on the RBF fee-bump path (DisableFingerprintRandomization = true)

    sampled (blend to the on-chain crowd):

    • nVersion 1/2 - left unset → sampled (:44-100) → stamped onto the tx (:367-369).
    • anti-fee-sniping - Core-replica incl. the ~10% backdate (up to 99 blocks, like Core's GetRand(100)), run per sampled flag, else 0 (:138-157).
    • Low-R grinding - NBitcoin default-grinds (EnforceLowR=true), but BTCPay overrides it per-tx from the sampled ShouldEnforceLowR → carried to signing → applied. When the sampled flag is false, grinding is off, so it permits high-R (72-byte) in on-chain proportion, unlike the six here that all grind uniformly (there a high-R eliminates them; BTCPay leaves high-R on the table on purpose).

    shuffled:

    fixed tells:

    uniform (no partition):

    user-set: coin control opt-in (→IncludeOnlyOutpoints); manual feerate + recommendations 10m/1h/6h/24h (~1h pre-filled); unbounded outputs; can send to base58 / bech32 + bech32m. No OP_RETURN in the send path (zero matches; address+amount only).

    BTCPay is the only integration here that actively randomizes version/timelock/low-R toward the on-chain distribution; like the Group A shufflers {payjoin-cli, ldk-node, BBM} it also shuffles input/output order. Its stable on-chain tells are structural, always-RBF 0xFFFFFFFD, default-P2WPKH homogeneous vin, change-matches-wallet, none of which separate it from that cluster. The one distinctive signal, NBitcoin's knapsack 0.01 BTC min-change, blunts prediction rather than aiding it.

  28. bc1cindy commented on Jul 28, 2026

    @bc1cindy
    ContributorAuthor

    Coin selection prediction across the integrations

    the prediction heuristic only works if the selector is reproducible. So the question per wallet is: given {UTXO set, feerate, outputs}, is its coin selection deterministic (predictable) or randomized?

    deterministic → predictable:

    • Cake (cake_wallet@cb194fb) - walks unspentCoins in a fixed, RNG-free order and accumulates until covered, no BnB, no value sort. updateAllUnspents builds it address by address in walletAddresses.allAddresses order, each address's coins ≈ by age (the server's listunspent height order): the batch path preserves that server order deterministically, the regular path adds each coin on fetch-completion (≈ age with a warm cache). The accumulate-until-covered loop consumes them in that order (lone .sort() = MWEB-vs-not only), and bitcoin_base takes the set as given. So the selected set is a deterministic, address-order-then-age function of {UTXO set, feerate, amount}, fully predictable, and effectively always changeful; the only non-determinism is accidental network-jitter completion order on cold, non-batch fetches (not a shuffle, gone once cached/batched). (fix in progress: cake_wallet#3408 replaces it with BranchAndBound, changeless + shuffled fallback.)
    • Liana (liana@d2baa62) - deterministic in both regimes, no RNG → predictable. It runs bdk_coin_select's BnB under a LowestFee-based metric (spend.rs:391-429, metric spend.rs:220-244); bdk_coin_select's run_bnb is a deterministic best-first search over a BinaryHeap (pop driven), no rng/shuffle. On BnB failure Liana falls back to sort_candidates_by_descending_value_pwu + select_next, also deterministic (unlike bdk_wallet, whose BnB fallback is the randomized SingleRandomDraw). So given {UTXO set, feerate, outputs} the selected set is exactly reproducible.
    • Boltz receiver (@3b460b9) - smallest-UTXO-that-fits, deterministic → predictable. select_contribution_input lists the wallet UTXOs, sorts ascending by amount (sort_by_key) and returns the first that contribute_inputs accepts (first-fit), not the payjoin crate's privacy-preserving selector. So an adversary who enumerates Boltz's UTXO set can predict the contributed input.
    • Electrum (electrum@1de4403) - CoinChooserPrivacy (the only registered chooser, config default 'Privacy': coinchooser.py:526-548, simple_config.py:668); randomized but the PRNG is seeded from the UTXO set, not the outputs (coinchooser.py:308-310), and is a deterministic SHA-256 stream (PRNG coinchooser.py:39-55) → enumerating the coins gives the whole seed.

    Randomized:

    the deterministic selectors (Cake, Liana, Boltz-receiver, Electrum, and BDK in the changeless regime) are directly exposed: an adversary who enumerates the cluster's UTXOs, plus the tx's outputs and feerate, can replay the selection and test whether a "better" coin was wrongly joined. The randomized ones (Core/payjoin-cli, NBitcoin, BDK's SRD fallback, Boltz ordinary sends) make the selected set a random variable, so a "better" unselected coin is consistent with normal behavior; there, only the parameters (min-change, cost-of-change, long-term feerate) fingerprint the selector, not the selection.

  29. DanGould commented on Aug 7, 2026

    @DanGould
    Member

    One way to improve this catalogue would be to associate date ranges or (better yet) software versions that produce fingerprints. Because even if they've been changed, those fingerprints will always be produced by software of a given version.

  30. bc1cindy commented on Aug 9, 2026

    @bc1cindy
    ContributorAuthor

    thank you @DanGould

    since there are so many privacy leaks and wallet devs don't always agree a given fingerprint matters (e.g. SatoshiPortal/bullbitcoin-mobile#2430), I agreed with @arminsabouri that a more general approach would be better: let wallet devs see the chain reality and decide what to do with their own fingerprints

    so i've been working on https://fungi-protocol.github.io/lumen-fingerprints and your suggestion is implemented there: each wallet entry carries software + from_version/until_version, wallets with changed behavior ship multiple version-ranged eras, and a closed era keeps the old fingerprint matchable forever, changed or not, fingerprints stay attributable to the versions that produced them

    Image Image

    uniforming wallet fingerprints is necessary for the payjoin privacy model to work, but even with perfectly uniform fingerprints the payjoin privacy model will remain fragile

    so with this, i'm closing this tracking issue, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions