diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 09:33:52 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 09:33:52 -0700 |
| commit | b7c5abd93868b97c25f52610510544764a3e44eb (patch) | |
| tree | ab2ae94ab10bb88d32dec24c9987f81ef3c7faf0 | |
| parent | 11b2a56a320bbd66b98991fd08478d154ff6c717 (diff) | |
| download | coinosv3-b7c5abd93868b97c25f52610510544764a3e44eb.tar.gz coinosv3-b7c5abd93868b97c25f52610510544764a3e44eb.zip | |
Features phase 2: gift/swap/ark primitives leave the core wallet
wallet.js drops from 2,762 to 2,374 lines and now exposes two generic
seams instead of feature code:
- registerCoinLock()/lockedCoinIds(): features contribute coin ids that
spending and the balance treat as unavailable (gift reservations).
- loadFeatureState()/saveFeatureState(): namespaced per-wallet persisted
state.
src/features/gifts-wallet.js takes the whole gift engine — reservation
sets, gift records, coin selection, the SIGHASH_SINGLE/NONE PSBT
builder, the compact codec, claim building, nostr-locked gifts — as an
installGiftWallet(wallet) instance install plus standalone exports.
Swap storage/key helpers (loadSwaps/saveSwaps/swapNode/nextSwapIndex)
install from swap.js; ark state storage installs from the ark feature.
The silent-payment scan worker only embeds when the sp feature ships
(wallet.js already tolerated its absence).
Minimal profile: 412KB, down from 476KB (full: 578KB). All suites pass:
ark UI e2e, ArkManager crash-resume, gift lifecycle, minimal smoke.
SP's engine (scanning, BIP-352 signing) stays in wallet.js — extracting
it safely wants an SP e2e harness on this stack first.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
| -rw-r--r-- | build.js | 7 | ||||
| -rw-r--r-- | src/features/ark.js | 19 | ||||
| -rw-r--r-- | src/features/gifts-wallet.js | 434 | ||||
| -rw-r--r-- | src/features/gifts.js | 6 | ||||
| -rw-r--r-- | src/features/swaps.js | 3 | ||||
| -rw-r--r-- | src/swap.js | 38 | ||||
| -rw-r--r-- | src/wallet.js | 450 | ||||
| -rw-r--r-- | tools/ark/gift-shot.js | 1 |
8 files changed, 527 insertions, 431 deletions
@@ -177,7 +177,10 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces let js = await result.outputs[0].text(); // Guard against a literal </script> inside the bundle closing our tag early. js = js.replaceAll('</script', '<\\/script'); - const workerB64 = Buffer.from(await buildSpWorker({ minify })).toString('base64'); + // The silent-payment scan worker only ships when the sp feature does. + const workerB64 = enabledFeatures(features).includes('sp') + ? Buffer.from(await buildSpWorker({ minify })).toString('base64') + : ''; const css = await Bun.file('./src/style.css').text(); return `<!doctype html> @@ -193,7 +196,7 @@ ${pwa ? PWA_HEAD : ''}<style>${css}</style> </head> <body> <div id="app"></div> -<script>globalThis.__SP_WORKER__=${JSON.stringify(workerB64)}</script> +${workerB64 ? `<script>globalThis.__SP_WORKER__=${JSON.stringify(workerB64)}</script>` : ''} <script>${js}</script> ${pwa ? SW_REGISTER + '\n' : ''}</body> </html>`; diff --git a/src/features/ark.js b/src/features/ark.js index 24acfea..868192a 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -11,8 +11,27 @@ import { shortAddr, shortTxid, timeAgo } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } +// Wallet storage/key helpers for this feature, installed onto the core +// wallet instance so a build without the feature ships none of it. +export function installArkWallet(wallet) { + if (wallet.loadArkState) return; // already installed + Object.assign(wallet, { + // ---- Ark support ------------------------------------------------------- + // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement + // history — no secrets: vtxo keys are re-derived from the seed). + _arkKey() { return this._cacheKey() + ':ark'; }, + loadArkState() { + try { return JSON.parse(localStorage.getItem(this._arkKey()) || 'null'); } catch { return null; } + }, + saveArkState(state) { + try { localStorage.setItem(this._arkKey(), JSON.stringify(state)); } catch {} + }, + }); +} + export function arkFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; + installArkWallet(wallet); // ark state storage lives outside the core wallet // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js new file mode 100644 index 0000000..52de0d0 --- /dev/null +++ b/src/features/gifts-wallet.js @@ -0,0 +1,434 @@ +// Gift wallet primitives — coin reservation, gift creation (incl. the +// split-first flow), the compact gift codec, claiming, and nostr-locked +// gifts. Installed onto the Wallet instance by the gifts feature so a build +// without gifts ships none of this. + +import * as btc from '@scure/btc-signer'; +import { hex, base64urlnopad, base32nopad } from '@scure/base'; +import { utxoId } from '../wallet.js'; +import { encryptGiftPayload } from '../nostr.js'; +import { p2wpkh } from '@scure/btc-signer/payment'; +import { concatBytes } from '@noble/hashes/utils'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + +export function installGiftWallet(wallet) { + if (wallet.createGift) return; // already installed + Object.assign(wallet, { + // Confirmed coins locked behind unclaimed gift links. The whole source coin is + // committed until the gift is claimed (our change only comes back in the + // claimer's tx), so it's already excluded from spending — and from the + // spendable balance below. + giftLockedValue() { + const res = this.reservedSet(); + return this.utxos.reduce((s, u) => s + (res.has(utxoId(u)) ? u.value : 0), 0); + }, + + // --- gift links (coins backing claimable presigned transactions) --------- + // A gift coin is either RESERVED (set aside, skipped by coin selection) or + // RECLAIMED (freed for spending, but its link stays live until the coin is + // actually spent). Both are "outstanding"; both are persisted per-wallet. + _giftKey() { + return this._cacheKey() + ':gift'; + }, + + _reclaimedKey() { + return this._cacheKey() + ':giftfree'; + }, + + reservedSet() { + if (!this._reserved) this._reserved = this._loadSet(this._giftKey()); + return this._reserved; + }, + + reclaimedSet() { + if (!this._reclaimed) this._reclaimed = this._loadSet(this._reclaimedKey()); + return this._reclaimed; + }, + + _saveReserved() { + try { localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()])); } catch {} + }, + + _saveReclaimed() { + try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {} + }, + + isReserved(id) { + return this.reservedSet().has(id); + }, + + // Passive reclaim: free the coin for spending but keep tracking the live link + // so it can still be revoked later (until the coin is spent). + unreserve(id) { + this.reservedSet().delete(id); + this.reclaimedSet().add(id); + this._saveReserved(); + this._saveReclaimed(); + }, + + // Truly revoke an unclaimed gift: spend its coin back into our own wallet, + // which double-spends the gift's presigned input so the link can't be claimed + // once this confirms. Uses a high fee to win any race with a claimer. + async revokeGift(id, feeRate) { + const draft = this.buildTx({ recipients: [{ address: this.freshChange().address }], feeRate, coinIds: [id], sendMax: true }); + const hexTx = this.sign(draft.tx); + const txid = await this.broadcast(hexTx); + this.reservedSet().delete(id); + this.reclaimedSet().delete(id); + this._saveReserved(); + this._saveReclaimed(); + return txid; + }, + + giftLink(id) { return this.giftRecords()[id] || null; }, + + markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } }, + + giftRecords() { + if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } } + return this._giftRecords; + }, + + _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} }, + + // Remember which tx claimed a gift (resolved lazily via outspend) so the + // history can merge the gift row with its claim transaction. + setGiftClaimTx(id, txid) { + const recs = this.giftRecords(); + if (recs[id] && recs[id].claimTxid !== txid) { + recs[id].claimTxid = txid; + this._saveGiftRecords(); + } + }, + + // Outstanding gifts (reserved + reclaimed) whose coin is still unspent; prunes + // any whose coin has since been claimed/spent. { id, reserved, value }. + outstandingGifts() { + const live = new Set(this.utxos.map((u) => utxoId(u))); + const res = this.reservedSet(); + const rec = this.reclaimedSet(); + let changed = false; + for (const id of [...res]) if (!live.has(id)) { res.delete(id); changed = true; } + for (const id of [...rec]) if (!live.has(id)) { rec.delete(id); changed = true; } + if (changed) { this._saveReserved(); this._saveReclaimed(); } + return [...new Set([...res, ...rec])].map((id) => ({ + id, + reserved: res.has(id), + value: (this.utxos.find((u) => utxoId(u) === id) || {}).value, + })); + }, + + // Gifts whose coin has been spent and weren't revoked by us → claimed. (Only + // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.) + claimedGifts() { + if (!this.loaded) return []; + const live = new Set(this.utxos.map((u) => utxoId(u))); + return Object.values(this.giftRecords()) + .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o))) + .map((r) => ({ id: r.id, amount: r.amount, locked: r.locked, created: r.created, claimTxid: r.claimTxid || null, outpoints: r.outpoints })) + .sort((a, b) => (b.created || 0) - (a.created || 0)); + }, + + // Best-fit coin selection for a gift of `gift` sats (needs gift + a dust + // change): the smallest single confirmed coin that covers it — one input and + // the least value locked until claim — else the fewest large coins. Returns + // the selected UTXOs, or null if confirmed funds are insufficient. + _selectGiftCoins(gift) { + const DUST = 294n; + const need = gift + DUST; + const avail = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); + const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0]; + if (single) return [single]; + const sel = []; + let sum = 0n; + for (const u of [...avail].sort((a, b) => b.value - a.value)) { + sel.push(u); + sum += BigInt(u.value); + if (sum >= need) return sel; + } + return null; // insufficient confirmed funds + }, + + // Build a gift link from a best-fit coin, locking the least value. No fee is + // reserved here: the claimer's wallet looks up the fee rate at claim time and + // subtracts it from the amount. + createGift(amountSats, feeRate) { + const gift = BigInt(Math.round(amountSats)); + const rate = Math.max(1, Math.round(feeRate)); + if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); + const sel = this._selectGiftCoins(gift); + if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); + return this._buildGiftPsbt(sel, gift); + }, + + // Gift the entire spendable balance: a no-change sweep of all unreserved + // spendable coins (confirmed, plus our own unconfirmed change). The claimer + // receives the whole amount minus their claim fee. + createGiftAll(feeRate) { + const rate = Math.max(1, Math.round(feeRate)); + const sel = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); + if (!sel.length) throw new Error('No coins available to gift.'); + const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); + if (sum < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); + return this._buildGiftPsbt(sel, sum); // change = 0 → no-change sweep + }, + + // Split + gift in one shot, with no confirmation wait: broadcast a self-send + // that carves out a gift+dust coin, then build the gift spending that + // still-unconfirmed carve-out. The funding and the claim form a chain that + // confirms together; the recipient just sees a pending balance until the + // split confirms. Lets the sender lock only ~the gift amount (instead of a + // whole large coin) without waiting for the split to confirm first. + async createGiftFromSplit(amountSats, feeRate) { + const gift = Math.round(amountSats); + const rate = Math.max(1, Math.round(feeRate)); + if (BigInt(gift) < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); + const DUST = 294; + const recvIndex = this.nextReceiveIndex; + const carveAddr = this.derive(0, recvIndex).address; + const changeIndex = this.nextChangeIndex; + + // Carve out exactly gift + dust to one of our own addresses; the rest comes + // back as change. buildTx already spends confirmed coins largest-first. + const draft = this.buildTx({ recipients: [{ address: carveAddr, amount: gift + DUST }], feeRate: rate }); + const carveIdx = draft.outputs.findIndex((o) => o.address === carveAddr); + if (carveIdx < 0) throw new Error('Could not size the split output.'); + const hexTx = this.sign(draft.tx); + const splitTxid = draft.tx.id; + await this.broadcast(hexTx); + + // Wait until the carve-out is actually visible in the mempool before we + // build the gift on top of it and reserve it. Otherwise a background refresh + // could rebuild the address from a not-yet-indexed API response, transiently + // drop the coin, and prune the reservation — risking a double-spend. + let seen = false; + for (let i = 0; i < 8 && !seen; i++) { + await sleep(i === 0 ? 400 : 900); + try { + const us = await this.api.addressUtxos(carveAddr); + seen = us.some((u) => u.txid === splitTxid && u.vout === carveIdx); + } catch {} + } + if (!seen) throw new Error('The split is taking a while to propagate. Once it confirms, create the gift from the new coin.'); + + // Reflect the split in our coin set — drop the spent inputs, add the + // (now mempool-visible) carve-out and change — so balances and gift tracking + // are correct immediately; a background scan reconciles the rest. + const spent = new Set(); + for (let i = 0; i < draft.tx.inputsLength; i++) { + const inp = draft.tx.getInput(i); + spent.add(`${hex.encode(inp.txid)}:${inp.index}`); + } + this.utxos = this.utxos.filter((u) => !spent.has(utxoId(u))); + this.utxos.push({ txid: splitTxid, vout: carveIdx, value: gift + DUST, address: carveAddr, chain: 0, index: recvIndex, confirmed: false }); + const changeIdx = draft.outputs.findIndex((o, i) => i !== carveIdx); + if (changeIdx >= 0) { + const co = draft.outputs[changeIdx]; + this.utxos.push({ txid: splitTxid, vout: changeIdx, value: co.amount, address: co.address, chain: 1, index: changeIndex, confirmed: false }); + } + this._recomputeBalanceFromUtxos(); + + return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift)); + }, + + // The coins a gift of this amount would lock: total value + how many inputs. + // A high input count means a costly-to-claim gift (each input adds to the + // claimer's fee) — the UI uses it to recommend splitting/consolidating first. + giftCoinSummary(amountSats) { + const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); + return sel ? { lock: sel.reduce((s, u) => s + u.value, 0), count: sel.length } : null; + }, + + + // Persistent gift records (per account): the shareable link/code + amount, so a + // gift's link/QR can be re-viewed later and a claimed one identified in history. + _giftRecordsKey() { return this._cacheKey() + ':giftrec'; }, + + recordGift({ code, locked, amount, claimCode, outpoints }) { + if (!outpoints || !outpoints.length) return; + this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false }; + this._saveGiftRecords(); + }, + + // Sats that creating this gift right now would lock (the selected coins' + // total), or null if confirmed funds are insufficient. Lets the UI warn when + // the lock would dwarf the gift and offer to split a coin first. + giftLockPreview(amountSats) { + const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); + return sel ? sel.reduce((s, u) => s + u.value, 0) : null; + }, + + // Build a signed gift from already-chosen coins. If the gift leaves a viable + // (>= dust) change, output0 is that change back to us, cryptographically fixed + // by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is + // locked while the claimer is free to add their own output. If there's no + // viable change (a whole-balance gift), there's no output at all and every + // input is SIGHASH_NONE, so the claimer sweeps the entire amount. Reserves the + // coins; returns { code, amount, reserved } where amount is what the claimer + // receives before their fee (the gift, or the whole swept sum). + _buildGiftPsbt(sel, gift) { + const DUST = 294n; + const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); + const change = sum - gift; + const keepChange = change >= DUST; + const sigOf = (i) => (keepChange && i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE); + const t = new btc.Transaction({ allowUnknownOutputs: true }); + sel.forEach((u, i) => { + const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net); + t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: sigOf(i), witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); + }); + if (keepChange) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); + sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [sigOf(i)])); + + for (const u of sel) this.reservedSet().add(utxoId(u)); + this._saveReserved(); + return { code: base32nopad.encode(_encodeCompactGift(t)), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) }; + }, + }); + // reserved gift coins are excluded from spending + the spendable balance + wallet.registerCoinLock(() => wallet.reservedSet()); +} + +// 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. +// Smallest sensible gift at a given fee rate: dust + one claim fee of headroom +// (floored at 546 sats), so the recipient clears dust even if fees climb. +export function giftMinimum(feeRate) { + const rate = Math.max(1, Math.round(feeRate)); + const claimFee = Math.ceil((11 + 68 + 31 * 2) * rate); + return Math.max(546, 294 + claimFee); +} + +// ---- compact gift codec ---------------------------------------------------- +// A gift link used to carry a full PSBT (verbose). The claimer only needs the +// presigned inputs (outpoint, amount, pubkey, signature) and the sender's +// committed change output, so we serialize just those — ~25% smaller, and (via +// base32 + an uppercase /g/ URL) it scans in the QR's denser alphanumeric mode. +// Legacy base64url-PSBT links still claim; _giftTx auto-detects the format. +const GIFT_MAGIC = 0x01; +const _u8 = (n) => Uint8Array.of(n & 0xff); +const _le32 = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }; +const _le64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; + +function _encodeCompactGift(tx) { + const hasChange = tx.outputsLength > 0; + const parts = [_u8(GIFT_MAGIC), _u8(hasChange ? 1 : 0), _u8(tx.inputsLength)]; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + const [pubkey, sig] = inp.partialSig[0]; // pubkey(33), sig = DER + sighash byte + parts.push(inp.txid, _le32(inp.index), _le64(inp.witnessUtxo.amount), pubkey, _u8(sig.length), sig); + } + if (hasChange) { + const o = tx.getOutput(0); // p2wpkh change: script = [00 14 <20-byte hash>] + parts.push(_le64(o.amount), o.script.slice(2)); + } + return concatBytes(...parts); +} + +function _decodeCompactGift(bytes) { + let p = 0; + const rd = (n) => bytes.slice(p, (p += n)); + const r32 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0, true); p += 4; return v; }; + const r64 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 8).getBigUint64(0, true); p += 8; return v; }; + p++; // magic + const hasChange = !!(bytes[p++] & 1); + const n = bytes[p++]; + const t = new btc.Transaction({ allowUnknownOutputs: true }); + for (let i = 0; i < n; i++) { + const txid = rd(32), vout = r32(), amount = r64(), pubkey = rd(33), sigLen = bytes[p++], sig = rd(sigLen); + const pay = p2wpkh(pubkey, btc.NETWORK); // witness-program .script is network-independent + // second arg true: re-add an already-signed input without tripping the sign-status guard + t.addInput({ txid, index: vout, witnessUtxo: { script: pay.script, amount }, sighashType: sig[sig.length - 1], partialSig: [[pubkey, sig]] }, true); + } + if (hasChange) { + const amount = r64(); + const hash = rd(20); + t.addOutput({ script: concatBytes(_u8(0x00), _u8(0x14), hash), amount }, true); + } + return t; +} + +// Parse a gift code to a Transaction. New format: base32(compact). Legacy: +// base64url(PSBT). Auto-detected via the compact magic byte. +function _giftTx(code) { + try { + const b = base32nopad.decode(code.toUpperCase()); + if (b[0] === GIFT_MAGIC) return _decodeCompactGift(b); + } catch {} + return btc.Transaction.fromPSBT(base64urlnopad.decode(code)); +} + +function _sumInputs(tx) { + let inAmt = 0n; + for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount; + return inAmt; +} +// The sender's change committed in a gift PSBT, or 0 for a no-change (whole- +// balance) gift that has no outputs at all. +function _giftChange(tx) { + return tx.outputsLength > 0 ? tx.getOutput(0).amount : 0n; +} +// The gift's funding outpoints (the coins its presigned inputs spend). If any is +// already spent — even by an unconfirmed claim — the gift has been taken and +// re-claiming would be a doomed double-spend. Used to gate the claim screen. +export function giftOutpoints(code) { + try { + const tx = _giftTx(code); + const out = []; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + out.push({ txid: hex.encode(inp.txid), vout: inp.index }); + } + return out; + } catch { + return []; + } +} +export function previewGift(code) { + try { + const tx = _giftTx(code); + // room is the full amount the claimer receives (inputs minus our change); + // the claim fee is subtracted from it at claim time, so report inputs too + // so the caller can size that fee for this PSBT. + return { room: Number(_sumInputs(tx) - _giftChange(tx)), inputs: tx.inputsLength }; + } catch { + return null; + } +} + +export function buildClaimTx(code, toAddress, feeRate, net) { + const tx = _giftTx(code); + const room = _sumInputs(tx) - _giftChange(tx); + const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 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) }; +} + +// A gift locked to a recipient's nostr key. The amount + recipient pubkey are +// public (anyone can see what it is and who it's for), but the claim payload (the +// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the +// link can be shared openly yet only the recipient's nostr key can claim it. +const LOCKED_GIFT_VERSION = 1; +// Encrypt the gift's claim payload under a one-time code. The code is delivered +// to the recipient via a nostr DM; the public link carries only the ciphertext. +// Returns { blob (for the /lg/ link), claimCode (to DM) }. +export function lockGift(code, amount, recipientPkHex) { + const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey }))); + return { blob, claimCode }; +} +// The public fields of a locked-gift blob (or null). Decryption needs the code. +export function previewLockedGift(blob) { + try { + const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); + if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; + } catch {} + return null; +} diff --git a/src/features/gifts.js b/src/features/gifts.js index a0d1f8c..9562b9f 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -2,7 +2,8 @@ // reclaim) and the claim flow (fresh or existing wallet, nostr-locked gifts, // claim codes / NIP-07 extension). Gift primitives live in ../wallet.js. -import { newMnemonic, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from '../wallet.js'; +import { newMnemonic } from '../wallet.js'; +import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from '../nostr.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; @@ -15,6 +16,7 @@ export function giftsFeature(ctx) { brandHeader, profileChip, activeAccount, setAccounts, getAccounts, claimTargets, enterWallet, activateAccount, commitAccount, } = ctx; + installGiftWallet(wallet); // gift primitives live outside the core wallet // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -747,7 +749,7 @@ export function giftsFeature(ctx) { return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); }, balanceLines() { - const locked = wallet.lockedValue; + const locked = wallet.giftLockedValue(); return locked > 0 ? [{ label: t('lockedInGifts'), sat: locked }] : []; }, historyEntries(txs) { diff --git a/src/features/swaps.js b/src/features/swaps.js index 636a9f8..b41b87a 100644 --- a/src/features/swaps.js +++ b/src/features/swaps.js @@ -2,7 +2,7 @@ // pay bolt11 invoices, provider settings. Extracted from app.js; the // protocol layer lives in ../swap.js. -import { SwapManager } from '../swap.js'; +import { SwapManager, installSwapWallet } from '../swap.js'; import { getNetwork, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; @@ -13,6 +13,7 @@ function isLnInvoice(v) { v = (v || '').trim(); return /^ln[a-z0-9]+$/i.test(v) export function swapsFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, copyBtn } = ctx; + installSwapWallet(wallet); // swap storage/keys live outside the core wallet // Boltz swap orchestrator (reverse = receive over LN, submarine = spend over LN). const swaps = new SwapManager({ diff --git a/src/swap.js b/src/swap.js index 9afe7e7..d7a1e9c 100644 --- a/src/swap.js +++ b/src/swap.js @@ -231,6 +231,44 @@ export function decodeBolt11(invoice) { // Orchestrates non-custodial swaps: creates them via Boltz, verifies the lockup // address locally before any funds move, then polls the chain (via the wallet's // esplora api) to claim (reverse) or detect success/refund (submarine). DOM-free. +// Wallet storage/key helpers for this feature, installed onto the core +// wallet instance so a build without the feature ships none of it. +export function installSwapWallet(wallet) { + if (wallet.loadSwaps) return; // already installed + Object.assign(wallet, { + // ---- Boltz swap support ---------------------------------------------- + // Persisted swap records (public metadata only — keys/preimage are re-derived + // deterministically from the seed via swapNode, so nothing secret is stored). + _swapKey() { return this._cacheKey() + ':swaps'; }, + loadSwaps() { + try { return JSON.parse(localStorage.getItem(this._swapKey()) || '[]'); } catch { return []; } + }, + saveSwaps(list) { + try { localStorage.setItem(this._swapKey(), JSON.stringify(list)); } catch {} + }, + _swapIdxKey() { return this._cacheKey() + ':swapidx'; }, + // Reserve the next swap index: monotonic + persisted on every call. A reverse/ + // submarine attempt that fails AFTER Boltz created the swap (a later validation + // throw, a dropped response) still burns its index, so a retry can't reuse the + // same deterministic preimage (Boltz rejects "swap with this preimage exists"). + // Seeded from saved swaps so existing wallets don't regress. + nextSwapIndex() { + const fromSaved = this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0); + let stored = 0; + try { stored = parseInt(localStorage.getItem(this._swapIdxKey()) || '0', 10) || 0; } catch {} + const idx = Math.max(fromSaved, stored); + try { localStorage.setItem(this._swapIdxKey(), String(idx + 1)); } catch {} + return idx; + }, + // Deterministic swap keypair on a dedicated chain (2) so swap keys never + // collide with receive (0) / change (1). Returns the HDKey node (has + // privateKey + publicKey, 33-byte compressed). + swapNode(index) { + return this.account().deriveChild(2).deriveChild(index); + }, + }); +} + export class SwapManager { constructor({ wallet, network = 'regtest', getApi, feeRate = 2, onUpdate }) { this.wallet = wallet; diff --git a/src/wallet.js b/src/wallet.js index 56e3726..403bcd8 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js'; +import { NostrSync, getSyncConfig, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -105,6 +105,9 @@ export class Wallet { this.mnemonic = ''; this.passphrase = ''; this.netName = 'mainnet'; + // Feature-registered providers of coin ids to exclude from spending and + // the spendable balance (e.g. coins reserved behind unclaimed gift links). + this._coinLocks = []; this.api = new Api('mainnet'); this.offline = false; @@ -1134,20 +1137,12 @@ export class Wallet { return this.confirmed + this.pending; } - // Confirmed coins locked behind unclaimed gift links. The whole source coin is - // committed until the gift is claimed (our change only comes back in the - // claimer's tx), so it's already excluded from spending — and from the - // spendable balance below. - get lockedValue() { - const res = this.reservedSet(); - return this.utxos.reduce((s, u) => s + (res.has(utxoId(u)) ? u.value : 0), 0); - } // The spendable headline: confirmed coins plus our own unconfirmed change (the // remainder of a spend we just made, so the spend debits immediately), minus // anything locked in gifts. Unconfirmed *incoming* receives are NOT counted // here — they show as pending until they confirm. get spendable() { - const res = this.reservedSet(); + const res = this.lockedCoinIds(); return this.utxos.reduce((s, u) => { if (res.has(utxoId(u))) return s; if (u.confirmed || u.chain === 1) return s + u.value; @@ -1158,7 +1153,7 @@ export class Wallet { // separate pending line, kept out of the spendable headline. Includes pending // (mempool) silent-payment receipts. get pendingIncoming() { - const res = this.reservedSet(); + const res = this.lockedCoinIds(); return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + this.spBalance().pending; } @@ -1194,7 +1189,7 @@ export class Wallet { buildTx({ recipients, feeRate, coinIds = null, sendMax = false, noSort = false }) { const pool_ = coinIds ? this.utxos.filter((u) => coinIds.includes(utxoId(u))) - : this.utxos.filter((u) => !this.isReserved(utxoId(u))); // skip coins set aside for gifts + : (() => { const locked = this.lockedCoinIds(); return this.utxos.filter((u) => !locked.has(utxoId(u))); })(); // skip locked coins (gift reservations, ...) // Received silent-payment outputs are spendable too — one-time taproot keys, // 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 @@ -1501,16 +1496,6 @@ 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 backing claimable presigned transactions) --------- - // A gift coin is either RESERVED (set aside, skipped by coin selection) or - // RECLAIMED (freed for spending, but its link stays live until the coin is - // actually spent). Both are "outstanding"; both are persisted per-wallet. - _giftKey() { - return this._cacheKey() + ':gift'; - } - _reclaimedKey() { - return this._cacheKey() + ':giftfree'; - } _loadSet(key) { try { return new Set(JSON.parse(localStorage.getItem(key) || '[]')); @@ -1518,179 +1503,37 @@ export class Wallet { return new Set(); } } - reservedSet() { - if (!this._reserved) this._reserved = this._loadSet(this._giftKey()); - return this._reserved; - } - reclaimedSet() { - if (!this._reclaimed) this._reclaimed = this._loadSet(this._reclaimedKey()); - return this._reclaimed; - } - isReserved(id) { - return this.reservedSet().has(id); + + // ---- optional-feature seams --------------------------------------------- + // Coin locks: features contribute sets of coin ids that spending and the + // balance treat as unavailable. + registerCoinLock(fn) { this._coinLocks.push(fn); } + lockedCoinIds() { + const all = new Set(); + for (const fn of this._coinLocks) { try { for (const id of fn()) all.add(id); } catch {} } + return all; } - _saveReserved() { - try { localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()])); } catch {} + // Namespaced per-wallet persisted state for features (swaps, ark, ...). + featureStateKey(name) { return this._cacheKey() + ':' + name; } + loadFeatureState(name, fallback = null) { + try { const v = JSON.parse(localStorage.getItem(this.featureStateKey(name)) || 'null'); return v == null ? fallback : v; } catch { return fallback; } } - _saveReclaimed() { - try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {} + saveFeatureState(name, value) { + try { localStorage.setItem(this.featureStateKey(name), JSON.stringify(value)); } catch {} } - // ---- Ark support ------------------------------------------------------- - // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement - // history — no secrets: vtxo keys are re-derived from the seed). - _arkKey() { return this._cacheKey() + ':ark'; } - loadArkState() { - try { return JSON.parse(localStorage.getItem(this._arkKey()) || 'null'); } catch { return null; } - } - saveArkState(state) { - try { localStorage.setItem(this._arkKey(), JSON.stringify(state)); } catch {} - } - // ---- Boltz swap support ---------------------------------------------- - // Persisted swap records (public metadata only — keys/preimage are re-derived - // deterministically from the seed via swapNode, so nothing secret is stored). - _swapKey() { return this._cacheKey() + ':swaps'; } - loadSwaps() { - try { return JSON.parse(localStorage.getItem(this._swapKey()) || '[]'); } catch { return []; } - } - saveSwaps(list) { - try { localStorage.setItem(this._swapKey(), JSON.stringify(list)); } catch {} - } - _swapIdxKey() { return this._cacheKey() + ':swapidx'; } - // Reserve the next swap index: monotonic + persisted on every call. A reverse/ - // submarine attempt that fails AFTER Boltz created the swap (a later validation - // throw, a dropped response) still burns its index, so a retry can't reuse the - // same deterministic preimage (Boltz rejects "swap with this preimage exists"). - // Seeded from saved swaps so existing wallets don't regress. - nextSwapIndex() { - const fromSaved = this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0); - let stored = 0; - try { stored = parseInt(localStorage.getItem(this._swapIdxKey()) || '0', 10) || 0; } catch {} - const idx = Math.max(fromSaved, stored); - try { localStorage.setItem(this._swapIdxKey(), String(idx + 1)); } catch {} - return idx; - } - // Deterministic swap keypair on a dedicated chain (2) so swap keys never - // collide with receive (0) / change (1). Returns the HDKey node (has - // privateKey + publicKey, 33-byte compressed). - swapNode(index) { - return this.account().deriveChild(2).deriveChild(index); - } - // Passive reclaim: free the coin for spending but keep tracking the live link - // so it can still be revoked later (until the coin is spent). - unreserve(id) { - this.reservedSet().delete(id); - this.reclaimedSet().add(id); - this._saveReserved(); - this._saveReclaimed(); - } - // Truly revoke an unclaimed gift: spend its coin back into our own wallet, - // which double-spends the gift's presigned input so the link can't be claimed - // once this confirms. Uses a high fee to win any race with a claimer. - async revokeGift(id, feeRate) { - const draft = this.buildTx({ recipients: [{ address: this.freshChange().address }], feeRate, coinIds: [id], sendMax: true }); - const hexTx = this.sign(draft.tx); - const txid = await this.broadcast(hexTx); - this.reservedSet().delete(id); - this.reclaimedSet().delete(id); - this._saveReserved(); - this._saveReclaimed(); - return txid; - } - // Outstanding gifts (reserved + reclaimed) whose coin is still unspent; prunes - // any whose coin has since been claimed/spent. { id, reserved, value }. - outstandingGifts() { - const live = new Set(this.utxos.map((u) => utxoId(u))); - const res = this.reservedSet(); - const rec = this.reclaimedSet(); - let changed = false; - for (const id of [...res]) if (!live.has(id)) { res.delete(id); changed = true; } - for (const id of [...rec]) if (!live.has(id)) { rec.delete(id); changed = true; } - if (changed) { this._saveReserved(); this._saveReclaimed(); } - return [...new Set([...res, ...rec])].map((id) => ({ - id, - reserved: res.has(id), - value: (this.utxos.find((u) => utxoId(u) === id) || {}).value, - })); - } - // Persistent gift records (per account): the shareable link/code + amount, so a - // gift's link/QR can be re-viewed later and a claimed one identified in history. - _giftRecordsKey() { return this._cacheKey() + ':giftrec'; } - giftRecords() { - if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } } - return this._giftRecords; - } - _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} } - recordGift({ code, locked, amount, claimCode, outpoints }) { - if (!outpoints || !outpoints.length) return; - this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false }; - this._saveGiftRecords(); - } - giftLink(id) { return this.giftRecords()[id] || null; } - markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } } - // Gifts whose coin has been spent and weren't revoked by us → claimed. (Only - // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.) - claimedGifts() { - if (!this.loaded) return []; - const live = new Set(this.utxos.map((u) => utxoId(u))); - return Object.values(this.giftRecords()) - .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o))) - .map((r) => ({ id: r.id, amount: r.amount, locked: r.locked, created: r.created, claimTxid: r.claimTxid || null, outpoints: r.outpoints })) - .sort((a, b) => (b.created || 0) - (a.created || 0)); - } - // Remember which tx claimed a gift (resolved lazily via outspend) so the - // history can merge the gift row with its claim transaction. - setGiftClaimTx(id, txid) { - const recs = this.giftRecords(); - if (recs[id] && recs[id].claimTxid !== txid) { - recs[id].claimTxid = txid; - this._saveGiftRecords(); - } - } - // Best-fit coin selection for a gift of `gift` sats (needs gift + a dust - // change): the smallest single confirmed coin that covers it — one input and - // the least value locked until claim — else the fewest large coins. Returns - // the selected UTXOs, or null if confirmed funds are insufficient. - _selectGiftCoins(gift) { - const DUST = 294n; - const need = gift + DUST; - const avail = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); - const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0]; - if (single) return [single]; - const sel = []; - let sum = 0n; - for (const u of [...avail].sort((a, b) => b.value - a.value)) { - sel.push(u); - sum += BigInt(u.value); - if (sum >= need) return sel; - } - return null; // insufficient confirmed funds - } - // Sats that creating this gift right now would lock (the selected coins' - // total), or null if confirmed funds are insufficient. Lets the UI warn when - // the lock would dwarf the gift and offer to split a coin first. - giftLockPreview(amountSats) { - const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); - return sel ? sel.reduce((s, u) => s + u.value, 0) : null; - } - // The coins a gift of this amount would lock: total value + how many inputs. - // A high input count means a costly-to-claim gift (each input adds to the - // claimer's fee) — the UI uses it to recommend splitting/consolidating first. - giftCoinSummary(amountSats) { - const sel = this._selectGiftCoins(BigInt(Math.round(amountSats))); - return sel ? { lock: sel.reduce((s, u) => s + u.value, 0), count: sel.length } : null; - } // Number of unreserved spendable coins (for the consolidate prompt/action). spendableCoinCount() { - return this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)).length; + const locked = this.lockedCoinIds(); + return this.utxos.filter((u) => !locked.has(utxoId(u)) && (u.confirmed || u.chain === 1)).length; } // Merge all unreserved spendable coins into one, via a max self-send. Leaves @@ -1703,56 +1546,8 @@ export class Wallet { return { txid: draft.tx.id, inputs: draft.inputsCount }; } - // Build a signed gift from already-chosen coins. If the gift leaves a viable - // (>= dust) change, output0 is that change back to us, cryptographically fixed - // by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is - // locked while the claimer is free to add their own output. If there's no - // viable change (a whole-balance gift), there's no output at all and every - // input is SIGHASH_NONE, so the claimer sweeps the entire amount. Reserves the - // coins; returns { code, amount, reserved } where amount is what the claimer - // receives before their fee (the gift, or the whole swept sum). - _buildGiftPsbt(sel, gift) { - const DUST = 294n; - const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); - const change = sum - gift; - const keepChange = change >= DUST; - const sigOf = (i) => (keepChange && i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE); - const t = new btc.Transaction({ allowUnknownOutputs: true }); - sel.forEach((u, i) => { - const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net); - t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: sigOf(i), witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); - }); - if (keepChange) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); - sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [sigOf(i)])); - - for (const u of sel) this.reservedSet().add(utxoId(u)); - this._saveReserved(); - return { code: base32nopad.encode(_encodeCompactGift(t)), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) }; - } - // Gift the entire spendable balance: a no-change sweep of all unreserved - // spendable coins (confirmed, plus our own unconfirmed change). The claimer - // receives the whole amount minus their claim fee. - createGiftAll(feeRate) { - const rate = Math.max(1, Math.round(feeRate)); - const sel = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); - if (!sel.length) throw new Error('No coins available to gift.'); - const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); - if (sum < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - return this._buildGiftPsbt(sel, sum); // change = 0 → no-change sweep - } - // Build a gift link from a best-fit coin, locking the least value. No fee is - // reserved here: the claimer's wallet looks up the fee rate at claim time and - // subtracts it from the amount. - createGift(amountSats, feeRate) { - const gift = BigInt(Math.round(amountSats)); - const rate = Math.max(1, Math.round(feeRate)); - if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - const sel = this._selectGiftCoins(gift); - if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); - return this._buildGiftPsbt(sel, gift); - } // Our nostr identity (used to DM a locked gift's claim code to the recipient). nostrPubkey() { return (this.nostr && this.nostr.pk) || null; } @@ -1762,63 +1557,6 @@ export class Wallet { return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; } - // Split + gift in one shot, with no confirmation wait: broadcast a self-send - // that carves out a gift+dust coin, then build the gift spending that - // still-unconfirmed carve-out. The funding and the claim form a chain that - // confirms together; the recipient just sees a pending balance until the - // split confirms. Lets the sender lock only ~the gift amount (instead of a - // whole large coin) without waiting for the split to confirm first. - async createGiftFromSplit(amountSats, feeRate) { - const gift = Math.round(amountSats); - const rate = Math.max(1, Math.round(feeRate)); - if (BigInt(gift) < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - const DUST = 294; - const recvIndex = this.nextReceiveIndex; - const carveAddr = this.derive(0, recvIndex).address; - const changeIndex = this.nextChangeIndex; - - // Carve out exactly gift + dust to one of our own addresses; the rest comes - // back as change. buildTx already spends confirmed coins largest-first. - const draft = this.buildTx({ recipients: [{ address: carveAddr, amount: gift + DUST }], feeRate: rate }); - const carveIdx = draft.outputs.findIndex((o) => o.address === carveAddr); - if (carveIdx < 0) throw new Error('Could not size the split output.'); - const hexTx = this.sign(draft.tx); - const splitTxid = draft.tx.id; - await this.broadcast(hexTx); - - // Wait until the carve-out is actually visible in the mempool before we - // build the gift on top of it and reserve it. Otherwise a background refresh - // could rebuild the address from a not-yet-indexed API response, transiently - // drop the coin, and prune the reservation — risking a double-spend. - let seen = false; - for (let i = 0; i < 8 && !seen; i++) { - await sleep(i === 0 ? 400 : 900); - try { - const us = await this.api.addressUtxos(carveAddr); - seen = us.some((u) => u.txid === splitTxid && u.vout === carveIdx); - } catch {} - } - if (!seen) throw new Error('The split is taking a while to propagate. Once it confirms, create the gift from the new coin.'); - - // Reflect the split in our coin set — drop the spent inputs, add the - // (now mempool-visible) carve-out and change — so balances and gift tracking - // are correct immediately; a background scan reconciles the rest. - const spent = new Set(); - for (let i = 0; i < draft.tx.inputsLength; i++) { - const inp = draft.tx.getInput(i); - spent.add(`${hex.encode(inp.txid)}:${inp.index}`); - } - this.utxos = this.utxos.filter((u) => !spent.has(utxoId(u))); - this.utxos.push({ txid: splitTxid, vout: carveIdx, value: gift + DUST, address: carveAddr, chain: 0, index: recvIndex, confirmed: false }); - const changeIdx = draft.outputs.findIndex((o, i) => i !== carveIdx); - if (changeIdx >= 0) { - const co = draft.outputs[changeIdx]; - this.utxos.push({ txid: splitTxid, vout: changeIdx, value: co.amount, address: co.address, chain: 1, index: changeIndex, confirmed: false }); - } - this._recomputeBalanceFromUtxos(); - - return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift)); - } // --- realtime (mempool.space WebSocket) --------------------------------- // Pushes us new mempool/confirmed transactions for our addresses so history @@ -2612,147 +2350,7 @@ export function xpubToZpub(xpub) { return _b58c.encode(data); } -// 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. -// Smallest sensible gift at a given fee rate: dust + one claim fee of headroom -// (floored at 546 sats), so the recipient clears dust even if fees climb. -export function giftMinimum(feeRate) { - const rate = Math.max(1, Math.round(feeRate)); - const claimFee = Math.ceil((11 + 68 + 31 * 2) * rate); - return Math.max(546, 294 + claimFee); -} - -// ---- compact gift codec ---------------------------------------------------- -// A gift link used to carry a full PSBT (verbose). The claimer only needs the -// presigned inputs (outpoint, amount, pubkey, signature) and the sender's -// committed change output, so we serialize just those — ~25% smaller, and (via -// base32 + an uppercase /g/ URL) it scans in the QR's denser alphanumeric mode. -// Legacy base64url-PSBT links still claim; _giftTx auto-detects the format. -const GIFT_MAGIC = 0x01; -const _u8 = (n) => Uint8Array.of(n & 0xff); -const _le32 = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }; -const _le64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; -function _encodeCompactGift(tx) { - const hasChange = tx.outputsLength > 0; - const parts = [_u8(GIFT_MAGIC), _u8(hasChange ? 1 : 0), _u8(tx.inputsLength)]; - for (let i = 0; i < tx.inputsLength; i++) { - const inp = tx.getInput(i); - const [pubkey, sig] = inp.partialSig[0]; // pubkey(33), sig = DER + sighash byte - parts.push(inp.txid, _le32(inp.index), _le64(inp.witnessUtxo.amount), pubkey, _u8(sig.length), sig); - } - if (hasChange) { - const o = tx.getOutput(0); // p2wpkh change: script = [00 14 <20-byte hash>] - parts.push(_le64(o.amount), o.script.slice(2)); - } - return concatBytes(...parts); -} - -function _decodeCompactGift(bytes) { - let p = 0; - const rd = (n) => bytes.slice(p, (p += n)); - const r32 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0, true); p += 4; return v; }; - const r64 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 8).getBigUint64(0, true); p += 8; return v; }; - p++; // magic - const hasChange = !!(bytes[p++] & 1); - const n = bytes[p++]; - const t = new btc.Transaction({ allowUnknownOutputs: true }); - for (let i = 0; i < n; i++) { - const txid = rd(32), vout = r32(), amount = r64(), pubkey = rd(33), sigLen = bytes[p++], sig = rd(sigLen); - const pay = p2wpkh(pubkey, btc.NETWORK); // witness-program .script is network-independent - // second arg true: re-add an already-signed input without tripping the sign-status guard - t.addInput({ txid, index: vout, witnessUtxo: { script: pay.script, amount }, sighashType: sig[sig.length - 1], partialSig: [[pubkey, sig]] }, true); - } - if (hasChange) { - const amount = r64(); - const hash = rd(20); - t.addOutput({ script: concatBytes(_u8(0x00), _u8(0x14), hash), amount }, true); - } - return t; -} - -// Parse a gift code to a Transaction. New format: base32(compact). Legacy: -// base64url(PSBT). Auto-detected via the compact magic byte. -function _giftTx(code) { - try { - const b = base32nopad.decode(code.toUpperCase()); - if (b[0] === GIFT_MAGIC) return _decodeCompactGift(b); - } catch {} - return btc.Transaction.fromPSBT(base64urlnopad.decode(code)); -} - -function _sumInputs(tx) { - let inAmt = 0n; - for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount; - return inAmt; -} -// The sender's change committed in a gift PSBT, or 0 for a no-change (whole- -// balance) gift that has no outputs at all. -function _giftChange(tx) { - return tx.outputsLength > 0 ? tx.getOutput(0).amount : 0n; -} -// The gift's funding outpoints (the coins its presigned inputs spend). If any is -// already spent — even by an unconfirmed claim — the gift has been taken and -// re-claiming would be a doomed double-spend. Used to gate the claim screen. -export function giftOutpoints(code) { - try { - const tx = _giftTx(code); - const out = []; - for (let i = 0; i < tx.inputsLength; i++) { - const inp = tx.getInput(i); - out.push({ txid: hex.encode(inp.txid), vout: inp.index }); - } - return out; - } catch { - return []; - } -} -export function previewGift(code) { - try { - const tx = _giftTx(code); - // room is the full amount the claimer receives (inputs minus our change); - // the claim fee is subtracted from it at claim time, so report inputs too - // so the caller can size that fee for this PSBT. - return { room: Number(_sumInputs(tx) - _giftChange(tx)), inputs: tx.inputsLength }; - } catch { - return null; - } -} - -export function buildClaimTx(code, toAddress, feeRate, net) { - const tx = _giftTx(code); - const room = _sumInputs(tx) - _giftChange(tx); - const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 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) }; -} - -// A gift locked to a recipient's nostr key. The amount + recipient pubkey are -// public (anyone can see what it is and who it's for), but the claim payload (the -// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the -// link can be shared openly yet only the recipient's nostr key can claim it. -const LOCKED_GIFT_VERSION = 1; -// Encrypt the gift's claim payload under a one-time code. The code is delivered -// to the recipient via a nostr DM; the public link carries only the ciphertext. -// Returns { blob (for the /lg/ link), claimCode (to DM) }. -export function lockGift(code, amount, recipientPkHex) { - const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex); - const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey }))); - return { blob, claimCode }; -} -// The public fields of a locked-gift blob (or null). Decryption needs the code. -export function previewLockedGift(blob) { - try { - const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); - if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; - } catch {} - return null; -} function firstUnused(chain) { const u = chain.find((a) => !a.used); diff --git a/tools/ark/gift-shot.js b/tools/ark/gift-shot.js index 948854c..3ebb21f 100644 --- a/tools/ark/gift-shot.js +++ b/tools/ark/gift-shot.js @@ -17,6 +17,7 @@ for (let i = 0; i < 30; i++) { try { if ((await fetch(APP)).ok) break; } catch { const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +page.on('pageerror', (e) => console.log('[pageerror]', e.message)); await page.setViewport({ width: 420, height: 860, deviceScaleFactor: 2 }); const clickText = (sel, text) => page.evaluate((sel, text) => { const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase())); |
