diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 13:38:08 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 13:38:08 -0700 |
| commit | 7234a3de9e200f36accb754f62163f0bd5360832 (patch) | |
| tree | f30b492cd8938503512a2f565a7d4a9b396afff1 /src/wallet.js | |
| parent | ffb5c30b54f913746cd6f9f79166cc4d0842dc78 (diff) | |
| download | coinosv3-7234a3de9e200f36accb754f62163f0bd5360832.tar.gz coinosv3-7234a3de9e200f36accb754f62163f0bd5360832.zip | |
Replace seed gift links with presigned PSBT claim links
Gifting no longer shares a seed. "Gift some Bitcoin" (Settings) presigns
a chosen amount as a #gift= link: one SIGHASH_SINGLE input (the sender's
change output is cryptographically fixed, so a front-runner can only take
the gift portion, never the change) plus the rest left open. The coin is
reserved per-wallet so normal sends skip it; reclaim it anytime.
Opening the link generates a brand-new wallet the sender never sees,
shows the amount, and on Claim adds the claimer's address as the output,
finalizes, and broadcasts — then prompts backup of the fresh seed. This
gives forward secrecy (sender locked out once confirmed), one-shot
semantics (claiming spends the input), and keeps funds in the sender's
wallet until claimed.
Crypto verified end-to-end (sign NONE/SINGLE -> add output -> finalize ->
valid tx; deterministic-signature equality proves the presigned sig is
valid for any output the claimer picks). Strings retranslated across all
19 locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
| -rw-r--r-- | src/wallet.js | 108 |
1 files changed, 91 insertions, 17 deletions
diff --git a/src/wallet.js b/src/wallet.js index 08f5d9e..06af3dc 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -9,7 +9,7 @@ // transactions, only the (txid, vout, value, address) of each UTXO. import { HDKey } from '@scure/bip32'; -import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, mnemonicToEntropy, entropyToMnemonic } from '@scure/bip39'; +import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hex, base64urlnopad } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; @@ -120,6 +120,7 @@ export class Wallet { this.api.offline = offline; this._account = null; this._addrCache.clear(); + this._reserved = null; // gift-reserved outpoints, lazy-loaded per wallet this._savedAt = 0; try { if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase); @@ -577,7 +578,7 @@ export class Wallet { buildTx({ recipients, feeRate, coinIds = null, sendMax = false }) { const pool_ = coinIds ? this.utxos.filter((u) => coinIds.includes(utxoId(u))) - : this.utxos.slice(); + : this.utxos.filter((u) => !this.isReserved(utxoId(u))); // skip coins set aside for gifts if (!pool_.length) throw new Error('No spendable coins selected.'); const inputs = pool_.map((u) => { @@ -758,6 +759,72 @@ export class Wallet { return { hex: t.hex, txid: t.id, fee: pl.fee, oldFee: prep.oldFee, feeRate: pl.rate, outputs, replaces: prep.txid }; } + // --- gift links (coins set aside as claimable presigned transactions) ---- + // Outpoints reserved for unclaimed gifts are persisted per-wallet and skipped + // by normal coin selection so a send can't spend a gift out from under it. + _giftKey() { + return this._cacheKey() + ':gift'; + } + reservedSet() { + if (!this._reserved) { + try { + this._reserved = new Set(JSON.parse(localStorage.getItem(this._giftKey()) || '[]')); + } catch { + this._reserved = new Set(); + } + } + return this._reserved; + } + isReserved(id) { + return this.reservedSet().has(id); + } + _saveReserved() { + try { + localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()])); + } catch {} + } + unreserve(id) { + this.reservedSet().delete(id); + this._saveReserved(); + } + // Active gifts whose coin is still unspent (drops claimed/spent ones). + activeGifts() { + const live = new Set(this.utxos.map((u) => utxoId(u))); + let changed = false; + for (const id of [...this.reservedSet()]) if (!live.has(id)) { this.reservedSet().delete(id); changed = true; } + if (changed) this._saveReserved(); + return [...this.reservedSet()]; + } + + // Build a SIGHASH_SINGLE-signed gift: input = a single coin, output0 = change + // back to us (cryptographically fixed), leaving the rest for the claimer to + // direct to their own fresh address. Reserves the coin. Returns { code, ... }. + createGift(amountSats, feeRate) { + const gift = BigInt(Math.round(amountSats)); + if (gift < 546n) throw new Error('Gift amount is too small.'); + const rate = Math.max(1, Math.round(feeRate)); + const claimFee = BigInt(Math.ceil((11 + 68 + 31 * 2) * rate)); // claimer's 1-in 2-out tx + const DUST = 294n; + const need = gift + claimFee + DUST; + const coin = this.utxos + .filter((u) => u.confirmed && !this.isReserved(utxoId(u))) + .sort((a, b) => a.value - b.value) + .find((u) => BigInt(u.value) >= need); + if (!coin) throw new Error('No single confirmed coin is large enough for that amount.'); + + const V = BigInt(coin.value); + const change = V - gift - claimFee; // back to us, >= DUST by construction + const pay = p2wpkh(this.derive(coin.chain, coin.index).pubkey, this.netCfg.net); + const t = new btc.Transaction({ allowUnknownOutputs: true }); + t.addInput({ ...pay, txid: coin.txid, index: coin.vout, sighashType: btc.SigHash.SINGLE, witnessUtxo: { script: pay.script, amount: V } }); + t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); + t.signIdx(this.node(coin.chain, coin.index).privateKey, 0, [btc.SigHash.SINGLE]); + + this.reservedSet().add(utxoId(coin)); + this._saveReserved(); + return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(gift), reserved: utxoId(coin) }; + } + // --- realtime (mempool.space WebSocket) --------------------------------- // Pushes us new mempool/confirmed transactions for our addresses so history // and balances update with no polling. @@ -1185,27 +1252,34 @@ export function utxoId(u) { return `${u.txid}:${u.vout}`; } -// Gift links: encode a wallet (12-word seed as BIP39 entropy, plus the optional -// passphrase) compactly for the #claim= fragment as base64url, and decode it -// back to { mnemonic, passphrase }. Returns null if the code isn't valid. -export function encodeGift(mnemonic, passphrase = '') { - let code = base64urlnopad.encode(mnemonicToEntropy(mnemonic.trim().replace(/\s+/g, ' '), wordlist)); - if (passphrase) code += '~' + base64urlnopad.encode(new TextEncoder().encode(passphrase)); - return code; -} - -export function decodeGift(code) { +// Gift-link claiming (sender side is Wallet.createGift). A gift code is a +// base64url PSBT: one SIGHASH_SINGLE-signed input + the sender's change output. +// previewGift reports the room available to the claimer (before their fee); +// buildClaimTx adds the claimer's output and finalizes a broadcastable tx. +export function previewGift(code) { try { - const [seedPart, passPart] = code.split('~'); - const mnemonic = entropyToMnemonic(base64urlnopad.decode(seedPart), wordlist); - if (!validateMnemonic(mnemonic, wordlist)) return null; - const passphrase = passPart ? new TextDecoder().decode(base64urlnopad.decode(passPart)) : ''; - return { mnemonic, passphrase }; + const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); + const inAmt = Number(tx.getInput(0).witnessUtxo.amount); + const change = Number(tx.getOutput(0).amount); + return { room: inAmt - change }; // claimer gets room − their chosen fee } catch { return null; } } +export function buildClaimTx(code, toAddress, feeRate, net) { + const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); + const inAmt = tx.getInput(0).witnessUtxo.amount; + const change = tx.getOutput(0).amount; + const room = inAmt - change; + const fee = BigInt(Math.max(1, Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(feeRate))))); + const out = room - fee; + if (out < 294n) throw new Error('Gift is too small to claim at this fee rate.'); + tx.addOutputAddress(toAddress, out, net); + tx.finalize(); + return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; +} + function firstUnused(chain) { const u = chain.find((a) => !a.used); return u ? u.index : chain.length; |
