aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js19
-rw-r--r--src/features/gifts-wallet.js434
-rw-r--r--src/features/gifts.js6
-rw-r--r--src/features/swaps.js3
-rw-r--r--src/swap.js38
-rw-r--r--src/wallet.js450
6 files changed, 521 insertions, 429 deletions
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);