// Who may open a hosted slot. A slot costs us memory, disk and traffic, so // sign-up asks for one of: // bns a signature from the wallet that owns a BNS name // (one slot per name) // deposit a BCH payment of at least `minSats` to the operator's deposit // address carrying OP_RETURN "pithos:" (one slot per // tx; refunds are an operator decision) // invite a code the operator handed out (one use each) // plus a per-IP limit on sign-ups per day. import path from 'node:path'; import { sha256, hash160, secp256k1, utf8ToBin, binToHex, base64ToBin, cashAddressToLockingBytecode, } from '@bitauth/libauth'; import { readJson, writeJson } from './store.js'; import { electrumCall } from './electrum.js'; export const bnsMessage = (id) => `pithos-hosted signup ${id}`; export const depositMemo = (id) => `pithos:${id}`; function varStr(bytes) { const n = bytes.length; const prefix = n < 0xfd ? [n] : [0xfd, n & 0xff, (n >> 8) & 0xff]; return Uint8Array.from([...prefix, ...bytes]); } // BIP-137: recover the signer, then compare its locking bytecode with the // address we expected. Recovery alone proves nothing: any signature recovers // to *some* key. export function signedByAddress(message, signatureB64, expectedAddress) { let sig; try { sig = base64ToBin(String(signatureB64).trim()); } catch { return false; } if (!(sig instanceof Uint8Array) || sig.length !== 65 || sig[0] < 27 || sig[0] > 34) return false; const compressed = sig[0] >= 31; const digest = sha256.hash(sha256.hash(Uint8Array.from([ ...varStr(utf8ToBin('Bitcoin Signed Message:\n')), ...varStr(utf8ToBin(message)), ]))); const recover = compressed ? secp256k1.recoverPublicKeyCompressed : secp256k1.recoverPublicKeyUncompressed; const pub = recover(sig.slice(1), (sig[0] - 27) & 3, digest); if (typeof pub === 'string') return false; const signer = binToHex(Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(pub), 0x88, 0xac])); const expected = cashAddressToLockingBytecode(expectedAddress); return typeof expected !== 'string' && binToHex(expected.bytecode) === signer; } export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCall } = {}) { const file = path.join(root, 'gate.json'); const state = readJson(file, { names: {}, txids: {}, invites: {}, ip: {} }); const save = () => writeJson(file, state); const today = () => new Date().toISOString().slice(0, 10); // Claims in flight. A proof is checked across awaits (BNS lookup, Electrum), // so the name, tx or IP slot is taken before the first await and given back // if the check fails; otherwise parallel sign-ups could share one proof. const pending = new Set(); function claim(key, id) { if (pending.has(key)) throw gateError('this sign-up is already being checked; try again in a moment', 429); pending.add(key); return () => pending.delete(key); } const used = (map, k, id) => Object.hasOwn(map, k) && map[k] !== id; async function bns({ name, signature }, id) { if (!cfg.bns) throw gateError('BNS sign-up is not enabled here'); const n = String(name || '').trim().toLowerCase(); if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(n)) throw gateError('enter a BNS name like yourname.bch'); if (used(state.names, n, id)) throw gateError(`${n} already opened a hosted slot`); const r = await fetchImpl(`${cfg.bns.api.replace(/\/$/, '')}/api/name/${encodeURIComponent(n)}`); const info = await r.json().catch(() => ({})); if (!r.ok || !info.registered || !info.owner) throw gateError(`${n} is not a registered BNS name`); if (!signedByAddress(bnsMessage(id), signature, info.owner)) { throw gateError(`the signature is not from the wallet that owns ${n} (${info.owner})`); } return { type: 'bns', name: n, owner: info.owner }; } async function deposit({ txid }, id) { const d = cfg.deposit; if (!d) throw gateError('deposit sign-up is not enabled here'); const t = String(txid || '').trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(t)) throw gateError('enter the 64-character transaction id'); if (used(state.txids, t, id)) throw gateError('this deposit already opened a hosted slot'); const tx = await electrum(d.electrum, 'blockchain.transaction.get', [t, true]); if ((tx.confirmations || 0) < (d.confirmations ?? 1)) throw gateError('the deposit has no confirmation yet; try again in about ten minutes'); const want = cashAddressToLockingBytecode(d.address); if (typeof want === 'string') throw new Error('deposit address in the gateway config is invalid'); const wantHex = binToHex(want.bytecode); const memoHex = Buffer.from(depositMemo(id), 'utf8').toString('hex'); let paid = 0; let memo = false; for (const o of tx.vout || []) { const hex = String(o.scriptPubKey?.hex || ''); if (hex === wantHex) paid += Math.round(Number(o.value) * 1e8); if (hex.startsWith('6a') && hex.includes(memoHex)) memo = true; } if (paid < d.minSats) throw gateError(`the deposit must send at least ${d.minSats} sats to ${d.address}`); if (!memo) throw gateError(`the deposit must carry the memo "${depositMemo(id)}" (OP_RETURN)`); return { type: 'deposit', txid: t, sats: paid }; } function invite({ code }, id) { const c = String(code || '').trim(); if (!(cfg.invites || []).includes(c)) throw gateError('this invite code is not valid'); if (used(state.invites, c, id)) throw gateError('this invite code was already used'); return { type: 'invite', code: c }; } return { describe() { return { bns: !!cfg.bns, deposit: cfg.deposit ? { address: cfg.deposit.address, minSats: cfg.deposit.minSats } : null, invite: !!(cfg.invites && cfg.invites.length), }; }, async admit(proof, id, ip) { const day = today(); for (const key of Object.keys(state.ip)) if (!key.startsWith(day)) delete state.ip[key]; const ipKey = `${day} ${ip}`; const kind = proof?.type; if (!['bns', 'deposit', 'invite'].includes(kind)) throw gateError('choose how to open your slot: BNS name, deposit or invite', 400); // The IP count goes up now and comes back down on failure. if ((state.ip[ipKey] || 0) >= (cfg.perIpPerDay ?? 3)) throw gateError('too many sign-ups from this network today', 429); state.ip[ipKey] = (state.ip[ipKey] || 0) + 1; const what = kind === 'bns' ? `n:${String(proof.name || '').trim().toLowerCase()}` : kind === 'deposit' ? `t:${String(proof.txid || '').trim().toLowerCase()}` : `i:${String(proof.code || '').trim()}`; let unclaim = () => {}; try { unclaim = claim(what, id); const r = kind === 'bns' ? await bns(proof, id) : kind === 'deposit' ? await deposit(proof, id) : invite(proof, id); if (r.name) state.names[r.name] = id; if (r.txid) state.txids[r.txid] = id; if (r.code) state.invites[r.code] = id; save(); return r; } catch (e) { state.ip[ipKey] = Math.max(0, (state.ip[ipKey] || 1) - 1); throw e; } finally { unclaim(); } }, }; } function gateError(message, status = 403) { return Object.assign(new Error(message), { status }); }