Split Payments - #128
Split Payments#128notTanveer wants to merge 6 commits into
Conversation
93a3b33 to
4913e75
Compare
| private async createSplitRegularToSPTransaction( | ||
| regularUtxos: CreateTransactionUtxo[], | ||
| targets: CreateTransactionTarget[], | ||
| feeRate: number, | ||
| changeAddress: string, | ||
| sequence: number, | ||
| skipSigning: boolean, | ||
| masterFingerprint: number, | ||
| precalculatedPaymentAmounts?: number[], | ||
| ): Promise<CreateTransactionResult | null> { | ||
| const spAddress = targets[0].address!; | ||
| let { inputs, outputs: rawOutputs } = this.coinselect(regularUtxos, targets, feeRate); | ||
|
|
||
| const obf = this.obfuscateUnnecessaryInputHeuristic(inputs, rawOutputs, regularUtxos, feeRate); | ||
| inputs = obf.inputs; | ||
| rawOutputs = obf.rawOutputs; | ||
|
|
||
| const changeValue = rawOutputs.find(o => !o.address)?.value ?? 0; | ||
| if (changeValue <= 0) return null; | ||
|
|
||
| const planned = await this.planSplitTransaction( | ||
| spAddress, | ||
| targets[0].value!, | ||
| changeValue, | ||
| feeRate, | ||
| rawOutputs.length, | ||
| precalculatedPaymentAmounts, | ||
| ); | ||
| const paymentCount = planned.outputs.filter(o => o.address === spAddress).length; | ||
| if (paymentCount < 2) return null; // planner declined to split; let the caller fall back | ||
|
|
||
| const inputWifs = inputs.map(input => ({ | ||
| txid: input.txid, | ||
| vout: input.vout, | ||
| wif: this._getWifForAddress(String(input.address)), | ||
| })); | ||
| const outputs = this.resolveSPOutputs(inputWifs, planned.outputs); | ||
|
|
||
| let masterFingerprintBuffer: Buffer; | ||
| if (masterFingerprint) { | ||
| let masterFingerprintHex = Number(masterFingerprint).toString(16); | ||
| if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; | ||
| masterFingerprintBuffer = Buffer.from(Buffer.from(masterFingerprintHex, 'hex')).reverse(); | ||
| } else { | ||
| masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); | ||
| } | ||
|
|
||
| let psbt = new bitcoin.Psbt(); | ||
| inputs.forEach(input => { | ||
| psbt = this._addPsbtInput(psbt, input, sequence, masterFingerprintBuffer); | ||
| }); | ||
| outputs.forEach(output => { | ||
| psbt.addOutput({ | ||
| address: output.address || changeAddress, | ||
| value: BigInt(output.value), | ||
| }); | ||
| }); | ||
|
|
||
| let tx: bitcoin.Transaction | undefined; | ||
| if (!skipSigning) { | ||
| inputs.forEach((input, idx) => { | ||
| const keyPair = ECPair.fromWIF(this._getWifForAddress(String(input.address))); | ||
| const tapInternalKey = psbt.data.inputs[idx].tapInternalKey as Uint8Array; | ||
| psbt.signTaprootInput(idx, keyPair.tweak(bitcoin.crypto.taggedHash('TapTweak', tapInternalKey))); | ||
| }); | ||
| psbt.finalizeAllInputs(); | ||
| tx = psbt.extractTransaction(); | ||
| } | ||
|
|
||
| const totalIn = inputs.reduce((sum, i) => sum + i.value, 0); | ||
| const totalOut = outputs.reduce((sum, o) => sum + o.value, 0); | ||
|
|
||
| return { | ||
| tx, | ||
| psbt, | ||
| inputs: inputs.map(i => ({ | ||
| txid: i.txid, | ||
| vout: i.vout, | ||
| address: i.address, | ||
| value: i.value, | ||
| })), | ||
| outputs: outputs.map(o => ({ | ||
| address: o.address || changeAddress, | ||
| value: o.value, | ||
| })), | ||
| fee: totalIn - totalOut, | ||
| changeAddresses: planned.changeAddresses, | ||
| }; | ||
| } |
There was a problem hiding this comment.
This looks like it duplicates most of the PSBT build/sign logic from AbstractHDElectrumWallet.createTransaction
was there a reason it couldn't reuse it? or can we extract it?
I noticed the copy also skips tapBip32Derivation/tapInternalKey metadata the parent adds to change outputs, wonder why is that?
There was a problem hiding this comment.
can't really do that.. the split pipeline requires atomic interleaving of SP derivation with multi-output planning that the parent doesn't support.
tapBip32Derivation is used for hardware wallet export. which we don't currently support
fc5a315 to
371f93d
Compare
371f93d to
ceefb03
Compare
|
ack ceefb03 |
2239f41 to
ae2c2d2
Compare
eadf7b9 to
ede2049
Compare
ede2049 to
457bb56
Compare
- planChangeOutputs: require a modulus of slack above m * floor. With zero budget every part lands exactly on the floor and deRound has no headroom to move, so the whole set ships round; fall back to fewer, roomier parts. - deRound: drop the first single-shot pass, the fixed-point loop subsumes it. - Confirm: display-sort split outputs by value like SendDetails does, so the index ordinals don't contradict each other across the two screens. On-chain order stays shuffled. - SendDetails: surface the builder's decline instead of silently sending a single output after showing an enabled split card.
Splitting the change into several outputs is now a separate opt-out toggle inside the split payment card, since it costs the sender extra UTXOs to spend later. The payment split is unaffected when it's declined. `SplitOptions.splitChange` threads to planChangeOutputs, which caps the plan at the single change output the plain builder would have produced. The SendDetails dry run passes the same flag, so the previewed change count and fee delta match what gets signed.
| psbt.addOutput({ | ||
| address: output.address, | ||
| address, | ||
| value: BigInt(output.value), | ||
| }); |
There was a problem hiding this comment.
Why doesn't this psbt.addOutput call attach tapBip32Derivation/tapInternalKey to change outputs, the way _buildAndSignPsbt does at abstract-hd-electrum-wallet.ts for the other split builder? With split-change on, this path can now produce up to 4 undecorated change outputs instead of 1. Is there a reason SP-UTXO-funded sends don't need this metadata, or
should this route through _buildAndSignPsbt too?
There was a problem hiding this comment.
This is by design — SP-UTXO inputs are signed with one-time BIP-352 tweaked keys that don't sit on any BIP-32 derivation path, so tapBip32Derivation doesn't meaningfully apply here. External PSBT viewers can't derive these keys from an xpub regardless.
The createSplitRegularToSPTransaction path delegates to _buildAndSignPsbt because those inputs are standard BIP-86 taproot keys with real derivation paths — different signing model entirely.
(also mentioned earlier)
Summary
This PR adds Split Payments for Silent Payment wallets to level up privacy.
Instead of sending everything in a single output, the wallet splits the payment into two outputs. That makes amount based heuristics way less effective and makes it much harder for chain surveillance to correlate the sender and recipient based on the payment amount.
The end result is better on-chain privacy without changing the user experience.
check out the test txn here.
Screenshots