aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 04:14:07 -0700
committerAdam Soltys <[email protected]>2026-06-28 04:14:07 -0700
commit76daca707a7b9cb9aafab1ceb8999ec3c708a63d (patch)
tree461014db1815482c101561a316f0df50bb957c91 /src
parenta6e6083e4c758a9c07dc7a4ea38f880c561cb365 (diff)
downloadcoinosv3-76daca707a7b9cb9aafab1ceb8999ec3c708a63d.tar.gz
coinosv3-76daca707a7b9cb9aafab1ceb8999ec3c708a63d.zip
Spend SP coins like any coin — confirmed-only, and able to fund SP payments
Two things: - Only confirmed SP coins enter the spend pool. Unconfirmed ones are excluded (the sender's tx could be replaced, invalidating our spend) — matching the spendable headline, which already counted confirmed-only. - SP→SP sending: our own silent-payment coins can now fund a payment TO an sp1 address (previously excluded). _applySilentPayments includes SP inputs with their key d = spend_priv + t_k, and silentPaymentScripts negates a taproot input's key when it gives odd Y, per BIP-352 (so the sender's A = a·G matches what the indexer reconstructs). signing was already SP-aware. Validated: BIP-352 sending vectors pass for all clean key-path-taproot-input cases (the remaining vector failures are NUMS script-path / labels / uncompressed keys — input types the wallet never spends). End-to-end on regtest: an SP coin funds a payment to another sp address, testmempoolaccept passes, and the recipient scans and finds it. Note: silentPaymentScripts derives input_hash from the eligible inputs it's given, which is correct because the wallet only ever spends all-eligible coins (P2WPKH + key-path SP); it isn't a general-purpose sender for txs with ineligible inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/silentpay.js15
-rw-r--r--src/wallet.js28
2 files changed, 29 insertions, 14 deletions
diff --git a/src/silentpay.js b/src/silentpay.js
index 1732ced..eeabc1a 100644
--- a/src/silentpay.js
+++ b/src/silentpay.js
@@ -69,17 +69,24 @@ export function decodeSilentPaymentAddress(addr) {
// Derive the one-time taproot output scripts for a set of silent-payment outputs
// that this transaction's inputs are funding.
-// inputs: [{ txid (display hex), vout, priv (Uint8Array 32) }] — ALL eligible
-// (P2WPKH) inputs of the tx; the derivation commits to every one.
+// inputs: [{ txid (display hex), vout, priv (Uint8Array 32), taproot? }] — ALL
+// eligible inputs of the tx; the derivation commits to every one. Set
+// taproot:true for P2TR inputs (e.g. our own silent-payment coins) so
+// their key is taken with even Y, per BIP-352.
// outputs: [{ scan, spend }] — one entry per desired output (repeat the same
// recipient to pay it multiple times; the k counter is handled here).
// Returns a taproot scriptPubKey (Uint8Array) for each entry, in order.
export function silentPaymentScripts(inputs, outputs) {
if (!inputs.length) throw new Error('Silent payment needs at least one input.');
- // a = Σ input private keys (mod n); A = a·G
+ // a = Σ input private keys (mod n); A = a·G. A taproot input contributes the
+ // key for its even-Y x-only pubkey, so negate when the key gives odd Y.
let a = 0n;
- for (const i of inputs) a = modN(a + toInt(i.priv));
+ for (const i of inputs) {
+ let d = toInt(i.priv);
+ if (i.taproot && Number(G.multiply(d).toRawBytes(true)[0]) === 0x03) d = modN(-d);
+ a = modN(a + d);
+ }
if (a === 0n) throw new Error('Silent payment input key sum is zero.');
const A = G.multiply(a);
diff --git a/src/wallet.js b/src/wallet.js
index 5943d86..dbb9b73 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -1177,12 +1177,11 @@ export class Wallet {
// signed by sign() with the per-output key. Excluded when paying an sp1…
// recipient (funding an SP send from a taproot input needs BIP-352 even-Y
// key handling we don't do yet), so those sends use BIP84 coins.
- const payingSp = recipients.some((r) => isSilentPaymentAddress(r.address));
- const spPool = payingSp
- ? []
- : coinIds
- ? this.spUtxos.filter((u) => coinIds.includes(utxoId(u)))
- : this.spUtxos.slice();
+ // Confirmed SP coins are spendable like any other coin — including to fund a
+ // silent payment. Unconfirmed ones are excluded (the sender's tx could be
+ // replaced, invalidating our spend).
+ const confirmedSp = this.spUtxos.filter((u) => u.confirmed);
+ const spPool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp.slice();
if (!pool_.length && !spPool.length) throw new Error('No spendable coins selected.');
const inputs = [
@@ -1287,13 +1286,22 @@ export class Wallet {
// actual inputs per BIP-352.
_applySilentPayments(tx, spOuts) {
const byOutpoint = new Map();
- for (const u of this.utxos) byOutpoint.set(utxoId(u), u);
+ for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false });
+ for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true });
+ const spKeys = this.silentPaymentKeys();
const inputs = [];
for (let i = 0; i < tx.inputsLength; i++) {
const inp = tx.getInput(i);
- const u = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`);
- if (!u) throw new Error('Missing key for a silent payment input.');
- inputs.push({ txid: u.txid, vout: u.vout, priv: this.node(u.chain, u.index).privateKey });
+ const e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`);
+ if (!e) throw new Error('Missing key for a silent payment input.');
+ // Our own SP coins can fund a silent payment too — they're taproot inputs
+ // whose key is d = spend_priv + t_k (committed to with even Y by the script).
+ if (e.sp) {
+ if (!spKeys) throw new Error('Missing keys for a silent payment input.');
+ inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true });
+ } else {
+ inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey });
+ }
}
const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend })));
spOuts.forEach((s, k) => {