diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 09:25:47 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 09:25:47 -0700 |
| commit | 6cce1f7fdbf487222e919bc2f50870d539f19b8c (patch) | |
| tree | 24b1d80d2ebd1c61216b46dfc8f90b11724d47a4 /src/features | |
| parent | c7ba5d5f0c8e80da1133fc6f53cc37bfedc281f2 (diff) | |
| download | coinosv3-6cce1f7fdbf487222e919bc2f50870d539f19b8c.tar.gz coinosv3-6cce1f7fdbf487222e919bc2f50870d539f19b8c.zip | |
BOLT 12 offers with a custom memo on the Spending receive page
Mining pools (Ocean) and payroll pay to a reusable offer rather than a
lightning address, and the memo is how the payer labels it. A
disclosure under the payment address mints one: the registrar's new
NIP-98'd /offer endpoint asks the ASP's CLN for an `amount: any` offer
carrying the memo, keyed to the caller's name (issuer = their address,
so two users' identical memos can't collapse onto one CLN offer). Up to
ten each, identical memos reuse the existing offer, and GET lists them.
Settled offer payments already had a home: the registrar's settle loop
now resolves a custom offer id to its owner and hands it to the same
forwarder the default offer uses — an instant, free arkoor send into
the user's Spending balance.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/names.js | 79 |
1 files changed, 76 insertions, 3 deletions
diff --git a/src/features/names.js b/src/features/names.js index a30038e..788c944 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -40,10 +40,11 @@ export function namesFeature(ctx) { async function post(path, method, payload, signer) { const url = `${REGISTRAR}${path}`; - const body = JSON.stringify(payload); + const hasBody = method !== 'GET'; + const body = hasBody ? JSON.stringify(payload) : ''; const auth = await withTimeout(nip98Header(signer || walletSigner(), url, method, body), 12000, 'signing'); const r = await withTimeout(fetch(url, { - method, headers: { 'content-type': 'application/json', authorization: auth }, body, + method, headers: { 'content-type': 'application/json', authorization: auth }, ...(hasBody ? { body } : {}), }), 12000, 'registrar'); const j = await r.json().catch(() => ({})); if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); @@ -301,7 +302,79 @@ export function namesFeature(ctx) { seg, h('div', { html: qrSvg(addr) }), h('div', { class: 'addr-box', style: 'width:100%;text-align:center;font-size:16px' }, addr), - copyBtn(addr, t('namesCopy'))); + copyBtn(addr, t('namesCopy')), + offerSection()); + } + + // ---- BOLT 12 offers with a memo ---------------------------------------- + // A reusable offer some payers want instead of a lightning address (mining + // pools like Ocean pay to one, and the memo is how you label the payout). + // Settled offer payments ride the registrar's forwarder into Spending, the + // same path the address already uses. + async function loadOffers() { + if (ui.namesOffers) return; + ui.namesOffers = 'loading'; + try { + const j = await post('/offer', 'GET'); + ui.namesOffers = j.offers || []; + } catch (e) { + ui.namesOffers = []; + ui.namesOfferError = e.message; + } + render(); + } + + async function createOffer() { + const memo = (ui.namesOfferMemo || '').trim(); + ui.namesOfferBusy = true; ui.namesOfferError = null; render(); + try { + const j = await post('/offer', 'POST', { memo }); + ui.namesOffers = [ + ...(Array.isArray(ui.namesOffers) ? ui.namesOffers.filter((o) => o.offerId !== j.offerId) : []), + { offerId: j.offerId, memo: j.memo, bolt12: j.bolt12 }, + ]; + ui.namesOfferMemo = ''; + } catch (e) { + ui.namesOfferError = e.message; + } + ui.namesOfferBusy = false; render(); + } + + function offerSection() { + if (!ui.namesOfferOpen) { + return h('button', { + class: 'linklike small', + onClick: () => { ui.namesOfferOpen = true; loadOffers(); render(); }, + }, t('namesOfferOpen')); + } + const offers = Array.isArray(ui.namesOffers) ? ui.namesOffers : []; + return h('div', { class: 'col', style: 'gap:10px;width:100%' }, + h('div', { class: 'divider' }), + h('div', { class: 'row between', style: 'align-items:baseline' }, + h('strong', {}, t('namesOfferTitle')), + h('button', { class: 'linklike small', onClick: () => { ui.namesOfferOpen = false; render(); } }, t('hide'))), + h('p', { class: 'small muted', style: 'margin:0' }, t('namesOfferDesc')), + h('div', { class: 'row gap6' }, + h('input', { + type: 'text', class: 'grow', maxlength: '120', + placeholder: t('namesOfferMemoPh'), + value: ui.namesOfferMemo || '', + onInput: (e) => { ui.namesOfferMemo = e.target.value; }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.namesOfferBusy, onClick: createOffer }, + ui.namesOfferBusy ? h('span', { class: 'spinner sm' }) : t('namesOfferCreate'))), + ui.namesOfferError ? h('div', { class: 'notice err small' }, ui.namesOfferError) : null, + ui.namesOffers === 'loading' ? h('span', { class: 'spinner sm' }) : null, + ...offers.map((o) => + h('div', { class: 'col', style: 'gap:6px;width:100%' }, + h('div', { class: 'small muted' }, o.memo || t('namesOfferNoMemo')), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, o.bolt12), + h('div', { class: 'row gap6' }, + copyBtn(o.bolt12, t('copy')), + h('button', { class: 'btn-sm', onClick: () => { ui.namesOfferQr = ui.namesOfferQr === o.offerId ? null : o.offerId; render(); } }, t('namesOfferQr'))), + ui.namesOfferQr === o.offerId + ? h('div', { style: 'align-self:center', html: qrSvg(o.bolt12.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }) + : null))); } return { |
