Skip to content

Split Payments - #128

Open
notTanveer wants to merge 6 commits into
CypherCommons:masterfrom
notTanveer:feat/split-payments-v2
Open

notTanveer wants to merge 6 commits into
CypherCommons:masterfrom
notTanveer:feat/split-payments-v2

Conversation

@notTanveer

Copy link
Copy Markdown
Contributor

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

Send Screen Split Payment Preview Transaction Details

@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch from 93a3b33 to 4913e75 Compare July 16, 2026 16:16
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment on lines +987 to +1075
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,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@notTanveer notTanveer Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread screen/send/SendDetails.tsx Outdated
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread helpers/silent-payments/splitPayment.ts Outdated
@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch 5 times, most recently from fc5a315 to 371f93d Compare July 23, 2026 12:14
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread tests/unit/bip352.test.ts
Comment thread helpers/silent-payments/splitPayment.ts Outdated
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread helpers/silent-payments/index.ts Outdated
@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch from 371f93d to ceefb03 Compare July 24, 2026 13:40
@chaitika

Copy link
Copy Markdown
Contributor

ack ceefb03

@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch 2 times, most recently from 2239f41 to ae2c2d2 Compare July 31, 2026 12:26
Comment thread class/wallets/hd-bip352-wallet.ts
Comment thread class/wallets/hd-bip352-wallet.ts
Comment thread screen/send/Confirm.tsx Outdated
Comment thread helpers/silent-payments/splitPayment.ts
Comment thread screen/send/SendDetails.tsx Outdated
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread screen/send/Confirm.tsx Outdated
Comment thread screen/send/SendDetails.tsx
Comment thread class/wallets/hd-bip352-wallet.ts Outdated
Comment thread screen/send/Confirm.tsx Outdated
@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch 3 times, most recently from eadf7b9 to ede2049 Compare August 16, 2026 08:27
@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch from ede2049 to 457bb56 Compare August 16, 2026 09:40
- 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.
Comment thread screen/send/Confirm.tsx Outdated
Comment on lines 1312 to 1315
psbt.addOutput({
address: output.address,
address,
value: BigInt(output.value),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@notTanveer notTanveer Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread screen/send/SendDetails.tsx Outdated
Comment thread helpers/silent-payments/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants