Send to and from BIP-352 silent payment addresses

The HD wallet can already derive its own sp1q… receive address and detect
incoming silent payments via the BlindBit indexer, but the Send dialog
only handled bare Bitcoin addresses (or npubs / nprofiles, which routed
through nostrPubkeyToBitcoinAddress). Silent-payment funds were stuck:
they showed in the balance but the dialog gated them with a "spending
isn't supported yet" notice.

Now the dialog handles both ends:

  - Recipient resolution in parseHdRecipient accepts sp1… (mainnet, v0)
    alongside bc1…, npub1…, and nprofile1…. The send mutation decodes the
    address, derives the per-transaction P_k locally from the selected
    inputs' BIP-341-tweaked private keys, and writes it as a regular P2TR
    output. The on-chain transaction looks like any other Taproot spend;
    the BIP-352 ECDH happens entirely off-chain.

  - The coin selector now mixes BIP-86 UTXOs with SP UTXOs from the
    NIP-78 storage doc. SP inputs are signed by computing
    d_k = b_spend + t_k and writing tapKeySig directly, bypassing
    @scure/btc-signer's automatic TapTweak (which would re-tweak the
    already-on-chain P_k and produce an invalid signature).

  - The "silent-payment-only balance" warning is gone — those funds are
    now spendable. The privacy disclaimer still appears for bare bc1…
    addresses but is suppressed for sp1… recipients, since the whole
    point of silent payments is that the on-chain output is fresh and
    unlinkable.

src/lib/hdwallet/sp/sender.ts contains the BIP-352 sender math (address
bech32m decode, outpoint serialisation, ECDH, P_k derivation) ported from
Ditto and adapted to noble-curves v2. src/lib/hdwallet/sp/spend.ts holds
the spend-side helpers (b_spend derivation, d_k = b_spend + t_k, manual
Schnorr signing for SP inputs). Both are covered by the BIP-352 canonical
taproot-only test vectors plus a sender↔receiver round-trip check that
the same (b_spend, t_k) the scanner persists really does produce the
P_k the spender signs against.
This commit is contained in:
Alex Gleason
2026-05-22 01:19:08 -05:00
parent 05332e31c9
commit 3adfe5d89a
7 changed files with 1683 additions and 162 deletions
+106 -57
View File
@@ -35,7 +35,6 @@ import {
isLargeAmount,
nostrPubkeyToBitcoinAddress,
satsToUSD,
validateBitcoinAddress,
} from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
@@ -43,9 +42,12 @@ import {
fetchFeeRates,
} from '@/lib/hdwallet/blockbook';
import {
buildHdUnsignedPsbt,
buildHdSpendPsbt,
finalizeHdPsbt,
type HdInput,
type HdSpendableSpUtxo,
type HdSpendableUtxo,
parseHdRecipient,
previewHdFee,
signHdPsbt,
} from '@/lib/hdwallet/transaction';
@@ -93,17 +95,31 @@ function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] {
// ---------------------------------------------------------------------------
interface ResolvedRecipient {
/** Final P2TR/P2WPKH/etc. address used as the PSBT output. */
/**
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
*
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
* string — the real on-chain `P_k` is derived at build time, after coin
* selection. The dialog never displays this value directly when
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
* by recipient kind.
*/
address: string;
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
pubkey?: string;
/** Raw text the user typed (for re-display). */
raw: string;
/**
* Recipient kind. `'address'` for bare Bitcoin addresses (including
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
*/
kind: 'address' | 'sp';
}
/**
* Parse the recipient input as one of:
* - bare Bitcoin address (mainnet, any standard type)
* - silent-payment address (`sp1…`, mainnet, v0)
* - npub1… → P2TR derived from the Nostr pubkey
* - nprofile1… → P2TR derived from the encoded pubkey
*
@@ -114,9 +130,13 @@ function resolveRecipient(input: string): ResolvedRecipient | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Try bare Bitcoin address first — common case.
if (validateBitcoinAddress(trimmed)) {
return { address: trimmed, raw: trimmed };
// Try bare Bitcoin / silent-payment via the unified parser.
const parsed = parseHdRecipient(trimmed);
if (parsed) {
if (parsed.kind === 'address') {
return { address: parsed.address, raw: trimmed, kind: 'address' };
}
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
}
// Try NIP-19 npub / nprofile.
@@ -125,10 +145,10 @@ function resolveRecipient(input: string): ResolvedRecipient | null {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
const address = nostrPubkeyToBitcoinAddress(decoded.data);
if (address) return { address, pubkey: decoded.data, raw: trimmed };
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
} else if (decoded.type === 'nprofile') {
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed };
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
}
} catch {
// fall through
@@ -165,7 +185,12 @@ interface SendResult {
*/
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
const availability = useHdWalletAccess();
const { scan, silentPaymentBalance, refetch: refetchWallet } = useHdWallet();
const {
scan,
silentPaymentBalance,
silentPaymentStorage,
refetch: refetchWallet,
} = useHdWallet();
const { config } = useAppContext();
const { blockbookBaseUrl } = config;
const { toast } = useToast();
@@ -201,16 +226,34 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
}, [feeRates, feeSpeed]);
// ── Owned UTXO set ───────────────────────────────────────────
const ownedUtxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
const totalBalance = useMemo(() => ownedUtxos.reduce((s, u) => s + u.value, 0), [ownedUtxos]);
// Silent-payment UTXOs are scanned and displayed on /wallet but cannot
// yet be spent by this dialog: the BIP-352 receive output uses a tweaked
// private key not derivable from the BIP-86 (chain, index) pair the PSBT
// signer expects. Detect the case where the wallet's _only_ funds are SP
// funds so we can surface a clear explanation instead of leaving the user
// with a silently-disabled Send button.
const onlyHasSpFunds = totalBalance === 0 && silentPaymentBalance > 0;
//
// Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
// discovered by the BIP-352 scanner and persisted via NIP-78. Both can
// fund a send; the PSBT builder dispatches per-input.
const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
const spUtxos: HdSpendableSpUtxo[] = useMemo(
() =>
(silentPaymentStorage?.utxos ?? []).map((u) => ({
txid: u.txid,
vout: u.vout,
value: u.value,
tweakHex: u.tweak,
k: u.k,
height: u.height,
})),
[silentPaymentStorage],
);
const ownedInputs: HdInput[] = useMemo(
() => [
...bip86Utxos.map<HdInput>((utxo) => ({ kind: 'bip86', utxo })),
...spUtxos.map<HdInput>((utxo) => ({ kind: 'sp', utxo })),
],
[bip86Utxos, spUtxos],
);
const totalBalance = useMemo(
() => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
[bip86Utxos, silentPaymentBalance],
);
// ── USD → sats ───────────────────────────────────────────────
const amountSats = useMemo(() => {
@@ -222,21 +265,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
// ── Fee estimate (matches the actual coin selection) ────────
//
// Crucially we do NOT use `ownedUtxos.length` as the input count: an HD
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
// wallet typically has many UTXOs across many addresses, but a real send
// only consumes the minimal set the coin selector picks. Using the full
// count would over-estimate fees by 10x or more on an active wallet, and
// would also make the UI think we're insufficient when we're not.
const estimatedFeeSats = useMemo(() => {
if (!ownedUtxos.length || !currentFeeRate || !amountSats) return 0;
return previewHdFee(ownedUtxos, amountSats, currentFeeRate);
}, [ownedUtxos, currentFeeRate, amountSats]);
if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0;
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
}, [ownedInputs, currentFeeRate, amountSats]);
const totalSats = amountSats + estimatedFeeSats;
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
const selectionFailed =
amountSats > 0 && !!currentFeeRate && ownedUtxos.length > 0 && estimatedFeeSats === 0;
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
@@ -244,7 +287,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
// user has manually overridden.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (!ownedUtxos.length || !feeRates || amountSats <= 0) return;
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
@@ -252,11 +295,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const fee = previewHdFee(ownedUtxos, amountSats, rate);
const fee = previewHdFee(ownedInputs, amountSats, rate);
if (fee > 0 && fee <= threshold) { target = speed; break; }
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, ownedUtxos, totalBalance]);
}, [amountSats, feeRates, ownedInputs, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
@@ -266,7 +309,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
// ── Two-tap arm + raw-address disclaimer ─────────────────────
const isLarge = isLargeAmount(totalSats, btcPrice);
const isRawAddress = !!recipient && !recipient.pubkey;
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
// payment — they do NOT have the privacy concern of a reused on-chain
// address. The public disclaimer is only needed for bare BTC addresses
// typed in directly (no Nostr identity attached, no SP).
const isRawAddress =
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
const [confirmArmed, setConfirmArmed] = useState(false);
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
@@ -304,8 +352,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
if (availability.status !== 'available') {
throw new Error('HD wallet is not available for this login type.');
}
if (!recipient) throw new Error('Enter a Bitcoin address or npub.');
if (!ownedUtxos.length) throw new Error('No spendable Bitcoin in this wallet.');
if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
if (!feeRates) throw new Error('Fee rates not loaded.');
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
if (amountSats <= 0) throw new Error('Enter an amount.');
@@ -315,17 +363,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
setProgress('building');
const built = buildHdUnsignedPsbt(
availability.account,
ownedUtxos,
recipient.address,
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient:
recipient.kind === 'sp'
? { kind: 'sp', spAddress: recipient.address }
: { kind: 'address', address: recipient.address },
amountSats,
rate,
feeRate: rate,
nextChangeIndex,
);
nsecBytes: availability.nsecBytes,
});
setProgress('signing');
const signedHex = signHdPsbt(built.psbtHex, built.inputDerivations, availability.account);
const signedHex = signHdPsbt(
built.psbtHex,
built.inputDescriptors,
availability.account,
availability.nsecBytes,
);
const txHex = finalizeHdPsbt(signedHex);
setProgress('broadcasting');
@@ -337,6 +394,10 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
notificationSuccess();
setSuccess(result);
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
// SP storage is keyed by `(txid, vout)`; the next scan will mark
// spent UTXOs as gone. Invalidate the doc query so the optimistic
// copy is dropped in favour of the relay copy on the next scan.
queryClient.invalidateQueries({ queryKey: ['hdwallet-sp-doc'] });
void refetchWallet();
},
onError: (err) => {
@@ -350,11 +411,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
if (availability.status !== 'available') {
setError('HD wallet is not available for this login type.'); return;
}
if (!recipient) { setError('Enter a Bitcoin address or npub.'); return; }
if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!ownedUtxos.length) { setError("You don't have any Bitcoin yet."); return; }
if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
if (isRawAddress && !acknowledgedPublic) {
setError('Acknowledge the privacy warning before sending.'); return;
@@ -366,7 +427,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
recipient,
btcPrice,
amountSats,
ownedUtxos.length,
ownedInputs.length,
insufficient,
isRawAddress,
acknowledgedPublic,
@@ -411,7 +472,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
!btcPrice ||
amountSats <= 0 ||
insufficient ||
!ownedUtxos.length ||
!ownedInputs.length ||
(isRawAddress && !acknowledgedPublic);
// ── Render ───────────────────────────────────────────────────
@@ -501,14 +562,16 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
id="hd-recipient-input"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
placeholder="bc1… or npub…"
placeholder="bc1…, sp1…, or npub…"
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
/>
{recipient && (
<p className="text-xs text-muted-foreground">
{recipient.pubkey ? (
{recipient.kind === 'sp' ? (
<>Sending via a silent payment the recipient gets a fresh, unlinkable on-chain address.</>
) : recipient.pubkey ? (
<>Sending to a Nostr user&apos;s on-chain address.</>
) : (
<>Sending to a raw Bitcoin address.</>
@@ -584,20 +647,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
</Alert>
)}
{/* Silent-payment-only balance: the PSBT signer can't yet spend
BIP-352 outputs, so explain why Send is disabled instead of
leaving the button greyed out with no feedback. */}
{onlyHasSpFunds && (
<Alert className="py-2">
<AlertTriangle className="size-3.5" />
<AlertDescription className="text-xs">
Your balance is held in silent-payment outputs. Spending
them isn't supported yet — receive on-chain bitcoin to
your regular receive address to send.
</AlertDescription>
</Alert>
)}
{/* Send button */}
<Button
type="button"
+190
View File
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest';
import { hex } from '@scure/base';
import {
bip86TweakedPrivateKey,
decodeSilentPaymentAddress,
deriveSilentPaymentOutputs,
isSilentPaymentAddress,
validateSilentPaymentAddress,
type SilentPaymentInput,
} from './sender';
import vectors from '../../../test/fixtures/bip352_taproot_vectors.json';
// ---------------------------------------------------------------------------
// Test fixture type
// ---------------------------------------------------------------------------
interface VinJSON {
txid: string;
vout: number;
private_key: string;
scriptPubKey: string;
}
interface SendingJSON {
vin: VinJSON[];
recipients: string[];
expected_output_permutations: string[][];
}
interface VectorJSON {
comment: string;
sending: SendingJSON[];
}
const taprootVectors: VectorJSON[] = vectors as VectorJSON[];
// ---------------------------------------------------------------------------
// Address validation
// ---------------------------------------------------------------------------
const REFERENCE_SP =
'sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv';
describe('isSilentPaymentAddress', () => {
it('accepts mainnet sp1…', () => {
expect(isSilentPaymentAddress(REFERENCE_SP)).toBe(true);
});
it('accepts testnet tsp1…', () => {
expect(isSilentPaymentAddress('tsp1qqfoo')).toBe(true);
});
it('rejects bare bech32m addresses', () => {
expect(
isSilentPaymentAddress(
'bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5',
),
).toBe(false);
});
it('rejects garbage', () => {
expect(isSilentPaymentAddress('not-an-address')).toBe(false);
expect(isSilentPaymentAddress('')).toBe(false);
});
});
describe('validateSilentPaymentAddress', () => {
it('accepts the BIP-352 reference address', () => {
expect(validateSilentPaymentAddress(REFERENCE_SP)).toBe(true);
});
it('rejects a corrupted checksum', () => {
expect(validateSilentPaymentAddress(REFERENCE_SP.slice(0, -1) + 'a')).toBe(false);
});
it('rejects mixed case', () => {
const mixed = REFERENCE_SP.slice(0, 4) + REFERENCE_SP.slice(4).toUpperCase();
expect(validateSilentPaymentAddress(mixed)).toBe(false);
});
});
describe('decodeSilentPaymentAddress', () => {
it('decodes the BIP-352 reference address', () => {
const sp = decodeSilentPaymentAddress(REFERENCE_SP);
expect(sp.hrp).toBe('sp');
expect(sp.network).toBe('mainnet');
expect(sp.version).toBe(0);
expect(sp.scanPubKey.length).toBe(33);
expect(sp.spendPubKey.length).toBe(33);
});
it('throws on a structurally invalid address', () => {
// bech32m.decode itself rejects this — short data part + bad checksum.
// The test exists so a regression that swallows decode errors would
// surface here.
expect(() => decodeSilentPaymentAddress('xx1qqqqqq')).toThrow();
});
});
// ---------------------------------------------------------------------------
// BIP-352 sender output derivation (canonical test vectors)
// ---------------------------------------------------------------------------
/**
* For Taproot-only vectors, every input's `private_key` is the BIP-341
* *tweaked* key (the actual signing scalar). The vectors give us a
* `scriptPubKey` of the form `OP_1 push32 <xonly>` — we use that to verify
* we're loading the right input. The BIP-352 sender algorithm negates
* scalars whose pubkey has odd-Y, so the test driver passes the raw
* private_key and `isTaproot: true`.
*/
describe('deriveSilentPaymentOutputs — BIP-352 taproot vectors', () => {
for (const vector of taprootVectors) {
describe(vector.comment, () => {
for (const send of vector.sending) {
it('matches the expected x-only outputs', () => {
const inputs: SilentPaymentInput[] = send.vin.map((v) => ({
txid: v.txid,
vout: v.vout,
privateKey: hex.decode(v.private_key),
isTaproot: true,
}));
const recipients = send.recipients.map((addr) => ({
address: decodeSilentPaymentAddress(addr),
raw: addr,
}));
const outputs = deriveSilentPaymentOutputs(inputs, recipients, {
network: 'mainnet',
});
const actual = outputs.map((o) => hex.encode(o.xOnlyPubKey)).sort();
// The BIP vector lists permutations; in single-recipient cases
// there's only one entry. Compare against any permutation.
const matchesAny = send.expected_output_permutations.some((perm) => {
const expected = [...perm].sort();
return (
actual.length === expected.length &&
actual.every((a, i) => a === expected[i])
);
});
expect(matchesAny).toBe(true);
});
}
});
}
});
// ---------------------------------------------------------------------------
// bip86TweakedPrivateKey
// ---------------------------------------------------------------------------
describe('bip86TweakedPrivateKey', () => {
it('produces a 32-byte tweaked key for a valid BIP-86 child key', () => {
// A random valid scalar — the vector is just a regression check that
// the helper returns a non-empty result; the math itself is delegated
// to `@scure/btc-signer/utils.taprootTweakPrivKey`.
const child = hex.decode(
'eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1',
);
const tweaked = bip86TweakedPrivateKey(child);
expect(tweaked.length).toBe(32);
expect(hex.encode(tweaked)).not.toBe(hex.encode(child));
});
});
// ---------------------------------------------------------------------------
// Sanity: an empty input set is rejected
// ---------------------------------------------------------------------------
describe('deriveSilentPaymentOutputs — edge cases', () => {
it('throws when no inputs are provided', () => {
const recipients = [
{ address: decodeSilentPaymentAddress(REFERENCE_SP), raw: REFERENCE_SP },
];
expect(() =>
deriveSilentPaymentOutputs([], recipients, { network: 'mainnet' }),
).toThrow();
});
it('returns an empty array when no recipients are provided', () => {
const inputs: SilentPaymentInput[] = [
{
txid: 'f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16',
vout: 0,
privateKey: hex.decode(
'eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1',
),
isTaproot: true,
},
];
expect(deriveSilentPaymentOutputs(inputs, [], { network: 'mainnet' })).toEqual([]);
});
});
+475
View File
@@ -0,0 +1,475 @@
import { bech32m, hex as hexCodec } from '@scure/base';
import * as btc from '@scure/btc-signer';
import { taprootTweakPrivKey } from '@scure/btc-signer/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToNumberBE, numberToBytesBE } from '@noble/curves/utils.js';
import { taggedHash } from './crypto';
// ---------------------------------------------------------------------------
// BIP-352 Silent Payments — sender side
// ---------------------------------------------------------------------------
//
// This module decodes a recipient's silent-payment address (`sp1…` mainnet,
// `tsp1…` testnets) and derives the one-shot Taproot output(s) the sender
// must include in the transaction.
//
// Algorithm (BIP-352 §"Creating outputs"):
//
// a = Σ a_i (with taproot input keys negated if their
// x-only pubkey has an odd Y after multiplication by G)
// A = a · G
// outpoint_L = lex-smallest serialised outpoint across ALL inputs
// input_hash = hashBIP0352/Inputs(outpoint_L || serP(A))
// ecdh = input_hash · a · B_scan
// t_k = hashBIP0352/SharedSecret(serP(ecdh) || ser32(k))
// P_k = B_spend + t_k · G
//
// `P_k` (x-only) is the per-recipient Taproot output the sender writes
// into the transaction. The receiver, scanning the chain, recovers the
// same `P_k` from `bscan` + the per-tx tweak `serP(input_hash · A)`.
//
// We deliberately do NOT implement:
//
// - The receiver side (scanning). See `scanner.ts` / `crypto.ts`.
// - Labels (`m != 0`). Labels are a receiver-side concept; the address
// payload already commits to the labelled `B_m` if any, so the sender
// treats labelled and unlabelled addresses identically.
//
// Agora uses noble-curves v2 (`Point` / `toBytes` / `fromBytes`); the
// ditto reference implementation that inspired this file uses v1
// (`ProjectivePoint` / `toRawBytes` / `fromHex`). The maths is the same.
// ---------------------------------------------------------------------------
const { Point } = secp256k1;
/** secp256k1 group order N. */
const SECP_N =
0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
/** Maximum bech32m string length permitted by BIP-350. */
const BECH32M_MAX_LENGTH = 1023;
/** BIP-352 per-recipient `k` ceiling. */
const K_MAX = 2323;
// ---------------------------------------------------------------------------
// Byte / scalar helpers
// ---------------------------------------------------------------------------
function concatBytes(...arrs: Uint8Array[]): Uint8Array {
let len = 0;
for (const a of arrs) len += a.length;
const out = new Uint8Array(len);
let off = 0;
for (const a of arrs) {
out.set(a, off);
off += a.length;
}
return out;
}
function compareBytes(a: Uint8Array, b: Uint8Array): number {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return a.length - b.length;
}
function bytesToHexLocal(b: Uint8Array): string {
return hexCodec.encode(b);
}
function u32be(n: number): Uint8Array {
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) {
throw new Error(`ser32: out of range (${n}).`);
}
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n, false);
return b;
}
function scalarBytes(s: bigint): Uint8Array {
if (s <= 0n || s >= SECP_N) {
throw new Error('Silent payment: scalar out of range.');
}
return numberToBytesBE(s, 32);
}
/** True iff `bytes` is a 33-byte compressed point on secp256k1. */
function isValidCompressedPoint(bytes: Uint8Array): boolean {
if (bytes.length !== 33) return false;
if (bytes[0] !== 0x02 && bytes[0] !== 0x03) return false;
try {
Point.fromBytes(bytes);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Silent payment address decoding
// ---------------------------------------------------------------------------
/** Network of a silent payment address. */
export type SilentPaymentNetwork = 'mainnet' | 'testnet';
/** A decoded silent payment address. */
export interface DecodedSilentPaymentAddress {
/** Bech32m HRP. `"sp"` for mainnet, `"tsp"` for testnet/signet/regtest. */
hrp: string;
network: SilentPaymentNetwork;
/** Silent-payment-version (0 for `sp1q…`). */
version: number;
/** Receiver's scan pubkey (33-byte compressed sec). */
scanPubKey: Uint8Array;
/**
* Receiver's spend pubkey (33-byte compressed sec). When the address is
* labelled, this is the labelled `B_m`, not the raw `B_spend`; from the
* sender's perspective the two are interchangeable.
*/
spendPubKey: Uint8Array;
}
/** Cheap pre-check: does this look like a silent payment address at all? */
export function isSilentPaymentAddress(s: string): boolean {
if (typeof s !== 'string') return false;
const lower = s.toLowerCase();
return lower.startsWith('sp1') || lower.startsWith('tsp1');
}
/**
* Decode a BIP-352 silent payment address.
*
* Throws on mixed case, invalid characters, bad checksum, unknown HRP,
* version 31 (reserved), payload shorter than 66 bytes after the version, or
* curve-invalid pubkeys.
*
* The decoder accepts (but ignores) trailing bytes for v1v30 per the BIP's
* forward-compatibility rule.
*/
export function decodeSilentPaymentAddress(addr: string): DecodedSilentPaymentAddress {
if (typeof addr !== 'string' || addr.length === 0) {
throw new Error('Silent payment address: empty string.');
}
if (addr.length > BECH32M_MAX_LENGTH) {
throw new Error('Silent payment address: too long.');
}
// BIP-173 forbids mixed case.
if (addr.toLowerCase() !== addr && addr.toUpperCase() !== addr) {
throw new Error('Silent payment address: mixed case.');
}
const lower = addr.toLowerCase();
// `@scure/base`'s `bech32m.decode` performs the polymod + character checks
// for us; we still need to do BIP-352-specific structural validation
// (version byte, payload length, pubkey validity).
let decoded: ReturnType<typeof bech32m.decode>;
try {
decoded = bech32m.decode(lower, BECH32M_MAX_LENGTH);
} catch (err) {
throw new Error(
`Silent payment address: ${err instanceof Error ? err.message : 'invalid bech32m'}.`,
);
}
const { prefix: hrp, words } = decoded;
let network: SilentPaymentNetwork;
if (hrp === 'sp') network = 'mainnet';
else if (hrp === 'tsp') network = 'testnet';
else throw new Error(`Silent payment address: unknown HRP "${hrp}".`);
if (words.length < 1) {
throw new Error('Silent payment address: data part too short.');
}
const version = words[0];
if (version > 31) throw new Error('Silent payment address: invalid version.');
if (version === 31) {
throw new Error('Silent payment address: reserved version 31.');
}
const payload = bech32m.fromWords(words.slice(1));
if (version === 0) {
if (payload.length !== 66) {
throw new Error(
`Silent payment v0: data part must be exactly 66 bytes (got ${payload.length}).`,
);
}
} else {
if (payload.length < 66) {
throw new Error(
`Silent payment v${version}: data part must be at least 66 bytes (got ${payload.length}).`,
);
}
}
const scanPubKey = payload.slice(0, 33);
const spendPubKey = payload.slice(33, 66);
if (!isValidCompressedPoint(scanPubKey)) {
throw new Error('Silent payment address: scan key is not a valid compressed point.');
}
if (!isValidCompressedPoint(spendPubKey)) {
throw new Error('Silent payment address: spend key is not a valid compressed point.');
}
return { hrp, network, version, scanPubKey, spendPubKey };
}
/**
* Best-effort validator. Returns `true` iff the string is a syntactically
* valid silent payment address (bech32m + curve checks). Use for inline
* form validation where pickers may speculatively check half-typed inputs
* and a thrown error is the wrong UX signal.
*/
export function validateSilentPaymentAddress(addr: string): boolean {
try {
decodeSilentPaymentAddress(addr);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Outpoint serialisation
// ---------------------------------------------------------------------------
/**
* Serialise an outpoint exactly as it appears in a Bitcoin transaction:
* 32-byte txid in internal (little-endian) byte order followed by a 4-byte
* little-endian vout.
*
* Esplora/Blockbook/mempool.space and Bitcoin Core's RPC all surface txids
* as their display form (big-endian); we reverse them here for the hash.
*/
function serializeOutpoint(txidHex: string, vout: number): Uint8Array {
if (!/^[0-9a-fA-F]{64}$/.test(txidHex)) {
throw new Error('outpoint: txid must be 32-byte hex.');
}
const txid = hexCodec.decode(txidHex.toLowerCase());
txid.reverse();
const voutBuf = new Uint8Array(4);
new DataView(voutBuf.buffer).setUint32(0, vout >>> 0, true);
return concatBytes(txid, voutBuf);
}
// ---------------------------------------------------------------------------
// Sender output derivation
// ---------------------------------------------------------------------------
/**
* A single sender input that contributes to BIP-352 ECDH.
*
* `privateKey` is the input's signing private key. For Taproot inputs this
* is the BIP-341 *tweaked* key (the same scalar used to produce the Schnorr
* signature on the input), NOT the untweaked BIP-86 child key.
*
* `isTaproot` flips the negate-if-odd-Y rule on per BIP-352 §"Creating
* outputs". When `true`, this module derives `pubKey = privateKey · G` and
* negates `privateKey` mod N if the resulting pubkey has an odd Y.
*/
export interface SilentPaymentInput {
txid: string;
vout: number;
privateKey: Uint8Array;
isTaproot: boolean;
}
/** A single resolved silent payment recipient. */
export interface SilentPaymentRecipient {
/** Decoded silent payment address. */
address: DecodedSilentPaymentAddress;
/** Original raw address string (for diagnostics / receipts). */
raw?: string;
}
/**
* One concrete sender output (the receiver's per-`k` taproot output) ready
* to be added to a PSBT.
*/
export interface SilentPaymentOutput {
/** 32-byte x-only taproot key — the value of the output's scriptPubKey. */
xOnlyPubKey: Uint8Array;
/** Convenience: the matching mainnet/testnet P2TR address. */
address: string;
/** The recipient this output was generated for. */
recipient: SilentPaymentRecipient;
}
/**
* Compute the BIP-352 sender outputs for a fixed set of inputs and
* recipients.
*
* The inputs MUST be the final set that will be signed and broadcast: the
* recipient's output depends on the input set (via `outpoint_L` and `A`).
* Adding, removing, or replacing an input invalidates the derived outputs.
*
* Throws if:
* - no inputs are given,
* - the summed private key is zero,
* - `input_hash` or any `t_k` would be an invalid scalar,
* - a recipient group exceeds `K_max = 2323` (per BIP-352).
*
* Recipients are grouped by scan key: multiple silent-payment addresses
* sharing the same scan key share one ECDH derivation, and each receives
* `k = 0, 1, …` in input order.
*
* `allOutpoints` is the outpoint of *every* input in the transaction —
* including inputs that contribute to neither the BIP-352 ECDH sum nor
* `outpoint_L`. Defaults to the outpoints of `eligibleInputs` (the common
* case where every input is BIP-352-eligible, which is what the Agora HD
* wallet always produces).
*/
export function deriveSilentPaymentOutputs(
eligibleInputs: SilentPaymentInput[],
recipients: SilentPaymentRecipient[],
options: {
allOutpoints?: ReadonlyArray<{ txid: string; vout: number }>;
network?: SilentPaymentNetwork;
} = {},
): SilentPaymentOutput[] {
const network = options.network ?? 'mainnet';
if (eligibleInputs.length === 0) {
throw new Error('Silent payment: at least one eligible input is required.');
}
if (recipients.length === 0) return [];
// ── Step 0: K_max check — fail before any crypto work ─────────────
const groups = new Map<string, SilentPaymentRecipient[]>();
for (const r of recipients) {
const key = bytesToHexLocal(r.address.scanPubKey);
const arr = groups.get(key);
if (arr) arr.push(r);
else groups.set(key, [r]);
}
for (const arr of groups.values()) {
if (arr.length > K_MAX) {
throw new Error(`Silent payment: recipient group exceeds K_max=${K_MAX}.`);
}
}
// ── Step 1: compute a = Σ a_i (negating odd-Y taproot keys) ───────
let aSum = 0n;
for (const input of eligibleInputs) {
if (input.privateKey.length !== 32) {
throw new Error('Silent payment: input private key must be 32 bytes.');
}
let scalar = bytesToNumberBE(input.privateKey);
if (scalar === 0n || scalar >= SECP_N) {
throw new Error('Silent payment: input private key out of range.');
}
if (input.isTaproot) {
// Per BIP-352: if x_only(a_i · G) has odd Y, negate a_i.
const pub = Point.BASE.multiply(scalar).toBytes(true);
if (pub[0] === 0x03) {
scalar = SECP_N - scalar;
}
}
aSum = (aSum + scalar) % SECP_N;
}
if (aSum === 0n) {
throw new Error('Silent payment: sum of input private keys is zero.');
}
// A = a · G
const aPub = Point.BASE.multiply(aSum).toBytes(true);
// ── Step 2: outpoint_L = lex-smallest serialised outpoint ─────────
const outpointsForHash =
options.allOutpoints ?? eligibleInputs.map((i) => ({ txid: i.txid, vout: i.vout }));
if (outpointsForHash.length === 0) {
throw new Error('Silent payment: no outpoints provided.');
}
let smallest: Uint8Array | null = null;
for (const op of outpointsForHash) {
const ser = serializeOutpoint(op.txid, op.vout);
if (smallest === null || compareBytes(ser, smallest) < 0) {
smallest = ser;
}
}
if (!smallest) throw new Error('Silent payment: no outpoints.');
// ── Step 3: input_hash = hashBIP0352/Inputs(outpoint_L || serP(A)) ──
const inputHash = taggedHash('BIP0352/Inputs', concatBytes(smallest, aPub));
const inputHashScalar = bytesToNumberBE(inputHash);
if (inputHashScalar === 0n || inputHashScalar >= SECP_N) {
throw new Error('Silent payment: invalid input_hash.');
}
// ── Step 4: derive outputs per scan-key group ─────────────────────
const out: SilentPaymentOutput[] = [];
for (const group of groups.values()) {
// ecdh = input_hash · a · B_scan
// = ((input_hash · a) mod n) · B_scan (Point math)
const scanPoint = Point.fromBytes(group[0].address.scanPubKey);
const combinedScalar = (inputHashScalar * aSum) % SECP_N;
if (combinedScalar === 0n) {
throw new Error('Silent payment: input_hash · a is zero.');
}
const ecdh = scanPoint.multiply(combinedScalar).toBytes(true);
let k = 0;
for (const recipient of group) {
const tK = taggedHash('BIP0352/SharedSecret', concatBytes(ecdh, u32be(k)));
const tScalar = bytesToNumberBE(tK);
if (tScalar === 0n || tScalar >= SECP_N) {
throw new Error('Silent payment: invalid t_k.');
}
// P_k = B_spend + t_k · G
const spendPoint = Point.fromBytes(recipient.address.spendPubKey);
const P = spendPoint.add(Point.BASE.multiply(tScalar));
if (P.is0()) {
throw new Error('Silent payment: B_spend + t_k·G is point at infinity.');
}
const Pbytes = P.toBytes(true);
// BIP-341 taproot output is the x-only of P.
const xonly = new Uint8Array(Pbytes.subarray(1, 33));
const addr = encodeP2TR(xonly, network);
out.push({ xOnlyPubKey: xonly, address: addr, recipient });
k++;
}
}
return out;
}
/** Encode an x-only key as a P2TR address using `@scure/btc-signer`. */
function encodeP2TR(xonly: Uint8Array, network: SilentPaymentNetwork): string {
const net = network === 'mainnet' ? btc.NETWORK : btc.TEST_NETWORK;
// `p2tr(xonly)` here is given the **output** key directly; passing it
// without a script tree and reading `.address` yields the bech32m
// encoding of `OP_1 push32 <xonly>` for the chosen network.
const pay = btc.p2tr(xonly, undefined, net);
if (!pay.address) {
throw new Error('Silent payment: failed to encode P2TR address.');
}
return pay.address;
}
/**
* BIP-86 child keys are not yet BIP-341-tweaked — `@scure/btc-signer`'s
* `signIdx` applies the TapTweak internally when signing. BIP-352, however,
* requires the **tweaked** Taproot scalar in `a = Σ a_i`. This helper
* computes the tweaked key for a single-key (key-path-only, no script tree)
* Taproot input.
*
* Equivalent to `taprootTweakPrivKey(child, undefined)`: applies the BIP-340
* parity-flip + `taggedHash("TapTweak", x_only(child·G))` addition mod N.
*
* Re-exported here so callers don't have to dig into `@scure/btc-signer/utils`.
*/
export function bip86TweakedPrivateKey(child: Uint8Array): Uint8Array {
return new Uint8Array(taprootTweakPrivKey(child));
}
// The `scalarBytes` helper is exported for the SP spend path, which needs
// to combine `b_spend` and `t_k` into a single signing scalar.
export { scalarBytes as encodeScalar };
export { SECP_N };
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { hex } from '@scure/base';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToNumberBE } from '@noble/curves/utils.js';
import {
deriveSilentPaymentSpendKey,
deriveSpUtxoOutputPoint,
deriveSpUtxoSigningKey,
deriveSpUtxoXOnly,
spP2trScriptPubKey,
} from './spend';
import { derivePkFromStoredTweak } from './crypto';
import { deriveSilentPaymentKeys } from '../derivation';
const { Point } = secp256k1;
// ---------------------------------------------------------------------------
// `deriveSpUtxoSigningKey` — d_k = (b_spend + t_k) mod N
// ---------------------------------------------------------------------------
describe('deriveSpUtxoSigningKey', () => {
it('produces a 32-byte scalar', () => {
const bSpend = hex.decode(
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
);
const tweak = hex.decode(
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
expect(dk.length).toBe(32);
});
it('matches manual modular addition', () => {
const bSpend = hex.decode(
'0000000000000000000000000000000000000000000000000000000000000001',
);
const tweak = hex.decode(
'0000000000000000000000000000000000000000000000000000000000000002',
);
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
expect(hex.encode(dk)).toBe(
'0000000000000000000000000000000000000000000000000000000000000003',
);
});
it('throws on out-of-range b_spend', () => {
const zero = new Uint8Array(32);
const tweak = new Uint8Array(32);
tweak[31] = 1;
expect(() => deriveSpUtxoSigningKey(zero, tweak)).toThrow();
});
});
// ---------------------------------------------------------------------------
// On-chain output derivation matches BIP-352 receiver math
// ---------------------------------------------------------------------------
//
// `deriveSpUtxoXOnly` computes (b_spend + t_k) · G. The receiver-side
// scanner already implemented in crypto.ts computes B_spend + t_k · G.
// Both must produce the same x-only key: this is the round-trip the
// receiver-then-spender flow depends on.
describe('deriveSpUtxoXOnly', () => {
it('matches the receiver-side derivation B_spend + t_k · G', () => {
// Fabricate a deterministic (b_spend, t_k) pair.
const bSpend = hex.decode(
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
);
const tweak = hex.decode(
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
);
// Sender-side: d_k · G
const senderXOnly = deriveSpUtxoXOnly(bSpend, tweak);
// Receiver-side: B_spend + t_k · G via the existing scanner helper
const bSpendScalar = bytesToNumberBE(bSpend);
const BSpend = Point.BASE.multiply(bSpendScalar).toBytes(true);
const receiverXOnly = derivePkFromStoredTweak(BSpend, tweak);
expect(hex.encode(senderXOnly)).toBe(hex.encode(receiverXOnly));
});
});
// ---------------------------------------------------------------------------
// `deriveSilentPaymentSpendKey`: integration with the existing derivation
// ---------------------------------------------------------------------------
describe('deriveSilentPaymentSpendKey', () => {
it("matches the receiver-side B_spend = b_spend · G derivation", () => {
// Random nsec — value doesn't matter, only that both paths agree.
const nsec = hex.decode(
'abababababababababababababababababababababababababababababababab',
);
const bSpend = deriveSilentPaymentSpendKey(nsec);
const keys = deriveSilentPaymentKeys(nsec);
// b_spend · G must equal the published `Bspend` (receive-side).
const derivedPub = Point.BASE.multiply(bytesToNumberBE(bSpend)).toBytes(true);
expect(hex.encode(derivedPub)).toBe(hex.encode(keys.Bspend));
});
});
// ---------------------------------------------------------------------------
// `deriveSpUtxoOutputPoint` returns a valid 33-byte compressed point
// ---------------------------------------------------------------------------
describe('deriveSpUtxoOutputPoint', () => {
it('returns a 33-byte compressed point on the curve', () => {
const bSpend = hex.decode(
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
);
const tweak = hex.decode(
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
);
const point = deriveSpUtxoOutputPoint(bSpend, tweak);
expect(point.length).toBe(33);
expect(point[0] === 0x02 || point[0] === 0x03).toBe(true);
// Should round-trip through `Point.fromBytes`.
expect(() => Point.fromBytes(point)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// `spP2trScriptPubKey`: shape sanity
// ---------------------------------------------------------------------------
describe('spP2trScriptPubKey', () => {
it('emits a 34-byte OP_1 push32 <xonly> script', () => {
const xonly = new Uint8Array(32);
xonly.fill(7);
const script = spP2trScriptPubKey(xonly);
expect(script.length).toBe(34);
expect(script[0]).toBe(0x51); // OP_1
expect(script[1]).toBe(0x20); // push 32
expect(Array.from(script.subarray(2))).toEqual(Array(32).fill(7));
});
});
+218
View File
@@ -0,0 +1,218 @@
import { HDKey } from '@scure/bip32';
import * as btc from '@scure/btc-signer';
import { signSchnorr } from '@scure/btc-signer/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToNumberBE, numberToBytesBE } from '@noble/curves/utils.js';
import { SECP_N } from './sender';
// ---------------------------------------------------------------------------
// BIP-352 Silent Payments — spend side
// ---------------------------------------------------------------------------
//
// `useHdWalletSp` discovers incoming silent-payment UTXOs by computing
// `shared = bscan · tweak` and walking `k = 0, 1, …` until no output
// matches. For each match it persists the per-output `t_k` tweak (32 bytes)
// in NIP-78 storage.
//
// To **spend** an SP UTXO, the wallet needs the private key matching the
// on-chain output key `P_k = B_spend + t_k · G`:
//
// d_k = (b_spend + t_k) mod N
//
// `P_k` is already a Taproot output key (BIP-341 with no script tree); it
// is NOT tweaked again by `taggedHash("TapTweak", x_only(P_k))` the way a
// BIP-86 child key is. Consequently the signing scalar is `d_k` directly
// and we must bypass `@scure/btc-signer`'s automatic TapTweak (which would
// produce a signature that doesn't verify against `P_k`).
//
// The {@link signSpUtxoInput} helper below computes the BIP-341 sighash with
// `Transaction.preimageWitnessV1`, signs it with `d_k`, and writes the
// signature into the PSBT input's `tapKeySig`. `finalize()` then emits a
// valid key-path witness.
//
// `b_spend` and `d_k` are spend-capable — they sit at the same trust
// boundary as the BIP-86 leaf keys. Callers must zero the buffers when
// done (best effort in JS).
// ---------------------------------------------------------------------------
const { Point } = secp256k1;
/**
* BIP-352 spend-key derivation path per BIP-352 §"Key Derivation" /
* NIP-SP §2.2.
*/
const SP_SPEND_PATH = "m/352'/0'/0'/0'/0";
/**
* Derive the 32-byte spend private scalar `b_spend` from a raw Nostr secret
* key. Same path the existing `deriveSilentPaymentAddress` /
* `deriveSilentPaymentKeys` helpers use; we keep the derivation co-located
* with the spend-side so the security boundary is explicit.
*
* Callers are responsible for zeroing the returned buffer when done.
*/
export function deriveSilentPaymentSpendKey(nsecBytes: Uint8Array): Uint8Array {
if (nsecBytes.length !== 32) {
throw new Error('nsec must be 32 bytes');
}
const root = HDKey.fromMasterSeed(nsecBytes);
const spendNode = root.derive(SP_SPEND_PATH);
if (!spendNode.privateKey) {
throw new Error('Failed to derive silent-payment spend private key');
}
// Defensive copy — `@scure/bip32` holds an internal reference.
return new Uint8Array(spendNode.privateKey);
}
/**
* Compute the spend scalar `d_k = (b_spend + t_k) mod N` for a single
* silent-payment UTXO discovered by the receiver-side scanner.
*
* Throws if `b_spend + t_k` reduces to 0 (probability ≈ 2^-256; honest
* indexers will never produce such a tweak, but a hostile one could in
* principle, and signing with 0 would leak nothing useful — we fail
* closed regardless).
*
* Returns a fresh 32-byte big-endian buffer. Callers should zero it when
* done.
*/
export function deriveSpUtxoSigningKey(
bSpend: Uint8Array,
tweak: Uint8Array,
): Uint8Array {
if (bSpend.length !== 32) throw new Error('b_spend must be 32 bytes');
if (tweak.length !== 32) throw new Error('tweak must be 32 bytes');
const a = bytesToNumberBE(bSpend);
const b = bytesToNumberBE(tweak);
if (a === 0n || a >= SECP_N) {
throw new Error('b_spend out of range');
}
if (b === 0n || b >= SECP_N) {
throw new Error('t_k out of range');
}
const d = (a + b) % SECP_N;
if (d === 0n) {
throw new Error('b_spend + t_k is zero mod N');
}
return numberToBytesBE(d, 32);
}
/**
* Compute the 33-byte compressed `P_k = (b_spend + t_k) · G` for a stored
* SP UTXO. Used to:
*
* 1. Build the witness UTXO's `scriptPubKey` (`OP_1 push32 x_only(P_k)`)
* when adding the input to the PSBT — Blockbook doesn't return that
* script natively for SP outputs, so we re-derive it locally.
* 2. Verify (in tests / debug paths) that our signing key matches the
* on-chain output.
*/
export function deriveSpUtxoOutputPoint(
bSpend: Uint8Array,
tweak: Uint8Array,
): Uint8Array {
const d = deriveSpUtxoSigningKey(bSpend, tweak);
try {
return Point.BASE.multiply(bytesToNumberBE(d)).toBytes(true);
} finally {
d.fill(0);
}
}
/**
* Compute the 32-byte x-only Taproot output key for a stored SP UTXO.
*/
export function deriveSpUtxoXOnly(
bSpend: Uint8Array,
tweak: Uint8Array,
): Uint8Array {
return deriveSpUtxoOutputPoint(bSpend, tweak).subarray(1, 33);
}
/**
* Build the standard P2TR `scriptPubKey` (`OP_1 push32 <xonly>`) for an SP
* UTXO's on-chain output. 34 bytes.
*/
export function spP2trScriptPubKey(xonly: Uint8Array): Uint8Array {
if (xonly.length !== 32) {
throw new Error('p2tr scriptPubKey: xonly key must be 32 bytes');
}
const out = new Uint8Array(34);
out[0] = 0x51; // OP_1
out[1] = 0x20; // push 32 bytes
out.set(xonly, 2);
return out;
}
/**
* Sign a single Taproot input that consumes a silent-payment UTXO.
*
* BIP-352 outputs ARE the output key on-chain — they are not BIP-341-
* TapTweaked. `@scure/btc-signer.signIdx` would unconditionally apply
* `taggedHash("TapTweak", x_only(P_k))` if we set `tapInternalKey =
* x_only(P_k)` and asked it to sign with `d_k`, producing a signature
* that doesn't verify against `P_k`. We sidestep that by:
*
* 1. Computing the BIP-341 sighash ourselves via
* {@link btc.Transaction.preimageWitnessV1}.
* 2. Signing with `d_k` directly using BIP-340 Schnorr.
* 3. Writing the result into `tapKeySig` so `finalize()` emits a normal
* key-path witness.
*
* Adjusts the signing scalar's parity per BIP-341 §"Constructing and
* spending Taproot outputs": if `x_only(d · G)` has odd Y, sign with
* `N - d` instead so the signature matches the x-only output key.
*
* `sighash` defaults to `SIGHASH_DEFAULT` (0x00), which produces a 64-byte
* Schnorr signature (no trailing sighash byte). All other valid Taproot
* sighash flags emit 65-byte signatures.
*/
export function signSpUtxoInput(
tx: btc.Transaction,
inputIndex: number,
signingKey: Uint8Array,
prevOutScripts: Uint8Array[],
prevOutAmounts: bigint[],
sighash: number = 0x00,
): void {
if (signingKey.length !== 32) {
throw new Error('SP signing key must be 32 bytes');
}
// BIP-340: signature is over the x-only pubkey. If x_only(d · G) has
// odd Y, sign with (n - d) instead.
let scalar = bytesToNumberBE(signingKey);
if (scalar === 0n || scalar >= SECP_N) {
throw new Error('SP signing key out of range');
}
const pub = Point.BASE.multiply(scalar).toBytes(true);
if (pub[0] === 0x03) {
scalar = SECP_N - scalar;
}
const adjusted = numberToBytesBE(scalar, 32);
try {
const hash = tx.preimageWitnessV1(
inputIndex,
prevOutScripts,
sighash,
prevOutAmounts,
);
const sig64 = signSchnorr(hash, adjusted);
const sig =
sighash === 0x00
? new Uint8Array(sig64)
: (() => {
const out = new Uint8Array(65);
out.set(sig64, 0);
out[64] = sighash;
return out;
})();
// `_ignoreSignStatus = true` lets us write the partial-sig field
// directly without `signIdx`'s own pubkey-match gate.
tx.updateInput(inputIndex, { tapKeySig: sig }, true);
} finally {
adjusted.fill(0);
}
}
+495 -105
View File
@@ -14,23 +14,59 @@ import {
type HdAccount,
HD_WALLET_NETWORK,
} from './derivation';
import {
bip86TweakedPrivateKey,
decodeSilentPaymentAddress,
deriveSilentPaymentOutputs,
isSilentPaymentAddress,
type SilentPaymentInput as SpSenderInput,
type SilentPaymentOutput as SpSenderOutput,
} from './sp/sender';
import {
deriveSilentPaymentSpendKey,
deriveSpUtxoSigningKey,
deriveSpUtxoXOnly,
signSpUtxoInput,
spP2trScriptPubKey,
} from './sp/spend';
import { hexToBytes } from './sp/crypto';
// ---------------------------------------------------------------------------
// HD wallet transaction construction & signing
// ---------------------------------------------------------------------------
//
// A "spendable" UTXO is one of our own UTXOs together with the (chain, index)
// pair that derived its address. We need both to:
// A "spendable" input is either:
//
// 1. Set `tapInternalKey` on the PSBT input correctly per BIP-371.
// 2. Reconstruct the BIP-341 tweaked private key when signing.
// - A BIP-86 UTXO (`HdSpendableUtxo`) — a P2TR output we control via the
// `m/86'/0'/0'/<chain>/<index>` HD hierarchy. Signing uses the
// `signIdx` path with the per-leaf private key.
//
// Change always goes to a fresh address on the internal (change) chain —
// never to a receive-chain address — to keep the on-chain heuristic
// "change-from-same-owner is the smaller output on the change chain" intact.
// - A silent-payment UTXO (`HdSpendableSpUtxo`) — a P2TR output the
// BIP-352 scanner discovered, keyed by a per-output tweak `t_k`. The
// spending scalar is `b_spend + t_k`, and because the on-chain output
// `P_k` is already a Taproot output key (no BIP-341 re-tweak), we sign
// it manually with Schnorr and inject `tapKeySig` into the PSBT.
//
// The two kinds are kept distinct at the type level so the signer dispatches
// correctly, but the coin selector and fee model treat them uniformly —
// every spendable input has the same on-chain footprint (a single Taproot
// witness with a 64-byte Schnorr signature).
//
// Recipients are also polymorphic:
//
// - A bare Bitcoin address (P2TR, P2WPKH, P2PKH, P2SH-P2WPKH, …).
// - A silent-payment address (`sp1…` mainnet). Decoded locally, with the
// receiver's per-transaction P2TR output derived from the chosen input
// set's tweaked private keys via {@link deriveSilentPaymentOutputs}.
//
// Change always goes to a fresh address on the internal (change) chain of
// the BIP-86 hierarchy — silent-payment change is intentionally not
// supported (it would require labelled SP addresses and a separate scan
// loop on the receive side; the wallet is receive-only for SP today
// modulo this spend flow).
// ---------------------------------------------------------------------------
/** A UTXO owned by the HD wallet, annotated with derivation info. */
/** A BIP-86 UTXO owned by the HD wallet, annotated with derivation info. */
export interface HdSpendableUtxo extends UTXO {
/** The Bitcoin address the UTXO belongs to. */
address: string;
@@ -40,6 +76,54 @@ export interface HdSpendableUtxo extends UTXO {
index: number;
}
/**
* A silent-payment UTXO owned by the HD wallet.
*
* Discovered by the BIP-352 scanner (`useHdWalletSp` + `scanBatch`) and
* persisted with the per-output BIP-352 tweak `t_k`. Spending requires
* `b_spend` (derived from nsec) plus `t_k` — both must be present at
* signing time.
*/
export interface HdSpendableSpUtxo {
txid: string;
vout: number;
/** Value in satoshis. */
value: number;
/** 32-byte BIP-352 tweak `t_k`, hex-encoded (matches the persisted form). */
tweakHex: string;
/** Per-tx output index within the SP output set (`k = 0, 1, …`). */
k: number;
/** Block height the UTXO was mined at (>= 1; SP UTXOs are always confirmed). */
height: number;
}
/** Unified input kind for SP-aware build / sign. */
export type HdInput =
| { kind: 'bip86'; utxo: HdSpendableUtxo }
| { kind: 'sp'; utxo: HdSpendableSpUtxo };
/** Helper: total satoshi value of a mixed input list. */
function inputValue(input: HdInput): number {
return input.kind === 'bip86' ? input.utxo.value : input.utxo.value;
}
/** Helper: confirmed/pending flag. SP UTXOs are always confirmed. */
function inputConfirmed(input: HdInput): boolean {
return input.kind === 'bip86' ? input.utxo.status.confirmed : true;
}
/** Stable identifier for de-duplication. */
function inputId(input: HdInput): string {
return input.kind === 'bip86'
? `bip86:${input.utxo.txid}:${input.utxo.vout}`
: `sp:${input.utxo.txid}:${input.utxo.vout}`;
}
/** Recipient parsing result. */
export type HdRecipient =
| { kind: 'address'; address: string }
| { kind: 'sp'; spAddress: string };
/** Result of building an unsigned PSBT for the HD wallet. */
export interface HdUnsignedPsbt {
/** Hex-encoded unsigned PSBT. */
@@ -50,10 +134,35 @@ export interface HdUnsignedPsbt {
hasChange: boolean;
/** Address used for change (if any). */
changeAddress?: string;
/** Per-input (chain, index) so signing knows which key to derive. */
inputDerivations: Array<{ chain: 0 | 1; index: number }>;
/**
* Per-input descriptor. Aligned 1:1 with the PSBT's inputs in order.
* `signHdPsbt` uses this to derive the right signing key per input.
*/
inputDescriptors: HdInputDescriptor[];
/**
* Resolved recipient address (the P2TR address actually written to the
* transaction). For silent-payment sends this is the derived per-tx
* `P_k`, which differs from the original `sp1…` string the user typed.
*/
resolvedRecipientAddress: string;
}
/** Per-input descriptor stored alongside the PSBT for signing dispatch. */
export type HdInputDescriptor =
| { kind: 'bip86'; chain: 0 | 1; index: number }
| { kind: 'sp'; tweakHex: string };
// ---------------------------------------------------------------------------
// Back-compat re-exports
// ---------------------------------------------------------------------------
//
// The dialog and older callers reference `inputDerivations`. We keep the
// shape working for pure-BIP86 builds and surface the richer descriptor
// list as the canonical field.
/** @deprecated Use {@link HdInputDescriptor} via `inputDescriptors`. */
export type HdInputDerivation = { chain: 0 | 1; index: number };
// ---------------------------------------------------------------------------
// PSBT hex helpers (private)
// ---------------------------------------------------------------------------
@@ -71,37 +180,37 @@ function psbtFromHex(psbtHex: string): btc.Transaction {
// ---------------------------------------------------------------------------
/**
* Branch-and-bound is overkill here. A "largest-first" coin selector is fine
* for a wallet whose UTXO set is bounded by gap-limit scanning:
* Largest-first coin selector that mixes BIP-86 and SP UTXOs.
*
* - Picks the smallest set of inputs that covers `target + fee`.
* - Prefers confirmed UTXOs over unconfirmed.
* - Returns the candidate set OR `null` if balance is insufficient.
* - Within each group, prefers larger values first (avoids signalling
* "consolidating dust" to chain analysis).
* - Returns the smallest set that covers `target + fee`, or `null` when
* the balance is insufficient.
*
* This deliberately avoids the privacy pitfall of the "smallest first"
* heuristic (which signals "I am consolidating dust" to chain analysis).
* The fee model treats every Taproot input the same (~57.5 vB), which is
* accurate for both BIP-86 and SP — each contributes one 64-byte Schnorr
* key-path witness.
*/
function selectUtxos(
utxos: readonly HdSpendableUtxo[],
function selectInputs(
inputs: readonly HdInput[],
target: number,
feeRate: number,
): { selected: HdSpendableUtxo[]; total: number } | null {
// Confirmed first, then largest first within each group.
const sorted = [...utxos].sort((a, b) => {
if (a.status.confirmed !== b.status.confirmed) return a.status.confirmed ? -1 : 1;
return b.value - a.value;
): { selected: HdInput[]; total: number } | null {
const sorted = [...inputs].sort((a, b) => {
const aConf = inputConfirmed(a);
const bConf = inputConfirmed(b);
if (aConf !== bConf) return aConf ? -1 : 1;
return inputValue(b) - inputValue(a);
});
const selected: HdSpendableUtxo[] = [];
const selected: HdInput[] = [];
let total = 0;
for (const utxo of sorted) {
selected.push(utxo);
total += utxo.value;
for (const input of sorted) {
selected.push(input);
total += inputValue(input);
// Fee for current set, assuming 2 outputs (recipient + change). If we
// can cover target+fee even without change, we'll get a chance to drop
// the change output below.
const feeWithChange = estimateFee(selected.length, 2, feeRate);
if (total >= target + feeWithChange) return { selected, total };
@@ -112,31 +221,38 @@ function selectUtxos(
return null;
}
// Legacy BIP-86-only path: lift `HdSpendableUtxo` into `HdInput` and reuse
// the unified selector so the fee preview matches what `buildHdUnsignedPsbt`
// will actually emit.
function liftBip86(utxos: readonly HdSpendableUtxo[]): HdInput[] {
return utxos.map((utxo) => ({ kind: 'bip86', utxo }));
}
/**
* Estimate the fee for a hypothetical spend without building a PSBT.
*
* Mirrors the input-selection used by `selectUtxos` so the UI fee preview
* matches what the actual transaction will pay. Crucially, this is _not_
* the same as `estimateFee(allUtxos.length, …)` — an HD wallet typically
* has many UTXOs across many addresses, but a real send only consumes the
* minimal set that covers `target + fee`.
* Accepts a mix of BIP-86 and SP UTXOs (callers that have only BIP-86
* UTXOs can pass a plain `HdSpendableUtxo[]` and it'll be lifted).
*
* Returns `0` when:
* - `target` is non-positive,
* - the fee rate is non-positive,
* - the wallet is empty, or
* - the balance is insufficient to cover `target + fee` (the caller
* should branch on `insufficient` separately and not surface a fee).
*/
export function previewHdFee(
utxos: readonly HdSpendableUtxo[],
inputs: readonly HdSpendableUtxo[] | readonly HdInput[],
target: number,
feeRate: number,
): number {
if (!Number.isFinite(target) || target <= 0) return 0;
if (!Number.isFinite(feeRate) || feeRate <= 0) return 0;
if (!utxos.length) return 0;
if (!inputs.length) return 0;
const selection = selectUtxos(utxos, target, feeRate);
const lifted = isHdInputArray(inputs) ? (inputs as readonly HdInput[]) : liftBip86(inputs as readonly HdSpendableUtxo[]);
const selection = selectInputs(lifted, target, feeRate);
if (!selection) return 0;
const { selected, total } = selection;
@@ -146,22 +262,67 @@ export function previewHdFee(
return estimateFee(selected.length, numOutputs, feeRate);
}
function isHdInputArray(
arr: readonly HdSpendableUtxo[] | readonly HdInput[],
): boolean {
if (arr.length === 0) return false;
const first = arr[0] as unknown as { kind?: unknown };
return first && typeof first === 'object' && 'kind' in first;
}
// ---------------------------------------------------------------------------
// PSBT build
// Recipient parsing
// ---------------------------------------------------------------------------
/**
* Build an unsigned P2TR PSBT for the HD wallet.
* Classify a recipient string as either a Bitcoin address or a silent-
* payment address. Returns `null` when the string is neither.
*
* Inputs are chosen by `selectUtxos`. Change (if any) goes to a fresh address
* on the internal chain, derived at `account.changeNode / nextChangeIndex`.
* Mainnet only (silent-payment `tsp1…` testnet addresses are rejected to
* match the wallet's mainnet-only stance).
*/
export function parseHdRecipient(input: string): HdRecipient | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (validateBitcoinAddress(trimmed)) {
return { kind: 'address', address: trimmed };
}
if (isSilentPaymentAddress(trimmed)) {
try {
const decoded = decodeSilentPaymentAddress(trimmed);
if (decoded.network !== 'mainnet') return null;
if (decoded.version !== 0) return null;
return { kind: 'sp', spAddress: trimmed };
} catch {
return null;
}
}
return null;
}
// ---------------------------------------------------------------------------
// PSBT build (legacy BIP-86 path — backwards compatible)
// ---------------------------------------------------------------------------
/**
* Legacy result shape kept for callers that haven't migrated to the new
* `inputDescriptors` field.
*/
export interface HdUnsignedPsbtLegacy {
psbtHex: string;
fee: number;
hasChange: boolean;
changeAddress?: string;
inputDerivations: HdInputDerivation[];
}
/**
* Build an unsigned P2TR PSBT for the HD wallet (BIP-86-only inputs +
* bare-address recipient).
*
* @param account HD account (used to derive change address & re-derive input keys).
* @param ownedUtxos Candidate UTXOs to spend (must include `chain`/`index`).
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis (must be >= dust limit).
* @param feeRate Fee rate in sat/vB.
* @param nextChangeIndex The next unused index on the change chain.
* Preserved for callers that haven't switched to the SP-aware
* {@link buildHdSpendPsbt}. Inputs are chosen by the unified selector;
* change (if any) goes to a fresh address on the internal chain.
*/
export function buildHdUnsignedPsbt(
account: HdAccount,
@@ -170,20 +331,113 @@ export function buildHdUnsignedPsbt(
amountSats: number,
feeRate: number,
nextChangeIndex: number,
): HdUnsignedPsbt {
if (!validateBitcoinAddress(toAddress)) {
throw new Error(`Invalid Bitcoin address: ${toAddress}`);
}
): HdUnsignedPsbtLegacy {
const built = buildHdSpendPsbt({
account,
inputs: liftBip86(ownedUtxos),
recipient: { kind: 'address', address: toAddress },
amountSats,
feeRate,
nextChangeIndex,
});
return {
psbtHex: built.psbtHex,
fee: built.fee,
hasChange: built.hasChange,
changeAddress: built.changeAddress,
inputDerivations: built.inputDescriptors.map((d) => {
if (d.kind !== 'bip86') {
// Should never happen given the input filter above.
throw new Error('Legacy buildHdUnsignedPsbt produced a non-BIP86 input descriptor.');
}
return { chain: d.chain, index: d.index };
}),
};
}
// ---------------------------------------------------------------------------
// PSBT build (SP-aware)
// ---------------------------------------------------------------------------
/** Arguments accepted by {@link buildHdSpendPsbt}. */
export interface BuildHdSpendArgs {
/** HD account — used for change derivation and (for BIP-86 inputs) re-derivation of internal pubkeys. */
account: HdAccount;
/**
* Candidate inputs to spend. Mix of BIP-86 UTXOs and SP UTXOs is supported;
* the coin selector picks the minimum-cost set that covers `amount + fee`.
*/
inputs: readonly HdInput[];
/** Where to send. */
recipient: HdRecipient;
/** Amount in satoshis (must be >= dust limit). */
amountSats: number;
/** Fee rate in sat/vB. */
feeRate: number;
/** Next unused index on the BIP-86 change chain. */
nextChangeIndex: number;
/**
* For SP recipients ONLY: the 32-byte raw Nostr secret key. Required to
* compute the per-recipient `P_k`, because BIP-352 binds the output to
* the tweaked Taproot scalar of every input — which is the private key
* material.
*
* `b_spend` (used to spend SP inputs) is also derived from this seed when
* any input is `kind: 'sp'`.
*
* The buffer is read inside this function and never persisted; callers
* should zero it after the call.
*/
nsecBytes?: Uint8Array;
}
/**
* Build an unsigned PSBT for an SP-aware HD wallet spend.
*
* Handles all four matrix cells:
*
* - BIP-86 inputs → bare-address recipient (legacy path)
* - BIP-86 inputs → silent-payment recipient (BIP-352 sender)
* - SP inputs → bare-address recipient
* - SP inputs → silent-payment recipient
*
* For SP recipients the per-recipient `P_k` is derived locally using the
* tweaked private keys of every selected input. The result is a regular
* P2TR output written into the PSBT; broadcast sees a normal Taproot
* transaction (the silent-payment ECDH happens off-chain).
*
* For SP inputs the output script (`OP_1 push32 x_only(P_k)`) is
* re-derived locally from the stored `t_k` tweak — Blockbook doesn't
* return it directly for SP UTXOs, and we don't want to trust an indexer
* for it anyway.
*/
export function buildHdSpendPsbt(args: BuildHdSpendArgs): HdUnsignedPsbt {
const { account, inputs, recipient, amountSats, feeRate, nextChangeIndex, nsecBytes } = args;
if (!Number.isInteger(amountSats) || amountSats < BITCOIN_DUST_LIMIT) {
throw new Error(`Amount must be at least ${BITCOIN_DUST_LIMIT} sats.`);
}
if (!Number.isFinite(feeRate) || feeRate <= 0) {
throw new Error('Fee rate must be positive.');
}
if (recipient.kind === 'address' && !validateBitcoinAddress(recipient.address)) {
throw new Error(`Invalid Bitcoin address: ${recipient.address}`);
}
const selection = selectUtxos(ownedUtxos, amountSats, feeRate);
// ── Deduplicate inputs by (kind, txid, vout) ─────────────────
const seen = new Set<string>();
const dedup: HdInput[] = [];
for (const i of inputs) {
const id = inputId(i);
if (seen.has(id)) continue;
seen.add(id);
dedup.push(i);
}
// ── Coin selection ───────────────────────────────────────────
const selection = selectInputs(dedup, amountSats, feeRate);
if (!selection) {
const total = ownedUtxos.reduce((s, u) => s + u.value, 0);
const total = dedup.reduce((s, i) => s + inputValue(i), 0);
throw new Error(
`Insufficient funds. Need at least ${amountSats.toLocaleString()} sats + fees, ` +
`have ${total.toLocaleString()} sats spendable.`,
@@ -191,7 +445,7 @@ export function buildHdUnsignedPsbt(
}
const { selected, total: totalInput } = selection;
// Recompute fee for the final selected set.
// ── Fee + change ─────────────────────────────────────────────
const feeWithChange = estimateFee(selected.length, 2, feeRate);
const changeIfKept = totalInput - amountSats - feeWithChange;
const hasChange = changeIfKept >= BITCOIN_DUST_LIMIT;
@@ -207,40 +461,121 @@ export function buildHdUnsignedPsbt(
}
const tx = new btc.Transaction();
const inputDerivations: HdUnsignedPsbt['inputDerivations'] = [];
const inputDescriptors: HdInputDescriptor[] = [];
for (const utxo of selected) {
// Re-derive the input's internal key from the account hierarchy. We do
// not trust the address string for this — the chain/index pair is the
// single source of truth.
const derived = deriveAddress(
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
utxo.chain,
utxo.index,
);
if (derived.address !== utxo.address) {
throw new Error(
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
`expected ${derived.address}, got ${utxo.address}`,
// For SP recipients we need each input's tweaked private key to derive
// the per-recipient output P_k. Compute and stash them up front so we
// can wipe the array at the end.
const spSenderInputs: SpSenderInput[] = [];
const wipeAfterBuild: Uint8Array[] = [];
const needsSpend = selected.some((i) => i.kind === 'sp');
if (needsSpend && !nsecBytes) {
throw new Error('SP UTXOs selected but nsecBytes was not provided.');
}
const bSpend = needsSpend && nsecBytes
? deriveSilentPaymentSpendKey(nsecBytes)
: undefined;
if (bSpend) wipeAfterBuild.push(bSpend);
// ── Build inputs ─────────────────────────────────────────────
for (const input of selected) {
if (input.kind === 'bip86') {
const utxo = input.utxo;
const derived = deriveAddress(
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
utxo.chain,
utxo.index,
);
}
const internalPubkey = hex.decode(derived.internalPubkeyHex);
const payment = btc.p2tr(internalPubkey, undefined, HD_WALLET_NETWORK);
if (derived.address !== utxo.address) {
throw new Error(
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
`expected ${derived.address}, got ${utxo.address}`,
);
}
const internalPubkey = hex.decode(derived.internalPubkeyHex);
const payment = btc.p2tr(internalPubkey, undefined, HD_WALLET_NETWORK);
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: payment.script,
amount: BigInt(utxo.value),
},
tapInternalKey: internalPubkey,
});
inputDerivations.push({ chain: utxo.chain, index: utxo.index });
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: { script: payment.script, amount: BigInt(utxo.value) },
tapInternalKey: internalPubkey,
});
inputDescriptors.push({ kind: 'bip86', chain: utxo.chain, index: utxo.index });
if (recipient.kind === 'sp') {
// BIP-352 sender needs the BIP-341 *tweaked* private key for every
// taproot input. Derive it here so the build is self-contained.
const leaf = deriveLeafPrivateKey(account, utxo.chain, utxo.index);
const tweaked = bip86TweakedPrivateKey(leaf);
leaf.fill(0);
wipeAfterBuild.push(tweaked);
spSenderInputs.push({
txid: utxo.txid,
vout: utxo.vout,
privateKey: tweaked,
isTaproot: true,
});
}
} else {
// SP input
const utxo = input.utxo;
if (!bSpend) {
throw new Error('SP input requires nsecBytes for spending');
}
const tweak = hexToBytes(utxo.tweakHex);
const xonly = deriveSpUtxoXOnly(bSpend, tweak);
const script = spP2trScriptPubKey(xonly);
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: { script, amount: BigInt(utxo.value) },
// We deliberately do NOT set `tapInternalKey` for SP inputs:
// `signIdx` would unconditionally re-tweak with TapTweak. The SP
// signer in `signHdPsbt` writes `tapKeySig` directly instead.
});
inputDescriptors.push({ kind: 'sp', tweakHex: utxo.tweakHex });
if (recipient.kind === 'sp') {
// d_k is also the BIP-352 input scalar — it's already the actual
// signing scalar for the on-chain P_k output, with no further
// BIP-341 tweak to apply. Pass `isTaproot: false` so the sender
// module doesn't re-apply odd-Y negation (which would invert d_k).
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
wipeAfterBuild.push(dk);
spSenderInputs.push({
txid: utxo.txid,
vout: utxo.vout,
privateKey: dk,
isTaproot: false,
});
}
}
}
tx.addOutputAddress(toAddress, BigInt(amountSats), HD_WALLET_NETWORK);
// ── Build recipient output ───────────────────────────────────
let resolvedRecipientAddress: string;
if (recipient.kind === 'address') {
resolvedRecipientAddress = recipient.address;
tx.addOutputAddress(recipient.address, BigInt(amountSats), HD_WALLET_NETWORK);
} else {
if (spSenderInputs.length === 0) {
throw new Error('Silent-payment send needs at least one input.');
}
const outputs = deriveSilentPaymentOutputs(
spSenderInputs,
[{ address: decodeSilentPaymentAddress(recipient.spAddress), raw: recipient.spAddress }],
{ network: 'mainnet' },
);
if (outputs.length !== 1) {
throw new Error('Silent-payment derivation returned unexpected number of outputs.');
}
const out: SpSenderOutput = outputs[0];
resolvedRecipientAddress = out.address;
tx.addOutputAddress(out.address, BigInt(amountSats), HD_WALLET_NETWORK);
}
// ── Optional change ──────────────────────────────────────────
let changeAddress: string | undefined;
if (hasChange) {
const changeDerived = deriveAddress(account.changeNode, CHANGE_CHAIN, nextChangeIndex);
@@ -248,12 +583,16 @@ export function buildHdUnsignedPsbt(
tx.addOutputAddress(changeAddress, BigInt(change), HD_WALLET_NETWORK);
}
// Best-effort wipe of the tweaked-key array.
for (const buf of wipeAfterBuild) buf.fill(0);
return {
psbtHex: txToPsbtHex(tx),
fee,
hasChange,
changeAddress,
inputDerivations,
inputDescriptors,
resolvedRecipientAddress,
};
}
@@ -262,40 +601,84 @@ export function buildHdUnsignedPsbt(
// ---------------------------------------------------------------------------
/**
* Sign every input in a PSBT using its corresponding HD-derived private key.
* Sign every input in a PSBT using its corresponding HD-derived key.
*
* `inputDerivations` MUST be aligned 1:1 with the PSBT's inputs in order.
* (`buildHdUnsignedPsbt` returns them in the right order.)
* For BIP-86 inputs: derives the leaf key from `(chain, index)` and uses
* `signIdx`, which applies the BIP-341 TapTweak before producing a Schnorr
* key-path signature.
*
* Each derived 32-byte leaf private key is passed directly to `signIdx`,
* which detects the `tapInternalKey` on the input and internally applies the
* BIP-341 TapTweak before producing a Schnorr key-path signature.
* For SP inputs: computes `d_k = b_spend + t_k`, hand-signs the input via
* BIP-340 Schnorr (no TapTweak, since `P_k` is already the output key on
* chain), and writes the result directly into `tapKeySig`.
*
* @returns Hex-encoded signed (but not finalised) PSBT.
* `inputDescriptors` MUST be aligned 1:1 with the PSBT's inputs in order.
* `buildHdSpendPsbt` (and the legacy `buildHdUnsignedPsbt`) return them in
* the right order.
*/
export function signHdPsbt(
psbtHex: string,
inputDerivations: ReadonlyArray<{ chain: 0 | 1; index: number }>,
inputDescriptors: ReadonlyArray<HdInputDescriptor | HdInputDerivation>,
account: HdAccount,
nsecBytes?: Uint8Array,
): string {
const tx = psbtFromHex(psbtHex);
if (tx.inputsLength !== inputDerivations.length) {
if (tx.inputsLength !== inputDescriptors.length) {
throw new Error(
`PSBT input count (${tx.inputsLength}) does not match derivations ` +
`length (${inputDerivations.length}).`,
`PSBT input count (${tx.inputsLength}) does not match descriptors length (${inputDescriptors.length}).`,
);
}
// Promote legacy `{chain, index}` entries to the discriminated union.
const normalised: HdInputDescriptor[] = inputDescriptors.map((d) => {
if ('kind' in d) return d;
return { kind: 'bip86', chain: d.chain, index: d.index };
});
const hasSp = normalised.some((d) => d.kind === 'sp');
if (hasSp && !nsecBytes) {
throw new Error('Signing SP inputs requires nsecBytes.');
}
const bSpend = hasSp && nsecBytes ? deriveSilentPaymentSpendKey(nsecBytes) : undefined;
// For SP inputs we need every prevout's `script` + `amount` to compute
// the BIP-341 sighash. Pull them from the PSBT inputs' witnessUtxo.
const prevOutScripts: Uint8Array[] = [];
const prevOutAmounts: bigint[] = [];
for (let i = 0; i < tx.inputsLength; i++) {
const { chain, index } = inputDerivations[i];
const privKey = deriveLeafPrivateKey(account, chain, index);
try {
tx.signIdx(privKey, i);
} finally {
// Best-effort wipe of the local copy.
privKey.fill(0);
const inp = tx.getInput(i);
if (!inp.witnessUtxo) {
throw new Error(`PSBT input ${i} missing witnessUtxo`);
}
prevOutScripts.push(inp.witnessUtxo.script);
prevOutAmounts.push(inp.witnessUtxo.amount);
}
try {
for (let i = 0; i < tx.inputsLength; i++) {
const desc = normalised[i];
if (desc.kind === 'bip86') {
const privKey = deriveLeafPrivateKey(account, desc.chain, desc.index);
try {
tx.signIdx(privKey, i);
} finally {
privKey.fill(0);
}
} else {
if (!bSpend) {
throw new Error('SP input encountered without b_spend (unreachable).');
}
const tweak = hexToBytes(desc.tweakHex);
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
try {
signSpUtxoInput(tx, i, dk, prevOutScripts, prevOutAmounts);
} finally {
dk.fill(0);
}
}
}
} finally {
if (bSpend) bSpend.fill(0);
}
return txToPsbtHex(tx);
@@ -312,6 +695,9 @@ export function finalizeHdPsbt(psbtHex: string): string {
/**
* Convenience: build → sign → finalise in one call.
*
* @deprecated Prefer the explicit {@link buildHdSpendPsbt} +
* {@link signHdPsbt} pair for SP-aware flows.
*/
export function createHdTransaction(
account: HdAccount,
@@ -335,8 +721,12 @@ export function createHdTransaction(
* Compute the maximum amount sendable to a single recipient if every owned
* UTXO is consumed and no change is produced.
*/
export function maxHdSendable(utxos: readonly HdSpendableUtxo[], feeRate: number): number {
const total = utxos.reduce((s, u) => s + u.value, 0);
const fee = estimateFee(utxos.length, 1, feeRate);
export function maxHdSendable(
utxos: readonly HdSpendableUtxo[] | readonly HdInput[],
feeRate: number,
): number {
const lifted = isHdInputArray(utxos) ? (utxos as readonly HdInput[]) : liftBip86(utxos as readonly HdSpendableUtxo[]);
const total = lifted.reduce((s, i) => s + inputValue(i), 0);
const fee = estimateFee(lifted.length, 1, feeRate);
return Math.max(0, total - fee);
}
+60
View File
@@ -0,0 +1,60 @@
[
{
"comment": "Single recipient: taproot only inputs with even y-values",
"sending": [
{
"vin": [
{
"txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16",
"vout": 0,
"private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1",
"scriptPubKey": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5"
},
{
"txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d",
"vout": 0,
"private_key": "fc8716a97a48ba9a05a98ae47b5cd201a25a7fd5d8b73c203c5f7b6b6b3b6ad7",
"scriptPubKey": "5120782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338"
}
],
"recipients": [
"sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"
],
"expected_output_permutations": [
[
"de88bea8e7ffc9ce1af30d1132f910323c505185aec8eae361670421e749a1fb"
]
]
}
]
},
{
"comment": "Single recipient: taproot only with mixed even/odd y-values",
"sending": [
{
"vin": [
{
"txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16",
"vout": 0,
"private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1",
"scriptPubKey": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5"
},
{
"txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d",
"vout": 0,
"private_key": "1d37787c2b7116ee983e9f9c13269df29091b391c04db94239e0d2bc2182c3bf",
"scriptPubKey": "51208c8d23d4764feffcd5e72e380802540fa0f88e3d62ad5e0b47955f74d7b283c4"
}
],
"recipients": [
"sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"
],
"expected_output_permutations": [
[
"77cab7dd12b10259ee82c6ea4b509774e33e7078e7138f568092241bf26b99f1"
]
]
}
]
}
]