Repository navigation
Tracking: chain-observable transaction-level fingerprinting #1597
Description
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):
payjoin-clivia Bitcoin Core'swalletcreatefundedpsbt(RBF default since v0.16)ldk-nodebdk_wallet 2.3.0Lianaspend.rs:773Boltzboltz-core/src/bitcoin/tx.rs:179Bull Bitcoin Mobilebdk_wallet_datasource.dart:202-204(bdk_dart default0xFFFFFFFD; set to0xFFFFFFFEonly whenreplaceByFee == false, which defaultstruesend_state.dart:82)
group C - mixed [0x01, 0xFFFFFFFF, …] (bug):
Cake Wallettransaction_builder.dart:402-406appliesreplaceByFeeSequenceonly toinputs[0]; remaining inputs stay atdefaultTxSequence. Combined withelectrum_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 corrected, dissolves into group A. the old 0xFFFFFFFF (Sequence::MAX)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
arminsabouri commentedon Jun 2, 2026
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
and yes.
bc1cindy commentedon Jun 4, 2026
nLockTimeacross the integrations: #1676Randomized backdating (≠ exact tip):
by default, Bitcoin Core's anti-fee-sniping sets
nLockTimeto 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_spendcomputeslocktime = anti_fee_sniping_locktime()commands/mod.rs:302-313and passes it into the txcommands/mod.rs:746. The functionspend.rs:485-517returns the current height, but ~10% of the time (clock precision permitting) subtracts 1-100 blocks; it returns0if the tip is >8h oldspend.rs:45or its timestamp is unknown. So ~10% of Liana prior txs sit below the tip.Exact-tip (locktime = current block height, no randomization):
payjoin-cliopts out of this: it sets an explicitlocktime = get_block_count()wallet.rs:54-59, passed towalletcreatefundedpsbt. Core writes it intorawTx.nLockTimeviaConstructTransactionrawtransaction_util.cpp:151-155, thenFundTransactioncopies it intocoin_control.m_locktimespend.cpp:1505-1512, and a setm_locktimedisables anti-fee-snipingspend.cpp:1324-1327so ~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 viabuild_txwithout calling.nlocktime()in any branch, so bdk's no-locktime arm applies:fee_sniping_height = current_height, no subtractionmod.rs:1433-1452. Its channel-funding path sets an explicit locktime, but also to the exact current heightevent.rs:628-629, so every ldk-node tx is exact-tip.Bull Bitcoin- wraps bdk_wallet 2.3.0 via bdk-flutter. Its builderbdk_wallet_datasource.dart:168-244sets 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
Boltz: swap timeout height. Refund requires itRefund.ts:12-37; claim optionalClaim.ts:69-79.No anti-fee-sniping
Cake: always0. Cake builds viaBitcoinTransactionBuilderelectrum_wallet.dart:1354, which has no locktime parameter and constructsBtcTransactionwithout alocktransaction_builder.dart:561;BtcTransactionthen defaultslocktime = lock ?? DEFAULT_TX_LOCKTIMEtransaction.dart:33=[0,0,0,0]constant.dart:361. No anti-fee-sniping anywhere.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-cliexplicitly proposes as a default.bc1cindy commentedon Jun 4, 2026
following the
nLockTimeanalysis, the same cluster{payjoin-cli, ldk-node, BBM}converges on the privacy-preserving default on a third construction axis, change position, while Liana divergesOutput ordering (change position) across the integrations
Randomized (change index shuffled, hides which output is change):
payjoin-cli(Core) itswalletcreatefundedpsbtoptions set no change positionwallet.rs:47-52, so Core hits the!change_posbranch and inserts change at a random positionspend.cpp:1264-1270.ldk-nodeandBull Bitcoininherit bdk_wallet 2.3.0, whoseTxOrderingdefaults toShuffletx_builder.rs:846-850, which shuffles the output vectortx_builder.rs:893. Neither sets an ordering:send_to_addresswallet/mod.rs:710-769calls no.ordering(), and BBM's builderbdk_wallet_datasource.dart:168-244sets none.Cakenow shuffles the output vector (change no longer last):outputOrdering: BitcoinOrdering.shuffleelectrum_wallet.dart:1523-1526. Fixed (merged): 3420 (software+RBF) + 3432 (HW/BCH); tracked in 3376.Deterministic position (insertion order, not shuffled - change identifiable by position):
Lianaappends change last, after the recipients, with an explicit// TODO: shuffle once we have Taprootspend.rs:744-753.Cakeappends the change output lastelectrum_wallet.dart:990; the code confirms it.// Here, lastOutput already is changeelectrum_wallet.dart:1118-1119. It builds withoutputOrdering: BitcoinOrdering.noneelectrum_wallet.dart:1360, andbitcoin_baseapplies no sort fornone, appending any OP_RETURN memo after the changetransaction_builder.dart:435-452. So change is the last output (before an optional memo), never shuffled.Single-output sweep (no change) -
Boltz: claim/refund txs have one output (the destination), no change to placeClaim.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.
bc1cindy commentedon Jun 4, 2026
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):
payjoin-cli(Core) funds viawalletcreatefundedpsbt, and Core builds the final input vector shuffled —selected_coins = result.GetShuffledInputVector()spend.cpp:1275-1276.ldk-nodeandBull Bitcoininherit bdk_wallet 2.3.0, whoseTxOrderingdefaults toShuffletx_builder.rs:846-850, which shuffles the input vectortx_builder.rs:893-895. Neither sets an ordering: ldk-node'ssend_to_addresswallet/mod.rs:710-769calls no.ordering(), and BBM's builderbdk_wallet_datasource.dart:168-244sets none.Cake- now shuffles Shuffle input order on bitcoin sends cake-tech/cake_wallet#3379BIP-69 lexicographic (sorted by txid, then vout):
Boltzsorts inputs explicitly -// BIP69 lexicographical input ordering/[...utxos].sort(compareInputs)Claim.ts:74-75, wherecompareInputsorders by txid then voutBip69.ts:16-19.moved to ShuffleCakeusesbitcoin_base's defaultinputOrdering: bip69transaction_builder.dart:43the call site passes onlyoutputOrdering, notinputOrderingelectrum_wallet.dart:1354-1362so inputs are sorted by txid then vouttransaction_builder.dart:381-396.Selection order (no sort, no shuffle):
Lianapushes inputs in coin-selection order, iterating the selected coins directly with no sort or shufflespend.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/CakeBoltz 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}.bc1cindy commentedon Jun 6, 2026
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 eliminatesthe lib's best-effort mitigation
try_preserving_privacy(receive/common/mod.rs:207-275) picks, for a 2-output tx, a contributed input keepingmin(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:
payjoin-cli- Corelistunspentdefaults, no integration filterwallet.rs:172-179→ select + one inputv2/mod.rs:896-906ldk-node- alllist_unspent_utxos, no filtermanager.rs:447-464(called at:418) →try_preserving_privacy:430→ one input:435Bull Bitcoin Mobile- all walletgetUtxospayjoin_repository_impl.dart:282, minus payjoin-locked via_filterAvailableUtxos(unspentUtxos, lockedUtxos):416-417(body only drops locked + keeps bitcoin:447-453), passed as candidates:438→tryPreservingPrivacypdk_payjoin_datasource.dart:265→ one input[inputPair]:276-277Boltz- hot-walletlist_unspent, no filterpayjoin/mod.rs:285-289→try_preserving_privacy:291-293→ one input:295-296(draft - "consolidation" multi-input not implemented yet)Liana- confirmed coins built into candidates, no filterreceiver.rs:173-220→try_preserving_privacy:228→ one input:237Cake Wallet- confirmed wallet unspent as candidates (getCandidateInputs; 0-conf excluded, #3389) →tryPreservingPrivacy(fallback to first candidate) → one input viacontributeInputspayjoin_receive_worker.dart:172-187. fixed (merged): #3304, issue #3303.skips it (bug):
Cake Wallet- the receiver creates theWantsInputs(pj5) viacommitOutputsand, in the same contiguous block, picksunspent[0]and contributes it viapj5.contributeInputs-pj5's whole lifecycle (create → contribute) is here, with notryPreservingPrivacycallpayjoin_receive_worker.dart:175-183. the selector isn't missing - it's defined on that sameWantsInputsin thepayjoin_flutterref cake pinsreceive.dart:363; cake just skips itwhy 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-txnSequence(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 throughtryPreservingPrivacy, I'll open an issue and PR therebc1cindy commentedon Jun 6, 2026
input script typeacross the integrations: #1722each wallet's UTXOs carry a script type fixed by the UTXO being spent. the lib's input normalization is field-level.
contribute_inputsoverwrites each contributed input'ssequencereceive/common/mod.rs:285-317but 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):
payjoin-cli- Coregetnewaddress, no type argwallet.rs:163-166→ inherits Core's default address type (bech32/P2WPKH)ldk-node- BDKBip84descriptorsbuilder.rs:1462-1463Bull Bitcoin Mobile- defaultScriptType.bip84create_default_wallets_usecase.dart:33→Descriptor.newBip84descriptor_derivation.dart:20-21distinctive type (partition signal vs the cluster):
Liana- descriptor is only everwsh()ortr()miniscriptdescriptors/mod.rs:792-819carrying anolder()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 typeselectrum_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 defaultanypasses every typeelectrum_wallet.dart:877-902, tagging each input's own type:970→ can mix types in one txBoltz(draft, #1430) - receiver lists its wallet UTXOs, sorts them by amount, and contributes the smallest one that fitspayjoin/mod.rs:518-542; the input type is re-derived from the UTXO'saddress(is_p2wpkh→new_p2wpkh,is_p2tr→new_p2tr_keyspend, else genericnew)payjoin/mod.rs:544-575, never matched to the sender. TheUnspentOutputstruct carries onlytxid/vout/address/amount/asset(noredeem_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.
bc1cindy commentedon Jun 6, 2026
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 explicitSIGHASH_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_keyspendassumes 64-byte defaultreceive/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:
payjoin-cli- Core default P2WPKHwallet.rs:163-166ldk-node- singleBip84/wpkh descriptorbuilder.rs:1462-1463Bull Bitcoin Mobile- defaultScriptType.bip84→newBip84create_default_wallets_usecase.dart:33taproot signers, each → 64-byte
SIGHASH_DEFAULT:Cake Wallet- signs taproot with0x00(TAPROOT_SIGHASH_ALL = 0x00constant.dart:353) and appends a byte only ifsighash != 0x00ec_private.dart:111-112→ 64-byteBoltz-SigHash.DEFAULTthen barewitness = [signature](no byte), script- and key-pathboltz-core Claim.ts:145-155→ 64-byteLiana-TapSighashType::Defaultsigner.rs:296→ serialized to 64-byte (rust-bitcoin appends the byte onlyif type != Defaultcrypto/src/taproot.rs:72-94)so sighash is uniform here (all 64-byte), not a partition signal
bc1cindy commentedon Jun 6, 2026
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):
payjoin-cli- delegates signing to Core'swalletprocesspsbtwallet.rs:95-99(called in finalizev2/mod.rs:926-928); Core'sCKey::Signgrinds for low-R -grinddefaultstruekey.h:149via the loop inkey.cpp:219-222→ 71-byteldk-node/Bull Bitcoin Mobile- both grind via BDK'ssign_ecdsa_low_rwhenallow_grindingis setsigner.rs:586-589(defaulttrue:861). ldk-node usesSignOptions::default()wallet/mod.rs:452; BBM setsallowGrinding: trueexplicitlybdk_wallet_datasource.dart:299Liana-sign_ecdsa_low_ron the wsh/ECDSA pathsigner.rs:273Boltz- passeslowR=truetosignECDSAClaim.ts:201→@scure/btc-signer(pinned^2.2.0) grinds:signECDSA(…, lowR)loops until low-Rutils.ts:162-176Cake Wallet-bitcoin_base'ssignInput→btcSigner.signTransactionec_private.dart:87-91→blockchain_utilsgrind loopwhile (lengthR == 33)re-signs with extra entropy until low-Rbitcoin_signer.dart:121-129so 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
bc1cindy commentedon Jun 6, 2026
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.versionsend/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:
Liana- explicit: the spend builder hardcodesversion: bitcoin::transaction::Version::TWOspend.rs:651; the payjoin receiver only contributes inputs and never references versionreceiver.rsCake Wallet- for bitcoin (the non-BCH branchelectrum_wallet.dart:1516-1536) it builds viaBitcoinTransactionBuilderwith no version arg; that builder constructsBtcTransaction(...)without a versiontransaction_builder.dart:561, which defaultsversion ?? DEFAULT_TX_VERSIONtransaction.dart:34=[0x02,…]constant.dart:369= v2ldk-node- builds via BDK'sbuild_tx()wallet/mod.rs:437and never calls.version()(none of its 6 build sites do); BDK'sversionis set only by that methodtx_builder.rs:559, so it staysNone→None => transaction::Version::TWObdk_wallet@2.3.0 mod.rs:1420= v2Bull Bitcoin Mobile- builds via the bdk-dartTxBuilderbdk_wallet_datasource.dart:184without.version(); the bdk-ffi binding defaultsversiontoNoneand forwards to bdk_wallet only if setbdk-ffi tx_builder.rs:73,528, so the samebdk_wallet 2.3.0default applies = v2payjoin-cli- itsWalletCreateFundedPsbtOptionshas no version fieldwallet.rs:47-52, so it never setswalletcreatefundedpsbt'sversionarg, which defaults toDEFAULT_WALLET_TX_VERSIONCore spend.cpp:1738=CTransaction::CURRENT_VERSIONcoincontrol.h:25={2}transaction.h:284= v2Boltz- acts only as the input-contributing receiver (built onpayjoin::receive'sNewReceiverpayjoin/mod.rs:67); it builds only per-input structures (TxIn+ psbtInput), never aTransactioninput_pair_from_list_unspent:301-324, and the file has zeroversionref, so the finalnVersionis the sender's, preservedso 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 payjoin8fb80573…v2. uniform here, but it partitions once a v1 wallet is on one side.bc1cindy commentedon Jun 8, 2026
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):
payjoin-cli- receiver candidates come fromlist_unspent(None, None, None, None, None)wallet.rs:172-176(the v2 receiver pathv2/mod.rs:896); the pinnedbitcoind-async-client = "0.10.8"resolves the omitted minconf, the first argv30.rs:316-321, to1v30.rs:329Liana- strongest: candidates filtered to confirmed onlyreceiver.rs:173, andCoinStatus::Confirmedis SQLblocktime IS NOT NULLsqlite/mod.rs:406-407Boltz- confirmed-only only by accident: candidates fromlist_unspent()payjoin/mod.rs:281-292→request("listunspent", None)chain_client.rs:209-210, andNoneparams resolve to an empty arg listrpc_client.rs:53,62→ no minconf sent; no in-code guard, relies entirely on Core's minconf=1 default (oneminconf=0from leaking)ldk-node- now confirmed-only per review: payjoin candidates come fromlist_unspent_confirmed_utxos()manager.rs:456, which keeps only UTXOs whose txchain_position.is_confirmed()wallet/mod.rs:1025-1034. (waslist_unspent_utxos()with no filter; lightningdevkit/ldk-node746 head 5a9bf22, still open/unmerged)Cake Wallet- now confirmed-only: thegetCandidateInputshandler callsgetUtxoWithPrivateKeys(confirmedOnly: true)manager.dart:292-296, which keeps only(e.confirmations ?? 0) > 0bitcoin_wallet.dart:564-565. fixed (merged): #3389. (was: onlyisSending && !isFrozen, no confirmation check)includes unconfirmed (0-conf selectable):
ldk-node- payjoin candidates fromlist_unspent_utxos()manager.rs:447-448, which iterates BDK'slist_unspent()and pushes each segwit UTXO with no confirmation filterwallet/mod.rs:1449-1519; inconsistent with the project's own coin-selection, which excludes unconfirmed elsewhere (exclude_unconfirmed()wallet/mod.rs:927)Bull Bitcoin Mobile- candidates come fromgetUtxosbdk_wallet_datasource.dart:311-334, which maps BDK'slistUnspent()raw with no confirmation filter (theWalletUtxoModelcarries no confirmation field); fed to the payjoin viapayjoin_repository_impl.dart:282, whose only filters remove locked UTXOs and narrow type, never confirmation:447-456Cake Wallet-getCandidateInputsreturnsgetUtxoWithPrivateKeys()manager.dart:299-308, whose only filter isisSending && !isFrozenbitcoin_wallet.dart:553-556— no confirmation check; Cake tracksconfirmations == 0only to flip RBF (spendsUnconfirmedTXelectrum_wallet.dart:910→enableRBF: !spendsUnconfirmedTX:1525), never to excludeso 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).
bc1cindy commentedon Jun 8, 2026
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):
ldk-node- BDK wallet built fromBip84(P2WPKH) descriptors for both keychains (builder.rs:1462-1463) → P2WPKH only (also asserted in the wallet,wallet/mod.rs:680-687)Bull Bitcoin Mobile- oneScriptType(bip84/49/44, no taproot) per wallet (wallet.dart:69-72), applied to both keychains (bdk_facade.dart:101-130)Liana- singlemulti_descfield (descriptors/mod.rs:92-93), exhaustivelywshortr, any other descriptor panics (unreachable!descriptors/mod.rs:792-802) → one type per walletmulti-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 txpayjoin-cli- funds its original PSBT via Core'swalletcreatefundedpsbt(wallet.rs:create_psbt), which has no input-type restriction; Core's coin selection prefers a singleOutputTypebut falls back to mixing types when no single type funds the target.AttemptSelectionselects overall_groupswhenallow_mixed_output_types && TypesCount() > 1(Core spend.cpp:702-723), that flag defaultingtruefor every filter but the strictest (spend.h:114+spend.cpp:898-901) → can co-spend mixed input typesBoltz(swap server) - its wallet funding/sweeps delegate coin-selection to the node:CoreWalletProvider.sendToAddress/sweepWallet(CoreWalletProvider.ts:53-75) callChainClient'ssendtoaddress(ChainClient.ts:316-324), with no custom selector (nofundrawtransaction/coinselectinlib/);sendtoaddressruns Core's coin selection, which mixes types as a fallback. the sameAttemptSelectionas payjoin-cli (spend.cpp:702-723) → can co-spend mixed input types. (itsboltzrpayjoin 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
wshreceiver) does create mixed inputs, but against the backdrop of Core/Cake users' own mixed-input txs, that alone isn't a clean signal.bc1cindy commentedon Jun 8, 2026
feerate source(probabilistic) across the integrations, where each wallet gets its sat/vB:user-input (no recommendation source to fingerprint):
payjoin-cli- required--fee-rateCLI flag (cli/mod.rs:95-97)Liana- no estimator -create_spendreceivesfeerate_vbas a parameter (commands/mod.rs:782-788), and the GUI fills it from a user-typed field (step.rs:419)mempool.space-style
/fees/recommendedAPI:Bull Bitcoin Mobile- mempool.space-style API: path/api/v1/fees/recommended, fieldsfastestFee/economyFee/minimumFee(fees_datasource.dart:30-54); default mainnet hostmempool.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); fallbackelectrumClient.feeRates(:780) → Electrumblockchain.estimatefee(electrum.dart:545-546)Boltz- mempool.space/v1/fees/recommendedfastestFee × 1.1(MempoolSpace.ts:54-72; factor= 1.1:19) as primary, Coreestimatesmartfeefallback, floor 2 (ChainClient.ts:368-390+:97) for its own txs; the payjoin uses the sender's PSBT feeratebackend-dependent:
ldk-node- dispatched byChainSourceKind(chain/mod.rs:427-438) to Esploraget_fee_estimates//fee-estimates(esplora.rs:289), Electrumestimate_fee/blockchain.estimatefee(electrum.rs:602), or Coreestimatesmartfee(bitcoind.rs:862)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.
arminsabouri commentedon Jun 9, 2026
I want to double check that one. Liana uses bitcoind, perhaps they are calling
estimatesmartfeebc1cindy commentedon Jun 9, 2026
checked again @arminsabouri
Liana uses
bitcoindfor blocks/coins/broadcast/mempool, but it does not callestimatesmartfeefor the spendfeerate(a repo-wide grep forestimatesmartfee/estimate_fee/estimatefeereturns zero hits acrosslianadandliana-gui)the daemon's
create_spendtakesfeerate_vbas 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, validatedvalue != 0 && value <= MAX_FEERATE,step.rs#L660-L668)the module doc-comment in
lianad/src/bitcoin/mod.rssays "gather fee estimates", but the only fee data actually pulled from the backend isMempoolEntryFees— the base/ancestor/descendant fees of existing mempool txs (d/mod.rs#L1192-L1196). It's consumed to buildAncestorInfo(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 estimatorbc1cindy commentedon Jun 9, 2026
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):
ldk-node- change is theinternalkeychain of the sameBip84descriptor as receive → always P2WPKH == input (builder.rs:1462-1463)Bull Bitcoin Mobile- singleScriptTypeper wallet (bip84/bip49/bip44) (wallet.dart:69-72); every branch builds theinternaldescriptor of that same type (bdk_facade.dart:101-135)Liana-change_descis the/1/*split of the same multipath descriptor asreceive_desc(/0/*) (descriptors/mod.rs:126-143); the change address is derived fromchange_descriptor()(commands/mod.rs:314-319) and the changeTxOuttakes its scriptPubKey from it (spend.rs:686-689)change type matches the payment/destination type (Bitcoin Core policy):
payjoin-cli- funds via Corewalletcreatefundedpsbtwith no change-type override (wallet.rs:47-52); Core'sTransactionChangeTypematches the destination type, falling back to the wallet's default address type (wallet.cpp:2267-2325)Boltz- funds via Coresendtoaddress, no change-type override (CoreWalletProvider.ts:53-67→ChainClient.ts:316-339) → same Core match-destination policychange type FIXED to P2WPKH regardless of input/payment/wallet type:
Cake Wallet- the change pool is filtered to P2WPKH only for Bitcoin wallets (electrum_wallet_addresses.dart:672-681, inline comment "For now fixed to p2wpkh, the cheapest type"), even though one Cake wallet generates p2pkh/p2sh/p2wpkh/p2tr/p2wsh addresses (electrum_wallet_addresses.dart:351-365);getChangeAddressdraws only from that filtered pool (electrum_wallet_addresses.dart:384-406) and the changeBitcoinOutputis built from it (electrum_wallet.dart:1156-1166). A taproot or legacy Cake spend still emits P2WPKH change → change type ≠ input ≠ payment.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
14 remaining items
bc1cindy commentedon Jun 24, 2026
Low-S, across signing libs.all produce canonical low-S (BIP146):
Core/rust-bitcoin/electrum-ecc- vialibsecp256k1, which normalizes S internally (scalar_is_high→cond_negate):secp256k1/src/ecdsa_impl.h#L293-L294electrum-ecc defaultkeys.py#L272(
enforce_low_s=True)@scure/btc-signer(Boltz) -@noble/curvesdefaultlowS: true, applied on sign:weierstrass.ts#L1629-L1631(defaultSigOpts lowS:true),#L1857-L1858(if lowS && high → neg(s))bitcoin_signer.dart#L144-L145(lengthS==33 → order - S)high-S is consensus-valid but non-standard (BIP146/policy) → not relayed. Uniform/inert.
bc1cindy commentedon Jun 24, 2026
SIGHASH optionalityacross integrations:default (uniform)- ECDSASIGHASH_ALL, taprootSIGHASH_DEFAULT: Coresign.cpp#L55· Electrumtransaction.py#L1069· Lianasigner.rs#L256· BoltzClaim.ts#L16,#L148· Caketransaction_builder.dart#L516-L518· ldk-node/BBM via BDKallow_all_sighashes: false(signer.rs#L822; BBMdatasource#L296)Non-ALL (SINGLE/NONE/ANYONECANPAY), only Bitcoin Core exposes it:sighashtypeonsignrawtransactionwithwallet/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 BDKNonStandardSighash(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.
pythcoiner commentedon Jul 8, 2026
@bc1cindy I think this worth opening an issue on Liana repo
pythcoiner commentedon Jul 8, 2026
we dont, only manual input for now, fee estimation will likely land soon on liana-business
pythcoiner commentedon Jul 8, 2026
Note: under taproot, if the user created his wallet following the
Simple Inheritancetemplate, 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)
pythcoiner commentedon Jul 8, 2026
this also worth an issue imo
bc1cindy commentedon Jul 8, 2026
this is bitcoin core/electrum behavior and liana already matches it
bc1cindy commentedon Jul 8, 2026
thanks! since this is part of wizardsardine/liana#2011, I left a review
pythcoiner commentedon Jul 9, 2026
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...
bc1cindy commentedon Jul 28, 2026
BTCPay Server across the construction axesBTCPay (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):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).Coin selection prediction- NBitcoin's DefaultCoinSelector: a Core-style stochastic knapsack (not BnB), randomized ("privacy improvement by making the selection random"), group-by-scriptPubKey, targeting target + 0.01 BTC min-change. For coin-selection prediction this cuts the wrong way: being non-deterministic and non-optimal, a "better" unselected coin is consistent with normal behavior, not evidence of a wrong cluster. What fingerprints is the parameterization (knapsack, group-by-spk, 0.01 BTC min-change), not the selection.Payjoin receiver (UIH2)- the plugin routes its whole confirmed-UTXO set through PDK's try_preserving_privacy → PDK: good side, with {BBM, Liana, Boltz}. One input, no consolidation; no script-type matching to the sender (candidates unfiltered by type) the residual gap; nSequence + random insertion index are PDK's doing.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.
bc1cindy commentedon Jul 28, 2026
Coin selection prediction across the integrationsthe 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:
Randomized:
sendtoaddressRPC, so selection runs inside Core (randomized) → not predictable.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.
DanGould commentedon Aug 7, 2026
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.
bc1cindy commentedon Aug 9, 2026
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
seethe chain reality and decide what to do with their own fingerprintsso 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 themuniforming 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!