127 lines
6.2 KiB
JavaScript
127 lines
6.2 KiB
JavaScript
|
|
// 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:<account id>" (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);
|
||
|
|
|
||
|
|
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 (state.names[n] && 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 (state.txids[t] && 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 (state.invites[c] && 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}`;
|
||
|
|
if ((state.ip[ipKey] || 0) >= (cfg.perIpPerDay ?? 3)) throw gateError('too many sign-ups from this network today', 429);
|
||
|
|
const kind = proof?.type;
|
||
|
|
let r;
|
||
|
|
if (kind === 'bns') r = await bns(proof, id);
|
||
|
|
else if (kind === 'deposit') r = await deposit(proof, id);
|
||
|
|
else if (kind === 'invite') r = invite(proof, id);
|
||
|
|
else throw gateError('choose how to open your slot: BNS name, deposit or invite', 400);
|
||
|
|
if (r.name) state.names[r.name] = id;
|
||
|
|
if (r.txid) state.txids[r.txid] = id;
|
||
|
|
if (r.code) state.invites[r.code] = id;
|
||
|
|
state.ip[ipKey] = (state.ip[ipKey] || 0) + 1;
|
||
|
|
save();
|
||
|
|
return r;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function gateError(message, status = 403) { return Object.assign(new Error(message), { status }); }
|