diff options
| author | Adam Soltys <[email protected]> | 2026-10-21 22:16:57 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-21 22:16:57 -0700 |
| commit | 5c007a2540d5a033c0934844eff68688af68817c (patch) | |
| tree | f006cc1cdcb02335467c71d760a34ad8fe3b44f5 | |
| parent | 8229ea08caeb6d03efa56205b9526e2751217162 (diff) | |
| download | coinosv3-server-5c007a2540d5a033c0934844eff68688af68817c.tar.gz coinosv3-server-5c007a2540d5a033c0934844eff68688af68817c.zip | |
names: mint nothing payable for a wallet on another network, and retire what was minted
A staging name is a mutinynet wallet, but every claim got a mainnet BOLT12
offer on our node published in its record, and custom offers and POS sales
were minted there too: real sats paid that way had no route onward (2,092
sat parked on 2026-10-22). otherNetwork() — the record's ark address decides,
a staging name without one is mutinynet — now gates the claim's stand-in
offer, custom offers and POS invoices (LNURL already refused staging).
At start, retireOtherNetworkOffers() disables the offers already out there
(83 staging names, 13 custom offers), takes their lno= out of the published
DNS record and drops them from state; idempotent, so a failure retries next
start. [email protected] keeps its offers: its wallet is mainnet.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | names/server.js | 68 |
1 files changed, 66 insertions, 2 deletions
diff --git a/names/server.js b/names/server.js index fbb7737..a0a0bb7 100644 --- a/names/server.js +++ b/names/server.js @@ -333,6 +333,18 @@ if (CFG.forwarder?.mnemonic) { log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`); } +// Is this name's wallet on another network than this registrar's node? Then +// nothing payable may be minted for it here: a mainnet offer for a mutinynet +// wallet takes real sats that have no route onward (2,092 sat parked +// 2026-10-22, paid through staging names' mainnet offers). The ark address +// decides; without one, a staging name is a mutinynet wallet. +const REGISTRAR_TESTNET = (CFG.forwarder?.network || 'mainnet') !== 'mainnet'; +function otherNetwork(uri, domain) { + const ark = arkParamOf(uri || ''); + if (ark) return ark.startsWith('tark') !== REGISTRAR_TESTNET; + return domain === 'staging.coinos.io' && !REGISTRAR_TESTNET; +} + async function makeOffer(address) { // "name@domain" if (!ln) return null; const o = await ln.call('offer', { amount: 'any', description: address }); @@ -1130,6 +1142,9 @@ Bun.serve({ } // Ring up a sale: {invoice:{fiat}} (the terminal's shape) or {amount} in sats. if (url.pathname === '/pos/invoice' && req.method === 'POST') { + if (otherNetwork(state.names[a.key]?.uri, state.names[a.key]?.domain)) { + return json({ error: 'this name\'s wallet is on another network' }, 400); + } if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429); if (!ln) return json({ error: 'invoicing is closed right now' }, 503); const body = await req.json().catch(() => ({})); @@ -1361,6 +1376,9 @@ Bun.serve({ 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); + if (otherNetwork(state.names[key]?.uri, state.names[key]?.domain)) { + return json({ error: 'this name\'s wallet is on another network — mint the offer in the wallet' }, 400); + } let o; try { // `issuer` keeps two users' identical memos from collapsing onto one @@ -1904,8 +1922,10 @@ Bun.serve({ // The record's lno=: the owner's own offer when they set one, else a // static offer on our node with the settle forwarder behind it. let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null; - if (lno) { - // their node answers fetchinvoice directly now — retire our stand-in + if (lno || otherNetwork(claim.uri, domain)) { + // their node answers fetchinvoice directly now, or their wallet is on + // another network and our offer could only strand what it takes — + // retire our stand-in either way if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {}); offer = null; } else if (!offer) { @@ -2023,4 +2043,48 @@ Bun.serve({ }); settleLoop().catch((e) => log('settle loop died:', e.message)); +retireOtherNetworkOffers().catch((e) => log('retiring other-network offers failed: ' + e.message)); + +// Names that got one of our offers before otherNetwork() existed (staging +// names: 84 of them, plus 15 custom offers, on 2026-10-22): disable the offer +// on the node, take its lno= out of the published record, drop custom ones. +// Idempotent — a name it fixed has no offerId left — so every start re-runs +// it and a name that failed (Cloudflare down, say) gets another go. +async function retireOtherNetworkOffers() { + if (!ln) return; + let names = 0, customs = 0; + const gone = (e) => /unknown offer|already disabled|not found/i.test(e.message); + for (const [key, rec] of Object.entries(state.names)) { + if (!rec.offerId || !otherNetwork(rec.uri, rec.domain)) continue; + try { + await ln.call('disableoffer', { offer_id: rec.offerId }).catch((e) => { if (!gone(e)) throw e; }); + const uri = rec.bolt12 ? withoutLno(rec.uri, rec.bolt12) : rec.uri; + if (uri !== rec.uri) rec.recordId = await cfWrite(key.split('@')[0], rec.domain, uri, rec.recordId); + rec.uri = uri; + delete rec.offerId; + delete rec.bolt12; + rec.updated = Date.now(); + persist(); + names++; + } catch (e) { log(`retiring the offer for ${key} failed: ${e.message}`); } + } + for (const [id, o] of Object.entries(state.offers || {})) { + const rec = state.names[o.key]; + if (!rec || !otherNetwork(rec.uri, rec.domain)) continue; + try { + await ln.call('disableoffer', { offer_id: id }).catch((e) => { if (!gone(e)) throw e; }); + delete state.offers[id]; + persist(); + customs++; + } catch (e) { log(`retiring custom offer ${id} for ${o.key} failed: ${e.message}`); } + } + if (names || customs) log(`retired our offers for wallets on another network: ${names} names, ${customs} custom offers`); +} + +// The published uri minus our lno= parameter (the owner's other params kept). +function withoutLno(uri, bolt12) { + const [base, query = ''] = uri.split('?'); + const kept = query.split('&').filter((kv) => kv && kv.toLowerCase() !== `lno=${bolt12.toLowerCase()}`); + return kept.length ? `${base}?${kept.join('&')}` : base; +} log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s), offers ${ln ? 'on' : 'off'}, forwarder ${fwd ? 'on' : 'off'}`); |
