aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-05 13:25:25 -0700
committerAdam Soltys <[email protected]>2026-07-05 13:25:25 -0700
commit191f6f757c596cf974ba4ee63967cdf19389fd58 (patch)
tree8e1b07195eddca5d54acaa66dbd9edcf70b362ba /src
parentf9fc0bb3e38c3387f8af5d151aa03d83376ba9cc (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js6
-rw-r--r--src/features/ark.js169
-rw-r--r--src/features/gifts.js166
-rw-r--r--src/i18n.js7
4 files changed, 318 insertions, 30 deletions
diff --git a/src/app.js b/src/app.js
index 4912ea1..f659097 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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',