aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js163
1 files changed, 125 insertions, 38 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 1a5a2a2..54bd32b 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -885,8 +885,9 @@ export function arkFeature(ctx) {
// 2. lightning: profile lud16/lud06, via the zap flow
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
- function startNpubPay(pk, npub) {
- const z = (ui.arkZap = { npub, pk, amount: '', comment: '', status: 'lookup' });
+ function startNpubPay(pk, npub, eventId = null) {
+ const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup' });
+ ui.zap = null; // a stale Lightning-zap card must not resurface behind ours
ui.sendError = '';
render();
const live = () => ui.arkZap === z;
@@ -925,7 +926,7 @@ export function arkFeature(ctx) {
if (profile && (profile.lud16 || profile.lud06)) {
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub); return; }
+ if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub, z.eventId); return; }
}
// 3. on-chain fallback
if (onchain) {
@@ -979,7 +980,8 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]],
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]],
}).catch(() => {});
ui.arkZapped = { amountSat: sats, npub: z.npub };
ui.arkZap = null;
@@ -989,51 +991,121 @@ export function arkFeature(ctx) {
ui.busy = false; render();
}
+ // No ark address published: the zap leaves anyway, as an ark gift locked to
+ // their nostr key. The sats move into a bearer vtxo now; the public link is
+ // safe to put in the receipt because only the recipient's key — or the
+ // claim code we DM them — opens it. Unclaimed, it stays revocable from the
+ // gift card like any other ark gift.
+ async function doArkZapGift() {
+ const z = ui.arkZap;
+ const sats = ctx.parseAmount(z.amount, ctx.getUnit());
+ if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; }
+ if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; }
+ ui.busy = true; ui.sendError = ''; render();
+ try {
+ const g = await createArkGift(sats, z.pk);
+ const locked = ctx.hook('lockArkGift', g.code, sats, z.pk);
+ if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build
+ noteZap('to:' + g.address, z.pk);
+ const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } });
+ ui.arkZap = null;
+ // best-effort receipt; carries the locked link so the recipient can
+ // discover the gift from the note even if the DM never lands
+ await wallet.nostrPublish({
+ kind: ARK_ZAP_KIND,
+ content: (z.comment || '').slice(0, 280),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url]],
+ }).catch(() => {});
+ const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
+ if (wallet.sendNostrDM) {
+ wallet.sendNostrDM(z.pk, dmText)
+ .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); })
+ .catch(() => { done.gift.dm = 'failed'; render(); });
+ } else done.gift.dm = 'failed';
+ } catch (e) {
+ ui.sendError = e.message;
+ }
+ ui.busy = false; render();
+ }
+
function arkZapView() {
// While resolving, show the SHAPE of the card that's coming — same
// title, same recipient chip, a spinner where the form will be. The
// screen fills in rather than being replaced.
if (ui.arkZap && ui.arkZap.status === 'lookup') return zapSkeleton(ui.arkZap.pk, ui.arkZap.npub);
if (ui.arkZapped) {
- return h('div', {
- class: 'card col',
- style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px',
- onClick: () => { ui.arkZapped = null; ui.send = blankSend(); render(); },
- },
- h('div', { class: 'check-badge' }, '⚡'),
- h('h2', { style: 'margin:0' }, t('arkZapSentTitle')),
+ const gift = ui.arkZapped.gift;
+ const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); };
+ if (!gift) {
+ return h('div', {
+ class: 'card col',
+ style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px',
+ onClick: finish,
+ },
+ h('div', { class: 'check-badge' }, '⚡'),
+ h('h2', { style: 'margin:0' }, t('arkZapSentTitle')),
+ h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
+ h('div', { class: 'small muted' }, t('tapToProceed')));
+ }
+ // A locked-gift zap has aftercare: the DM's fate, and the link + claim
+ // code to pass along by hand when the DM couldn't be delivered.
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' },
+ h('div', { class: 'check-badge' }, '🎁'),
+ h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')),
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
- h('div', { class: 'small muted' }, t('tapToProceed')));
+ gift.dm === 'sending'
+ ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))
+ : gift.dm === 'sent'
+ ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent'))
+ : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')),
+ h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url),
+ h('div', { class: 'row gap6 wrap', style: 'justify-content:center' },
+ copyBtn(gift.url, t('copyLink')),
+ gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null),
+ h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done')));
}
const z = ui.arkZap;
if (!z) return null;
+ const spendable = arkBalance()?.spendableSat || 0;
+ // No ark address found — but a zap can still leave as an ark gift locked
+ // to their nostr key, when this build carries the gifts feature.
+ const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift');
+ const amountInputs = (hint) => h('div', { class: 'col gap6' },
+ h('div', { class: 'input-group' },
+ h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount,
+ onInput: (e) => { z.amount = e.target.value; } }),
+ h('div', { style: 'display:flex;align-items:center' }, unitTag())),
+ h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment,
+ onInput: (e) => { z.comment = e.target.value; } }),
+ h('div', { class: 'small faint' }, hint));
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, '⚡ ' + t('zapTitle')),
ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub),
z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null,
- z.status === 'noark' ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null,
+ z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null,
+ giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null,
+ giftOk ? amountInputs(t('arkZapGiftHint')) : null,
+ giftOk
+ ? (ui.busy
+ ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
+ : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn')))
+ : null,
+ z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
// No Ark address, but they may still take a Lightning zap — hand off to
// the zaps feature (present alongside swaps).
(z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap')
- ? h('button', { class: 'btn-primary btn-block', onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
: null,
- z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
- z.status === 'ready' && (arkBalance()?.spendableSat || 0) < 330
+ z.status === 'ready' && spendable < 330
? h('div', { class: 'notice info' }, t('zapNoBalance'))
: z.status === 'ready'
- ? h('div', { class: 'col gap6' },
- h('div', { class: 'input-group' },
- h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount,
- onInput: (e) => { z.amount = e.target.value; } }),
- h('div', { style: 'display:flex;align-items:center' }, unitTag())),
- h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment,
- onInput: (e) => { z.comment = e.target.value; } }),
- h('div', { class: 'small faint' }, t('arkZapHint')))
+ ? amountInputs(t('arkZapHint'))
: null,
ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')),
- z.status === 'ready' && (arkBalance()?.spendableSat || 0) >= 330
+ z.status === 'ready' && spendable >= 330
? (ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
: h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn')))
@@ -1510,6 +1582,24 @@ export function arkFeature(ctx) {
})).then((flags) => { if (flags.some(Boolean)) ark._save(); });
}
+ // Fund a fresh bearer gift identity. Shared by the gifts feature's hook and
+ // the zap flow's locked-gift fallback; `lockedTo` records who a locked gift
+ // was for (the sender can still revoke either kind while unclaimed).
+ async function createArkGift(amountSat, lockedTo = null) {
+ const mgr = await connectArk();
+ const secret = crypto.getRandomValues(new Uint8Array(32));
+ const secretHex = hex.encode(secret);
+ const gm = await giftManager(secretHex);
+ const address = gm.address();
+ const actionId = await mgr.send(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.destBytesList || [action.destBytes])[0])).id;
+ arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) });
+ mgr._save();
+ return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address };
+ }
+
// 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) {
@@ -1962,6 +2052,15 @@ export function arkFeature(ctx) {
startNpubPay(pk, String(text).trim());
return true;
},
+ // Zap a person — optionally a specific note of theirs (the profile feed's
+ // ⚡ button) — through the same resolution ladder as a pasted npub. The
+ // event id rides into the receipt's e tag; callers fall back to the
+ // lnZapNpub hook when ark can't serve this build/wallet.
+ zapNpub(pk, npub, eventId) {
+ if (!arkAvailable() || !wallet.nostrFetch) return false;
+ startNpubPay(pk, npub, eventId || null);
+ return true;
+ },
historyEntries() {
const s = arkStateNow();
if (!s) return [];
@@ -2062,19 +2161,7 @@ export function arkFeature(ctx) {
setNetwork(g.net);
return true;
},
- 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.destBytesList || [action.destBytes])[0])).id;
- arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false });
- mgr._save();
- return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat };
- },
+ arkGiftCreate(amountSat) { return createArkGift(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) {