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 /names | |
| 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 'names')
| -rw-r--r-- | names/server.js | 41 |
1 files changed, 41 insertions, 0 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') { |
