diff options
| author | Adam Soltys <[email protected]> | 2026-07-06 00:08:34 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-06 00:08:34 -0700 |
| commit | 8e2ff4976db3d7586d7b9c993a94cf9bea071a12 (patch) | |
| tree | 47a1bf687ef7bbbc3545f81f060edb782c056b5f | |
| parent | a3783d7e7f53709a8f8352494b9e89583e46f85b (diff) | |
| download | coinosv3-8e2ff4976db3d7586d7b9c993a94cf9bea071a12.tar.gz coinosv3-8e2ff4976db3d7586d7b9c993a94cf9bea071a12.zip | |
Ark zaps PoC — a NIP-61-shaped zap protocol over arkoor
Lightning zaps are welded to LNURL+bolt11; nutzaps (NIP-61) proved the
alternative shape, and ark fits it better than Cashu because delivery
already exists (the mailbox). Draft protocol, kinds provisional:
kind 10037 (replaceable) announces the wallet's ark address+network;
the zap itself is a plain instant arkoor send; kind 9737 is the
sender-signed receipt tagging recipient, amount, comment, and the
delivered vtxo id — so the receipt is checkable against the actual
payment, unlike a bare attestation.
UX: paste an npub into Send and hal offers the zap (needs both the ark
and sync features; npub decoding is inline bech32 so ark-only builds
stay lean). The nostr layer gains generic publishEvent/fetchEvents,
exposed through the sync seam as wallet.nostrPublish/nostrFetch.
Proven on regtest against a mini NIP-01 relay: announce, resolve,
zap 1,000 sats with a comment, instant receipt+delivery, vtxo match.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/features/ark.js | 122 | ||||
| -rw-r--r-- | src/features/sync.js | 16 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/nostr.js | 17 |
4 files changed, 161 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 1f87541..a6959f2 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -4,7 +4,7 @@ import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; -import { hex, base32nopad } from '@scure/base'; +import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; import { boardFee } from '../ark/board.js'; @@ -91,6 +91,7 @@ export function arkFeature(ctx) { arkConnectPromise = mgr.init().then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + announceArkAddress(mgr); // ark zaps: tell nostr where our mailbox lives const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); // Receives arrive in real time over the mailbox stream; the poll is the @@ -351,6 +352,111 @@ export function arkFeature(ctx) { ui.arkBusy = null; render(); } + // ---- ark zaps (draft protocol, NIP-61-shaped) ---------------------------- + // Lightning zaps (NIP-57) are built around LNURL + bolt11; ark needs + // neither. Mirroring nutzaps (NIP-61): the recipient announces their ark + // address as a replaceable event, the sender pays with a plain instant + // arkoor send and publishes a receipt referencing the delivered vtxo. + // Kinds are provisional until a NIP lands. + const ARK_INFO_KIND = 10037; // replaceable: ["ark", <address>], ["network", <net>] + const ARK_ZAP_KIND = 9737; // receipt: ["p", pk], ["e", note?], ["amount", sats], ["vtxo", id], ["network", net] + + // npub decode without importing the nostr stack (keeps ark-only builds lean) + function npubToHex(s) { + try { + const { prefix, words } = bech32.decode(String(s || '').trim(), 200); + if (prefix !== 'npub') return null; + return hex.encode(new Uint8Array(bech32.fromWords(words))); + } catch { return null; } + } + + // Tell the world where our ark mailbox lives (once per address, and only + // when the sync/nostr feature is present in this build). + function announceArkAddress(mgr) { + if (!wallet.nostrPublish) return; + const addr = mgr.address(); + if (mgr._zapAnnounced === addr) return; + mgr._zapAnnounced = addr; + wallet.nostrPublish({ kind: ARK_INFO_KIND, tags: [['ark', addr], ['network', getNetwork()]] }).catch(() => {}); + } + + // Resolve an npub to a same-network ark address via their announcement. + async function lookupArkZapTarget(pk) { + const events = await wallet.nostrFetch({ kinds: [ARK_INFO_KIND], authors: [pk] }, 6000); + const ev = (events || []).sort((a, b) => b.created_at - a.created_at)[0]; + if (!ev) return { status: 'noark' }; + const addr = (ev.tags.find((t) => t[0] === 'ark') || [])[1]; + const net = (ev.tags.find((t) => t[0] === 'network') || [])[1]; + if (!addr) return { status: 'noark' }; + if (net && net !== getNetwork()) return { status: 'wrongnet', net }; + return { status: 'ready', address: addr }; + } + + async function doArkZap() { + const z = ui.arkZap; + const sats = ctx.parseAmount(z.amount, ctx.getUnit()); + if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } + ui.busy = true; ui.sendError = ''; render(); + try { + const mgr = await connectArk(); + const actionId = await mgr.send(z.address, sats); + 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; + // the receipt is best-effort: the sats are already delivered via mailbox + await wallet.nostrPublish({ + kind: ARK_ZAP_KIND, + content: (z.comment || '').slice(0, 280), + tags: [['p', z.pk], ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], + }).catch(() => {}); + ui.arkZapped = { amountSat: sats, npub: z.npub }; + ui.arkZap = null; + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); + } + + function arkZapView() { + 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')), + h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), + h('div', { class: 'small muted' }, t('tapToProceed'))); + } + const z = ui.arkZap; + if (!z) return null; + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, '⚡ ' + t('arkZapTitle')), + 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 === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, + 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'))) + : 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' + ? (ui.busy + ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn'))) + : null)); + } + // ---- unilateral exit (trustless: no server cooperation) ------------------ const addrScript = (address) => btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); @@ -767,9 +873,23 @@ export function arkFeature(ctx) { return false; }, sendView() { + if (ui.arkZapped || ui.arkZap) return arkZapView(); if (ui.arkSent || ui.arkSend) return arkSendReview(); return null; }, + // An npub pasted into Send becomes an ark zap (needs the nostr seam from + // the sync feature and a connected-able ark). + matchSendText(text) { + const pk = npubToHex(text); + if (!pk || !arkAvailable() || !wallet.nostrFetch) return false; + ui.arkZap = { npub: String(text).trim(), pk, amount: '', comment: '', status: 'lookup' }; + ui.sendError = ''; + render(); + Promise.all([connectArk(), lookupArkZapTarget(pk)]) + .then(([, res]) => { if (ui.arkZap && ui.arkZap.pk === pk) { Object.assign(ui.arkZap, res); render(); } }) + .catch((e) => { if (ui.arkZap && ui.arkZap.pk === pk) { ui.arkZap.status = 'noark'; ui.sendError = e.message; render(); } }); + return true; + }, historyEntries() { if (!ark || !ark.state) return []; return ark.movements() diff --git a/src/features/sync.js b/src/features/sync.js index f927aef..8349b5d 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -22,6 +22,22 @@ export function installSyncWallet(wallet) { return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; }, + // Generic event publish/fetch on the configured sync relays — the seam + // other features (ark zaps) use to speak their own kinds. No-ops when + // sync is disabled or the wallet is offline. + async nostrPublish(partial) { + const sync = getSyncConfig(); + if (this.offline || !sync.enabled || !this.nostr.sk) return null; + this.nostr.setRelays(sync.relays); + return this.nostr.publishEvent(partial); + }, + async nostrFetch(filter, maxWait) { + const sync = getSyncConfig(); + if (this.offline || !sync.enabled) return []; + this.nostr.setRelays(sync.relays); + return this.nostr.fetchEvents(filter, maxWait); + }, + // Pull the latest state from Nostr; apply it if it's newer than what we have. // Returns true if state was applied (so the caller can skip a full scan). async syncFromNostr() { diff --git a/src/i18n.js b/src/i18n.js index e45d662..cfaf187 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -408,6 +408,13 @@ const STR = { arkExitClaimingStatus: 'Exiting {n} sats — almost done, awaiting confirmation', arkExitRetrying: 'hit a snag — retrying automatically', arkExitCancel: 'Cancel this exit', + arkZapTitle: 'Zap over Ark', + arkZapLookup: 'Looking up their Ark address…', + arkZapNoArk: 'This nostr user hasn’t published an Ark address.', + arkZapHint: 'Instant and free — delivered to their Ark wallet, with a zap receipt on nostr.', + arkZapCommentPh: 'Comment (optional)', + arkZapBtn: 'Zap', + arkZapSentTitle: 'Zapped!', arkOpsBoard: '{n} deposit(s) confirming', arkOpsSend: '{n} send(s) finishing up', arkOpsRefresh: '{n} refresh(es) waiting for the next round', diff --git a/src/nostr.js b/src/nostr.js index ccb797d..d0df2fb 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -161,6 +161,23 @@ export class NostrSync { return res.some((x) => x.status === 'fulfilled'); } + // Publish an arbitrary event (finalized here with our key) to the relays. + // Used by features that speak their own event kinds (e.g. ark zaps). + async publishEvent(partial) { + if (!this.sk) return null; + let evt; + try { + evt = finalizeEvent({ created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...partial }, this.sk); + } catch { return null; } + await Promise.allSettled(pool.publish(this.relays, evt)); + return evt; + } + + // Fetch events matching a filter from the relays (best-effort). + async fetchEvents(filter, maxWait = 5000) { + try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; } + } + // Fetch the newest decrypted state across relays, or null. async fetch() { if (!this.sk) return null; |
