aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--names/server.js41
-rw-r--r--src/features/names.js79
-rw-r--r--src/i18n.js7
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',