diff options
| author | Adam Soltys <[email protected]> | 2026-08-16 00:38:49 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-16 00:38:49 -0700 |
| commit | f23a0488854a58bea8c0cb2ddc8e1c424a6828f4 (patch) | |
| tree | a773b37ab693e70cd1cb54a81eed598a266d0e3c /names/server.js | |
| parent | b3e3468579c7ea996cd8da6fa3cc4ff9004cfa32 (diff) | |
| download | coinosv3-f23a0488854a58bea8c0cb2ddc8e1c424a6828f4.tar.gz coinosv3-f23a0488854a58bea8c0cb2ddc8e1c424a6828f4.zip | |
Hats: put your money where your head is — supporter hats perch above the avatar everywhere it appears, from 21-sat beanie to 2.1M-sat top hat; the crown stays on the founder
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
Diffstat (limited to 'names/server.js')
| -rw-r--r-- | names/server.js | 136 |
1 files changed, 136 insertions, 0 deletions
diff --git a/names/server.js b/names/server.js index 0682f9a..b484be1 100644 --- a/names/server.js +++ b/names/server.js @@ -321,6 +321,34 @@ async function publishZapReceipt(pending, inv) { } catch (e) { log('zap receipt failed: ' + e.message); } } +// --------------------------------------------------------------------------- +// Hats — cosmetic supporter hats, shown above the buyer's avatar in coinos. +// Ownership is a paid CLN invoice; the registry here is what clients trust +// (a kind-0 field would be self-asserted). Money stays on the node: it IS +// the support. The crown is Adam's and is not for sale. +// --------------------------------------------------------------------------- + +const HAT_ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; +const HAT_PRICES = { + beanie: 21, party: 2100, trucker: 2100, cowboy: 21000, fedora: 21000, + bowler: 210000, wizard: 210000, top: 2100000, +}; + +function hatRec(pk) { + const r = (state.hats || {})[pk]; + const owned = r ? [...(r.owned || [])] : []; + if (pk === HAT_ADMIN_PK && !owned.includes('crown')) owned.push('crown'); + const equipped = r ? (r.equipped || null) : (pk === HAT_ADMIN_PK ? 'crown' : null); + return { owned, equipped }; +} + +function grantHat(pk, hat) { + state.hats ||= {}; + const r = state.hats[pk] ||= { owned: [], equipped: null }; + if (!r.owned.includes(hat)) r.owned.push(hat); + r.equipped = hat; // a fresh purchase goes straight on the head +} + async function settleLoop() { if (!ln || !fwd) return; state.lastPayIndex = state.lastPayIndex || 0; @@ -334,6 +362,15 @@ async function settleLoop() { } state.lastPayIndex = inv.pay_index || state.lastPayIndex; persist(); + // hat purchases settle here too — grant and keep the sats (that's the point) + const hatPending = state.hatInvoices && state.hatInvoices[inv.payment_hash]; + if (hatPending) { + grantHat(hatPending.pubkey, hatPending.hat); + delete state.hatInvoices[inv.payment_hash]; + persist(); + log(`hat sold: ${hatPending.hat} to ${hatPending.pubkey.slice(0, 12)}`); + continue; + } 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) @@ -628,6 +665,105 @@ Bun.serve({ return json({ name, domain, uri: r.uri }); } + // --- hats ------------------------------------------------------------ + + // Batched display lookup: which hat is each of these heads wearing? + // Public data (a hat is worn in public by construction). + if (url.pathname === '/hats' && req.method === 'GET') { + const pks = (url.searchParams.get('pks') || '') + .split(',').filter((p) => /^[0-9a-f]{64}$/.test(p)).slice(0, 100); + const hats = {}; + for (const pk of pks) hats[pk] = hatRec(pk).equipped; + return json({ hats }); + } + + // The shop view: everything one pubkey owns, plus the price list (the + // client shows what the server would actually charge). + const hm = url.pathname.match(/^\/hats\/([0-9a-f]{64})$/); + if (hm && req.method === 'GET') { + return json({ ...hatRec(hm[1]), prices: HAT_PRICES }); + } + + if (url.pathname === '/hats/invoice' && req.method === 'POST') { + if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); + let body; + try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const hat = String(body.hat || ''); + const sat = HAT_PRICES[hat]; + if (!sat) return json({ error: 'no such hat' }, 400); + if (hatRec(a.pubkey).owned.includes(hat)) return json({ error: 'you already own that hat' }, 400); + if (!ln) return json({ error: 'the hat stand is closed right now' }, 503); + try { + const inv = await ln.call('invoice', { + amount_msat: sat * 1000, + label: `hat-${hat}-${a.pubkey.slice(0, 8)}-${Date.now()}`, + description: `coinos hat: ${hat}`, + expiry: 900, + }); + state.hatInvoices ||= {}; + state.hatInvoices[inv.payment_hash] = { pubkey: a.pubkey, hat, sat, ts: Date.now() }; + for (const [h2, v] of Object.entries(state.hatInvoices)) { + if (Date.now() - v.ts > 7 * 86400_000) delete state.hatInvoices[h2]; + } + persist(); + log(`hat invoice: ${hat} (${sat} sat) for ${a.pubkey.slice(0, 12)}`); + return json({ invoice: inv.bolt11, paymentHash: inv.payment_hash, sat }); + } catch (e) { + log('hat invoice failed: ' + e.message); + return json({ error: 'could not create an invoice' }, 500); + } + } + + // The buyer pings us right after paying, so the hat appears without + // waiting on the settle loop. Idempotent with it: whoever runs first + // grants, the other finds it done. + if (url.pathname === '/hats/claim' && req.method === 'POST') { + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); + let body; + try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const hash = String(body.paymentHash || ''); + const hat = String(body.hat || ''); + const pending = (state.hatInvoices || {})[hash]; + if (!pending) { + // the settle loop may have beaten us to it + if (hat && hatRec(a.pubkey).owned.includes(hat)) return json(hatRec(a.pubkey)); + return json({ error: 'unknown invoice' }, 404); + } + if (pending.pubkey !== a.pubkey) return json({ error: 'not your invoice' }, 403); + if (!ln) return json({ error: 'the hat stand is closed right now' }, 503); + try { + const r = await ln.call('listinvoices', { payment_hash: hash }); + if ((r.invoices || [])[0]?.status !== 'paid') return json({ error: 'invoice not paid yet' }, 402); + } catch (e) { + return json({ error: 'could not check the invoice' }, 500); + } + grantHat(pending.pubkey, pending.hat); + delete state.hatInvoices[hash]; + persist(); + log(`hat sold (claim): ${pending.hat} to ${pending.pubkey.slice(0, 12)}`); + return json(hatRec(a.pubkey)); + } + + if (url.pathname === '/hats/equip' && req.method === 'POST') { + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); + let body; + try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const hat = body.hat ? String(body.hat) : null; + if (hat && !hatRec(a.pubkey).owned.includes(hat)) return json({ error: 'you do not own that hat' }, 403); + state.hats ||= {}; + const r = state.hats[a.pubkey] ||= { owned: [], equipped: null }; + r.equipped = hat; + persist(); + return json(hatRec(a.pubkey)); + } + // --- LNURL-pay (LUD-06/16, NIP-57 zaps) ------------------------------ const lm = url.pathname.match(/^\/(?:\.well-known\/lnurlp|lnurlp)\/([a-z0-9._-]{1,30})$/i); if (lm && req.method === 'GET') { |
