Tracking: chain-observable transaction-level fingerprinting #1597
Description
Activity
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.
Reacted by CindynLockTimeacross 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.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 Taproot
spend.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.
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#3379
BIP-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}.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 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 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-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 thereinput 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-21
distinctive 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.
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:33
taproot 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
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-129
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
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, preserved
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 payjoin8fb80573…v2. uniform here, but it partitions once a v1 wallet is on one side.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 exclude
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).
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 wallet
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 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.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 feerate
backend-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.
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
estimatesmartfeeI want to double check that one. Liana uses bitcoind, perhaps they are calling
estimatesmartfeechecked 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 estimatorReacted by Armin Sabourichange 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 policy
change 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
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))- blockchain_utils (Cake) -
bitcoin_signer.dart#L144-L145(lengthS==33 → order - S)
high-S is consensus-valid but non-standard (BIP146/policy) → not relayed. Uniform/inert.
SIGHASH optionalityacross integrations:default (uniform)- ECDSASIGHASH_ALL, taprootSIGHASH_DEFAULT: Core
sign.cpp#L55· Electrum
transaction.py#L1069· Liana
signer.rs#L256· Boltz
Claim.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.
Reacted by Dan GouldLiana- 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.
@bc1cindy I think this worth opening an issue on Liana repo
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
estimatesmartfeewe dont, only manual input for now, fee estimation will likely land soon on liana-business
Reacted by CindyLiana - 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 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)
Reacted by Cindyonly 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
Reacted by CindyLiana- 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.
@bc1cindy I think this worth opening an issue on Liana repo
this is bitcoin core/electrum behavior and liana already matches it
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
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...Reacted by CindyBTCPay 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):- 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:- Input & output order - both vectors shuffled (defaults true). Input shuffle is never disabled; output shuffle is gated by CanShuffleOutputs, turned off only in Open-Assets/colored-coin paths (colored change, asset send, issuance), none of which a normal BTC send hits. So: not BIP-69 input order, and since change is a plain output added to the same vector, a random change index (no deterministic first/last).
fixed tells:- opt-in RBF - forced on for BTC, no UI switch (RBF=true · SupportRBF=true · OptInRBF). With OptInRBF and version < 3, NBitcoin sets every input to Sequence.OptInRBF = 0xFFFFFFFD uniform, non-mixed, = the Group A value.
- Input script type - one per wallet: all coins are resolved against a single strategy per PSBT, so no multi-type vin; default P2WPKH (= Segwit · NBX fallback). Supports P2PKH / P2SH-P2WPKH / P2WPKH / P2TR + multisig P2SH/P2WSH (routing); P2PK not supported (miniscript allows only wsh/sh/pkh/tr/wpkh).
- Change - from the Change branch of the same strategy: type matches inputs, fresh key (no reuse), bech32 only if the wallet is segwit (not an invariant).
- Spend unconfirmed - always eligible: MinConfirmations defaults to 0 and BTCPay never sets it, so the UTXO filter admits unconfirmed coins; no toggle.
uniform (no partition):- SigHash ALL (ECDSA) / Default → 64-byte (taproot); compressed pubkeys always (33-byte HD child).
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.
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:
- 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:
- Bitcoin Core / payjoin-cli (bitcoin@7dea464, rust-payjoin@0df711f) - payjoin-cli sets no selection knobs (wallet.rs:47-52); SelectCoins runs BnB+Knapsack+CoinGrinder+SRD (spend.cpp:747-776) and returns the least-waste result (spend.cpp:806-808); SRD shuffles the UTXOs (coinselection.cpp:622-625), Knapsack shuffles the groups (:739) with a randomized change target (:883-891). BnB runs first and is deterministic, but for arbitrary (changeful) amounts Knapsack/SRD produce a fresh random candidate each run and the change target is randomized, so the least-waste winner is a random variable, not a function of the UTXO set + feerate. (Only a dominant changeless BnB solution is deterministic, the same regime as BDK below.)
- BTCPay / NBitcoin (NBitcoin@2d08d2a) - NBitcoin's DefaultCoinSelector is a Core-style stochastic knapsack: it shuffles the groups then runs a 50%-inclusion stochastic subset-sum, group-by-scriptPubKey, targeting target + 0.01 BTC min-change. Randomized (full construction-axis analysis in the BTCPay comment).
- BDK / ldk-node, Bull Bitcoin (bdk_wallet@fc88144) - default BranchAndBoundCoinSelection: BnB is deterministic only in the changeless regime, else falls back to a shuffling SingleRandomDraw. ldk-node (wallet/mod.rs:772-814) and Bull Bitcoin (bdk_wallet_datasource.dart:211-242) use the default unchanged → predictable only in the changeless-BnB regime (as with Core above).
- Boltz ordinary send - no picker in Boltz: CoreWalletProvider.sendToAddress just delegates to chainClient.sendToAddress → Core's
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.
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.
Reacted by pythcoinerthank 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 them
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!
Reacted by Dan Gould and spacebear
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(seereceive/common/mod.rs:280-317andsend/mod.rs:391,1295).complements #1586 (network-level): this covers
nSequencedivergence inpriortransactions 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'snSequence), 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
replaceByFeeSequenceto all inputs, not justinputs[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; onlyCake's group-C bug diverges. fixing group C therefore closes thenSequencedivergence entirely - there's no residual A↔B split (apayjoin-cli↔Boltzpayjoin is already uniform). canonical convergence on0xFFFFFFFD(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 - uniformlycorrected, dissolves into group A. the old0xFFFFFFFF(Sequence::MAX)Boltz/BBMMAX was their payjoin-contributed input, which the lib overwrites with the sender's sequence atreceive/common/mod.rs:285-317it never reaches the chain. their own txs use0xFFFFFFFD