Skip to content

Split Payments - #128

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

Split Payments#128
notTanveer wants to merge 3 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 from ceefb03 to 2239f41 Compare July 31, 2026 12:24
@notTanveer
notTanveer force-pushed the feat/split-payments-v2 branch from 2239f41 to ae2c2d2 Compare July 31, 2026 12:26
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.

2 participants