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 | |
| 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
| -rw-r--r-- | names/server.js | 41 | ||||
| -rw-r--r-- | src/features/names.js | 79 | ||||
| -rw-r--r-- | src/i18n.js | 7 |
3 files changed, 124 insertions, 3 deletions
diff --git a/names/server.js b/names/server.js index 99552dc..74bcb9b 100644 --- a/names/server.js +++ b/names/server.js @@ -295,6 +295,8 @@ async function settleLoop() { persist(); const offerId = inv.local_offer_id; let name = offerId && Object.keys(state.names).find((n) => state.names[n].offerId === offerId); + // custom offers (a memo'd bolt12 the user minted for e.g. a mining pool) + if (!name && offerId && state.offers && state.offers[offerId]) name = state.offers[offerId].key; // LNURL invoices we minted: recorded against their payment hash const pending = state.invoices && state.invoices[inv.payment_hash]; if (!name && pending) name = pending.key; @@ -440,6 +442,45 @@ Bun.serve({ return json({ results }); } + // A user's own BOLT 12 offers with a custom memo — what a mining pool or + // payroll wants to send to. Settled payments ride the same forwarder as + // the name's default offer, so they land in the user's Spending balance. + if (url.pathname === '/offer' && (req.method === 'POST' || req.method === 'GET')) { + if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); + const bodyText = req.method === 'POST' ? await req.text() : ''; + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); + const key = Object.keys(state.names) + .find((k) => state.names[k].pubkey === a.pubkey || state.names[k].manager === a.pubkey); + if (!key) return json({ error: 'claim a name first' }, 400); + state.offers = state.offers || {}; + if (req.method === 'GET') { + return json({ offers: Object.entries(state.offers) + .filter(([, o]) => o.key === key) + .map(([offerId, o]) => ({ offerId, memo: o.memo, bolt12: o.bolt12, created: o.created })) }); + } + if (!ln) return json({ error: 'offers unavailable' }, 503); + let body; + try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const memo = String(body.memo || '').slice(0, 120).trim(); + const mine = Object.entries(state.offers).filter(([, o]) => o.key === key); + const dup = mine.find(([, o]) => o.memo === memo); + if (dup) return json({ ok: true, offerId: dup[0], memo, bolt12: dup[1].bolt12, existing: true }); + if (mine.length >= 10) return json({ error: 'too many offers' }, 400); + let o; + try { + // `issuer` keeps two users' identical memos from collapsing onto one + // CLN offer (it dedupes by the offer's contents). + o = await ln.call('offer', { amount: 'any', description: memo || key, issuer: key }); + } catch (e) { + return json({ error: e.message }, 500); + } + state.offers[o.offer_id] = { key, memo, bolt12: o.bolt12, created: Date.now() }; + persist(); + log(`offer for ${key}: ${memo ? JSON.stringify(memo) : '(no memo)'}`); + return json({ ok: true, offerId: o.offer_id, memo, bolt12: o.bolt12 }); + } + // Availability / lookup. Public data (it's DNS). const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/); if (m && req.method === 'GET') { 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 { diff --git a/src/i18n.js b/src/i18n.js index 3dd710a..b387285 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -548,6 +548,13 @@ const STR = { namesClaim: 'Claim', namesClaimed: 'You are now {name}', namesCopy: 'Copy address', + namesOfferOpen: 'Need a Lightning offer (BOLT 12)?', + namesOfferTitle: 'Lightning offer', + namesOfferDesc: 'A reusable BOLT 12 offer some payers want instead of an address — mining pools like Ocean pay to one. Payments land in Spending exactly like your address.', + namesOfferMemoPh: 'Label (e.g. Ocean payout)', + namesOfferCreate: 'Create', + namesOfferNoMemo: 'No label', + namesOfferQr: 'QR', namesRelease: 'Release', namesReleased: 'Name released', namesNeedArk: 'Your wallet is still connecting — try again in a moment', |
