diff options
| author | Adam Soltys <[email protected]> | 2026-07-05 13:25:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-05 13:25:25 -0700 |
| commit | 191f6f757c596cf974ba4ee63967cdf19389fd58 (patch) | |
| tree | 8e1b07195eddca5d54acaa66dbd9edcf70b362ba | |
| parent | f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc (diff) | |
| download | coinosv3-191f6f757c596cf974ba4ee63967cdf19389fd58.tar.gz coinosv3-191f6f757c596cf974ba4ee63967cdf19389fd58.zip | |
Ark gifts: instant off-chain bearer gifts inside the gift flow
Ark can't presign a bearer spend, so an ark gift is a vtxo sent to an
ephemeral ark identity whose seed IS the link (/ag/<code>: net, amount,
32-byte secret). The claimer rebuilds the identity, reads its mailbox,
and sweeps to their own ark address — instant, free, and it works for a
brand-new empty wallet with zero on-chain footprint. The sender keeps
the secret, which is what makes revoke possible (first cosign wins).
The gift form gains an On-chain/Ark source toggle when ark balance
exists; ark gifts skip the lock/split logic entirely. Claim, choose-
wallet, already-claimed, and nostr-locked (/lg/) flows all branch by
code kind; unclaimed ark gifts list with one-tap revoke and lazily
mark themselves claimed via GetVtxoStatus. Features stay decoupled:
gifts asks for ark support via the new ctx.hook seam, so either
feature builds alone.
Also restores goHome in the feature ctx — lost in the plugin-seam
extraction, it made the already-claimed screen throw on render.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/app.js | 6 | ||||
| -rw-r--r-- | src/features/ark.js | 169 | ||||
| -rw-r--r-- | src/features/gifts.js | 166 | ||||
| -rw-r--r-- | src/i18n.js | 7 |
4 files changed, 318 insertions, 30 deletions
@@ -2907,11 +2907,15 @@ async function importSnapshotFile(e) { // sits just before boot so every helper it captures is defined; the hooks are // only invoked at runtime (first render happens after loadLocale below). const ctx = { - h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, + h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, brandHeader, activeAccount, setAccounts: (list) => { accounts = list; }, getAccounts: () => accounts, claimTargets, enterWallet, activateAccount, commitAccount, + // cross-feature calls (e.g. gifts asking the ark feature for ark-gift + // support) — lazy so it resolves against the final FEATURES list, and a + // build without the target feature simply gets null back + hook: (name, ...args) => featureHook(name, ...args), }; const FEATURES = buildFeatures(ctx); diff --git a/src/features/ark.js b/src/features/ark.js index dcfd042..d61925b 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3,7 +3,11 @@ // sends, history entries, refresh/consolidation, server settings. import * as btc from '@scure/btc-signer'; +import { HDKey } from '@scure/bip32'; +import { hex, base32nopad } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; +import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; @@ -305,6 +309,105 @@ export function arkFeature(ctx) { ui.arkBusy = null; render(); } + // ---- ark gifts: bearer-key vtxos ---------------------------------------- + // Ark can't presign a bearer spend (every arkoor needs a live server cosign), + // so an ark gift is a vtxo sent to an ephemeral ark identity whose seed IS + // the link. The claimer rebuilds the identity from the code, reads the + // gift's mailbox for the vtxo, and sweeps it to their own ark address — + // instant and free. The sender keeps the secret too, which is what makes + // revoke possible (sweep it back before it's claimed; first cosign wins). + const AG_MAGIC = 0x11; + const AG_NET = { mainnet: 0, testnet: 1, signet: 2, mutinynet: 3, regtest: 4 }; + const AG_NET_BY = Object.fromEntries(Object.entries(AG_NET).map(([k, v]) => [v, k])); + + function encodeArkGiftCode(net, amountSat, secret) { + const b = new Uint8Array(42); + b[0] = AG_MAGIC; + b[1] = AG_NET[net] ?? 0; + new DataView(b.buffer).setBigUint64(2, BigInt(amountSat), true); + b.set(secret, 10); + return base32nopad.encode(b); + } + + function decodeArkGiftCode(code) { + try { + const b = base32nopad.decode(String(code || '').toUpperCase()); + if (b.length !== 42 || b[0] !== AG_MAGIC || !(b[1] in AG_NET_BY)) return null; + return { + net: AG_NET_BY[b[1]], + amountSat: Number(new DataView(b.buffer, b.byteOffset).getBigUint64(2, true)), + secretHex: hex.encode(b.slice(10)), + }; + } catch { return null; } + } + + // A manager over the gift identity. State persists per device (keyed by the + // secret's hash) so a claim interrupted mid-sweep resumes checkpointed. + async function giftManager(secretHex) { + const cfg = getArkConfig(); + if (!cfg) throw new Error(t('arkNotConnected')); + const key = 'btc-wallet-arkgift:' + hex.encode(sha256(hex.decode(secretHex))).slice(0, 24); + const mgr = new ArkManager({ + account: HDKey.fromMasterSeed(hex.decode(secretHex)), + storage: { + load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }, + save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} }, + }, + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + }); + await mgr.init(); + return mgr; + } + + // The gift vtxo's key/outpoint for direct status checks (receive key 0). + const giftKey0 = (secretHex) => { + const n = HDKey.fromMasterSeed(hex.decode(secretHex)).deriveChild(3).deriveChild(0); + return n.privateKey; + }; + const pointRawFromId = (id) => { + const [txid, vout] = id.split(':'); + const raw = new Uint8Array(36); + raw.set(hex.decode(txid).reverse(), 0); + new DataView(raw.buffer).setUint32(32, Number(vout), true); + return raw; + }; + + function arkGiftRecords() { + if (!ark || !ark.state) return []; + return (ark.state.gifts = ark.state.gifts || []); + } + + // Lazily mark records whose vtxo the server reports spent (claimed — or our + // own revoke). Throttled: this is called from the gift card's render path. + let arkGiftsCheckedAt = 0; + function refreshArkGiftRecords() { + if (!ark || Date.now() - arkGiftsCheckedAt < 30_000) return; + arkGiftsCheckedAt = Date.now(); + const open = arkGiftRecords().filter((g) => !g.revoked && !g.claimed); + Promise.all(open.map(async (g) => { + try { + const st = await getVtxoStatus(ark.arkUrl, pointRawFromId(g.id), giftKey0(g.secretHex)); + if (st === VTXO_STATE_SPENT) { g.claimed = true; return true; } + } catch {} + return false; + })).then((flags) => { if (flags.some(Boolean)) ark._save(); }); + } + + // Sweep the gift identity's balance to `destAddress`. Shared by claim + // (recipient's address) and revoke (sender's own address). + async function sweepArkGift(code, destAddress) { + const g = decodeArkGiftCode(code); + if (!g) throw new Error('not an ark gift'); + if (g.net !== getNetwork()) throw new Error(t('arkGiftWrongNet', { net: g.net })); + const gm = await giftManager(g.secretHex); + await gm.sync(); + await gm.reconcile().catch(() => {}); + const amount = gm.balance().spendableSat; + if (!amount) throw Object.assign(new Error(t('giftTakenTitle')), { giftTaken: true }); + await gm.send(destAddress, amount); + return amount; + } + // Offboard the whole ark balance back into this wallet's own on-chain // receive address — the mirror image of boarding. async function doArkOffboard() { @@ -536,5 +639,71 @@ export function arkFeature(ctx) { done ? null : h('div', { class: 'small muted', style: 'margin-top:2px' }, t('arkBoardedNote'))); }, settingsCards() { return [arkCard()]; }, + + // ---- ark-gift hooks (called by the gifts feature via ctx.hook) ---- + arkGiftInfo() { + if (!arkAvailable() || !ark || !ark.state) return null; + return { spendableSat: ark.balance().spendableSat }; + }, + arkGiftDecode(code) { return decodeArkGiftCode(code); }, + async arkGiftCreate(amountSat) { + const mgr = await connectArk(); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const secretHex = hex.encode(secret); + const gm = await giftManager(secretHex); + const actionId = await mgr.send(gm.address(), amountSat); + const action = mgr.state.actions.find((a) => a.id === actionId); + if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); + const vtxoId = decodeVtxo(hex.decode(action.destBytes)).id; + arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false }); + mgr._save(); + return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat }; + }, + // Claim-side status: claimable (with the live amount), taken, wrongnet, or + // unknown (not visible here — wrong server or not yet delivered). + async arkGiftStatus(code) { + const g = decodeArkGiftCode(code); + if (!g) return null; + if (g.net !== getNetwork()) return { state: 'wrongnet', net: g.net, amountSat: g.amountSat }; + const gm = await giftManager(g.secretHex); + await gm.sync().catch(() => {}); + await gm.reconcile().catch(() => {}); + const amt = gm.balance().spendableSat; + if (amt > 0) return { state: 'claimable', amountSat: amt }; + const seen = gm.state.vtxos.length > 0; + return { state: seen ? 'taken' : 'unknown', amountSat: g.amountSat }; + }, + async arkGiftClaim(code) { + if (wallet.watchOnly) throw new Error(t('arkWatchOnly')); + const mine = await connectArk(); + const amount = await sweepArkGift(code, mine.address()); + await mine.sync().catch(() => {}); // pull the swept vtxo in right away + mine.ackReceives(); // the gift UI celebrates; don't double-celebrate here + return amount; + }, + arkGiftOutstanding() { + refreshArkGiftRecords(); + return arkGiftRecords().filter((g) => !g.revoked && !g.claimed) + .map((g) => ({ id: g.id, amountSat: g.amountSat, created: g.created })); + }, + async arkGiftRevoke(id) { + const g = arkGiftRecords().find((x) => x.id === id); + if (!g) throw new Error('unknown gift'); + const mine = await connectArk(); + const code = encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex)); + let amount; + try { + amount = await sweepArkGift(code, mine.address()); + } catch (e) { + // beaten by the claimer — record it so the row self-heals immediately + if (e && e.giftTaken) { g.claimed = true; mine._save(); } + throw e; + } + g.revoked = true; + mine._save(); + await mine.sync().catch(() => {}); + mine.ackReceives(); // our own sweep-back isn't a "payment received" + return amount; + }, }; } diff --git a/src/features/gifts.js b/src/features/gifts.js index b17f5da..de6c9c3 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -11,12 +11,16 @@ import { fmtBtc, timeAgo, shortAddr } from '../format.js'; export function giftsFeature(ctx) { const { - h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, + h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download, brandHeader, activeAccount, setAccounts, getAccounts, claimTargets, enterWallet, activateAccount, commitAccount, } = ctx; installGiftWallet(wallet); // gift primitives live outside the core wallet + // Ark-gift support comes from the ark feature (when this build ships it); + // every call degrades to null in an on-chain-only build. + const hook = ctx.hook || (() => null); + const arkGiftOf = (code) => hook('arkGiftDecode', code); // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -25,6 +29,11 @@ export function giftsFeature(ctx) { // decoding to a real gift. function readGiftHash() { try { + const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift + if (am) { + history.replaceState(null, '', '/'); + return arkGiftOf(am[1]) ? am[1] : null; + } let code = null; const pm = location.pathname.match(/^\/g\/([A-Za-z0-9]+)\/?$/i); // new path form if (pm) code = pm[1]; @@ -59,6 +68,7 @@ export function giftsFeature(ctx) { // wallet (the original first-timer flow). function claimGift(code) { ui.claimLocked = null; + ui.claimArkAmount = null; const targets = claimTargets(); if (targets.length) { setAccounts(targets); ui.claimChoose = { code }; render(); } else enterWallet(newMnemonic(), '', { gift: code }); @@ -75,7 +85,8 @@ export function giftsFeature(ctx) { // Pick where to receive a gift: an existing wallet, or a brand-new one. function claimChooseView() { const code = ui.claimChoose.code; - const pv = previewGift(code); + const ag = arkGiftOf(code); + const pv = ag ? { room: ag.amountSat } : previewGift(code); return h( 'div', { class: 'col', style: 'gap:16px' }, @@ -206,12 +217,14 @@ export function giftsFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: goHome }, t('goToHome')) ); } - const pv = previewGift(ui.claimCode); + const ag = arkGiftOf(ui.claimCode); + const pv = ag ? { room: ui.claimArkAmount ?? ag.amountSat, inputs: 1 } : previewGift(ui.claimCode); // The headline is the full amount received (inputs minus the sender's change). // The network fee is determined now, at claim time, and comes out of that - // amount — we surface the estimate on the Claim button so it isn't a surprise. + // amount — we surface the estimate on the Claim button so it isn't a + // surprise. An ark gift sweeps off-chain: no fee at all. const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5)); - const estFee = pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; + const estFee = ag ? 0 : pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; const total = pv ? pv.room : 0; return h( 'div', @@ -233,7 +246,8 @@ export function giftsFeature(ctx) { ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, t('claimBtn'), ' ', - h('span', { style: 'font-size:0.85em;opacity:0.9' }, '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')) + h('span', { style: 'font-size:0.85em;opacity:0.9' }, + '(' + (ag ? t('claimArkFree') : t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + ')')) ); } @@ -241,6 +255,20 @@ export function giftsFeature(ctx) { // unconfirmed claim — means the gift is gone; show the "already claimed" screen // rather than let the user attempt a doomed double-spend. async function checkGiftClaimed(code) { + if (arkGiftOf(code)) { + try { + const st = await hook('arkGiftStatus', code); + if (st.state === 'taken') ui.claimTaken = {}; + else if (st.state === 'wrongnet') ui.claimError = t('arkGiftWrongNet', { net: st.net }); + else if (st.state === 'unknown') ui.claimNotVisible = true; + else ui.claimArkAmount = st.amountSat; // live amount (matches the code's) + } catch { + // transient — leave claimable; the sweep itself is the final guard + } + ui.claimChecking = false; + render(); + return; + } const ops = giftOutpoints(code); if (!ops.length) { ui.claimChecking = false; render(); return; } try { @@ -262,28 +290,49 @@ export function giftsFeature(ctx) { } // Broadcast the presigned gift to this fresh wallet's first receive address. + // Shared post-claim navigation: fresh wallets go to seed backup, existing + // ones straight in with the instant celebration. + function afterClaim(amount) { + ui.claimedAmount = amount; + const wasProvisional = !!(activeAccount() && activeAccount().provisional); + commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) + if (wasProvisional) { + ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + } else { + ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up + ui.claimStep = null; + ui.tab = 'receive'; + ui.giftJustClaimed = amount; // celebration shows instantly, no scan wait + } + return wasProvisional; + } + async function doClaim() { if (wallet.offline) { ui.claimError = t('scanOffline'); render(); return; } if (ui.claimChecking || ui.claimTaken) return; // not verified / already taken ui.busy = true; ui.claimError = ''; render(); + if (arkGiftOf(ui.claimCode)) { + // Ark gift: sweep the bearer vtxo into this wallet's ark balance — + // off-chain, instant, no fee, works for a brand-new empty wallet. + try { + const amount = await hook('arkGiftClaim', ui.claimCode); + afterClaim(amount); + } catch (e) { + if (e && e.giftTaken) ui.claimTaken = {}; + else ui.claimError = e.message || t('claimFailed'); + } + ui.busy = false; + render(); + return; + } try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; const to = wallet.receive[0] ? wallet.receive[0].address : wallet.derive(0, 0).address; const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); await wallet.broadcast(claim.hex); - ui.claimedAmount = claim.amount; - const wasProvisional = !!(activeAccount() && activeAccount().provisional); - commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) - if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up - } else { - ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up - ui.claimStep = null; - ui.tab = 'receive'; - ui.giftJustClaimed = claim.amount; // celebration shows instantly, no scan wait - } + afterClaim(claim.amount); wallet.scan().then(() => { // The claim credit advances the fresh receive index in the background; // ack it so the index-based celebration doesn't fire a second time @@ -391,7 +440,27 @@ export function giftsFeature(ctx) { ); } + // An ark gift: send the amount to a fresh bearer identity, off-chain and + // instant — no coin locking, no split logic, no claim fee. + async function doCreateArkGift() { + const info = hook('arkGiftInfo'); + const avail = info ? info.spendableSat : 0; + const amt = ui.giftMax ? avail : parseAmount(ui.giftAmount, getUnit()); + if (!amt || amt < 330) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(330) + ' ' + unitLabel() }); render(); return; } + if (amt > avail) { ui.giftError = t('giftExceedsBalance'); render(); return; } + ui.busy = true; ui.giftError = ''; render(); + try { + const g = await hook('arkGiftCreate', amt); + ui.giftIsArk = true; + finishGift(g, amt); + } catch (e) { + ui.giftError = e.message; + } + ui.busy = false; render(); + } + function createGiftLink() { + if (ui.giftSource === 'ark') { doCreateArkGift(); return; } const rate = giftRate(); if (ui.giftMax) { doCreateGiftAll(rate); return; } const min = giftMinimum(rate); @@ -477,7 +546,7 @@ export function giftsFeature(ctx) { h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), h('div', { class: 'row gap6 wrap' }, copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; ui.giftIsArk = false; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -501,15 +570,34 @@ export function giftsFeature(ctx) { ); })() : h('div', { class: 'col gap6' }, - h('div', { class: 'input-group' }, - h('input', { type: 'number', step: getUnit() === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), - disabled: ui.giftMax, - value: ui.giftMax ? (getUnit() === 'sats' ? String(wallet.spendable) : fmtBtc(wallet.spendable)) : ui.giftAmount, - onInput: (e) => (ui.giftAmount = e.target.value) }), - h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')), - h('div', { style: 'display:flex;align-items:center' }, unitTag()) - ), - h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), + // Source toggle: gift on-chain coins (presigned link, coins locked + // until claim) or the ark balance (instant bearer vtxo, no lock). + (() => { + const info = hook('arkGiftInfo'); + if (!info || info.spendableSat <= 0) { if (ui.giftSource === 'ark') ui.giftSource = 'chain'; return null; } + const src = ui.giftSource || 'chain'; + const btn = (id, label) => h('button', { + type: 'button', class: (src === id ? 'btn-primary' : 'btn-ghost') + ' grow', + onClick: () => { ui.giftSource = id; ui.giftError = ''; render(); }, + }, label); + return h('div', { class: 'row gap6' }, btn('chain', t('giftSourceChain')), btn('ark', t('giftSourceArk'))); + })(), + (() => { + const isArk = ui.giftSource === 'ark'; + const maxVal = isArk ? (hook('arkGiftInfo')?.spendableSat || 0) : wallet.spendable; + return h('div', { class: 'input-group' }, + h('input', { type: 'number', step: getUnit() === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), + disabled: ui.giftMax, + value: ui.giftMax ? (getUnit() === 'sats' ? String(maxVal) : fmtBtc(maxVal)) : ui.giftAmount, + onInput: (e) => (ui.giftAmount = e.target.value) }), + h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')), + h('div', { style: 'display:flex;align-items:center' }, unitTag()) + ); + })(), + h('div', { class: 'small faint' }, + ui.giftSource === 'ark' ? t('giftArkNote') + : ui.giftMax ? t('giftAllNote') + : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), (() => { const raw = ui.giftLockNpub.trim(); const pk = raw ? parseNostrPubkey(raw) : null; @@ -522,8 +610,28 @@ export function giftsFeature(ctx) { : h('div', { class: 'small err' }, t('giftLockInvalid'))); })(), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), - h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) + ui.busy + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) ), + // Outstanding ark gifts (bearer vtxos we can still revoke by sweeping back) + (() => { + const arkGifts = hook('arkGiftOutstanding') || []; + if (!arkGifts.length) return null; + return h('div', { class: 'col gap6', style: 'margin-top:4px' }, + h('span', { class: 'small muted' }, t('giftArkOutstanding', { n: arkGifts.length })), + ...arkGifts.map((g) => h('div', { class: 'row between' }, + h('span', { class: 'small mono' }, fmtAmount(g.amountSat) + ' ' + unitLabel() + ' ', h('span', { class: 'tag' }, 'Ark')), + ui.busy && ui.revokeId === g.id + ? h('span', { class: 'spinner sm' }) + : h('button', { class: 'btn-sm', onClick: async () => { + ui.revokeId = g.id; ui.busy = true; render(); + try { await hook('arkGiftRevoke', g.id); toast(t('giftArkRevoked')); } + catch (e) { toast(e.message); } + ui.busy = false; ui.revokeId = null; render(); + } }, t('giftRevoke')) + ))); + })(), active.length ? h('div', { class: 'col gap6', style: 'margin-top:4px' }, h('span', { class: 'small muted' }, t('giftReserved', { n: active.length })), @@ -650,7 +758,7 @@ export function giftsFeature(ctx) { // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. function giftUrl() { - return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; + return `${location.origin}/${ui.giftLocked ? 'lg' : ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}`; } function giftRate() { diff --git a/src/i18n.js b/src/i18n.js index 510a49e..9e04f71 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -377,6 +377,13 @@ const STR = { arkBoardNoFunds: 'Needs at least {n} sats of on-chain balance.', arkRefreshBtn: 'Refresh / consolidate {n} coin(s)', arkOffboardBtn: 'Move {n} back on-chain', + giftSourceChain: 'On-chain', + giftSourceArk: '⚔ Ark', + giftArkNote: 'Sent from your Ark balance — the link claims instantly, off-chain, with no fee.', + giftArkOutstanding: '{n} unclaimed Ark gift(s)', + giftArkRevoked: 'Gift swept back to your Ark balance.', + claimArkFree: 'instant, no fee', + arkGiftWrongNet: 'This gift lives on {net} — switch the network in Settings, then open the link again.', arkOffboardedTitle: 'Withdrawing!', arkOffboardedNote: 'Arrives in this wallet’s on-chain balance after 1 confirmation.', arkOffboarded: 'Withdrawn on-chain', |
