diff options
| -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', |
