aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 09:25:47 -0700
committerAdam Soltys <[email protected]>2026-08-03 09:25:47 -0700
commit6cce1f7fdbf487222e919bc2f50870d539f19b8c (patch)
tree24b1d80d2ebd1c61216b46dfc8f90b11724d47a4
parentc7ba5d5f0c8e80da1133fc6f53cc37bfedc281f2 (diff)
downloadcoinosv3-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.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',