287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
// A tiny nostr network on localhost, for the tests: a relay (NIP-01, optional NIP-42 AUTH), a lightning address
|
|
// server that hands out zap invoices and publishes receipts when they're "paid", and a Nostr Wallet Connect wallet.
|
|
// No dependencies; the WebSocket server is the bare RFC 6455 handshake and frames.
|
|
//
|
|
// node tools/devnet.mjs [--auth] prints one JSON line with the relay, servers and test keys, then serves
|
|
|
|
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { finalizeEvent, verifyEvent, now, npubEncode } from '../lib/nostr.js';
|
|
import { generatePrivateKey, hex, getPublicKey, nip04Decrypt, nip04Encrypt, nip44ConversationKey, nip44Decrypt, nip44Encrypt, unhex } from '../lib/crypto.js';
|
|
import { invoiceMsats } from '../lib/zap.js';
|
|
|
|
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
|
|
function frame(text, op = 0x1) {
|
|
const data = Buffer.from(text);
|
|
let head;
|
|
if (data.length < 126) head = Buffer.from([0x80 | op, data.length]);
|
|
else if (data.length < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | op; head[1] = 126; head.writeUInt16BE(data.length, 2); }
|
|
else { head = Buffer.alloc(10); head[0] = 0x80 | op; head[1] = 127; head.writeBigUInt64BE(BigInt(data.length), 2); }
|
|
return Buffer.concat([head, data]);
|
|
}
|
|
|
|
function acceptWebSockets(server, onConnection) {
|
|
server.sockets = new Set();
|
|
server.on('upgrade', (req, socket) => {
|
|
server.sockets.add(socket);
|
|
socket.on('close', () => server.sockets.delete(socket));
|
|
const key = req.headers['sec-websocket-key'];
|
|
if (!key) { socket.destroy(); return; }
|
|
const accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
|
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
let buf = Buffer.alloc(0);
|
|
let parts = [];
|
|
let open = true;
|
|
const conn = {
|
|
send: (text) => { if (open) socket.write(frame(text)); },
|
|
close: () => { if (open) { open = false; try { socket.end(Buffer.from([0x88, 0])); } catch {} } },
|
|
onmessage: null,
|
|
onclose: null,
|
|
};
|
|
socket.on('data', (chunk) => {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
for (;;) {
|
|
if (buf.length < 2) return;
|
|
const fin = buf[0] & 0x80, op = buf[0] & 0x0f, masked = buf[1] & 0x80;
|
|
let len = buf[1] & 0x7f, off = 2;
|
|
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; }
|
|
else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; }
|
|
const m = masked ? 4 : 0;
|
|
if (buf.length < off + m + len) return;
|
|
const payload = Buffer.from(buf.subarray(off + m, off + m + len));
|
|
if (masked) { const mask = buf.subarray(off, off + 4); for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; }
|
|
buf = buf.subarray(off + m + len);
|
|
if (op === 0x8) { conn.close(); socket.destroy(); return; }
|
|
if (op === 0x9) { socket.write(frame(payload.toString(), 0xa)); continue; }
|
|
if (op === 0x1 || op === 0x0) {
|
|
parts.push(payload);
|
|
if (fin) { const text = Buffer.concat(parts).toString('utf8'); parts = []; conn.onmessage?.(text); }
|
|
}
|
|
}
|
|
});
|
|
socket.on('close', () => { open = false; conn.onclose?.(); });
|
|
socket.on('error', () => { open = false; });
|
|
onConnection(conn, req);
|
|
});
|
|
}
|
|
|
|
export function matches(f, e) {
|
|
if (f.ids && !f.ids.includes(e.id)) return false;
|
|
if (f.authors && !f.authors.includes(e.pubkey)) return false;
|
|
if (f.kinds && !f.kinds.includes(e.kind)) return false;
|
|
if (f.since && e.created_at < f.since) return false;
|
|
if (f.until && e.created_at > f.until) return false;
|
|
for (const [k, vals] of Object.entries(f)) {
|
|
if (k.length === 2 && k[0] === '#' && Array.isArray(vals)) {
|
|
if (!e.tags.some((t) => t[0] === k[1] && vals.includes(t[1]))) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const isReplaceable = (k) => k === 0 || k === 3 || (k >= 10000 && k < 20000);
|
|
const isEphemeral = (k) => k >= 20000 && k < 30000;
|
|
|
|
export async function startDevnet({ auth = false, port = 0, lnPort = 0 } = {}) {
|
|
const events = [];
|
|
const subs = new Set();
|
|
const lnKey = generatePrivateKey();
|
|
const lnPub = hex(getPublicKey(lnKey));
|
|
const walletKey = generatePrivateKey();
|
|
const walletPub = hex(getPublicKey(walletKey));
|
|
const walletClient = generatePrivateKey();
|
|
const invoices = new Map();
|
|
const stats = { published: 0, authed: 0, paid: 0, rejected: 0 };
|
|
|
|
function store(e) {
|
|
if (isEphemeral(e.kind)) return;
|
|
if (isReplaceable(e.kind)) {
|
|
const i = events.findIndex((x) => x.pubkey === e.pubkey && x.kind === e.kind);
|
|
if (i >= 0) { if (events[i].created_at > e.created_at) return; events.splice(i, 1); }
|
|
}
|
|
if (!events.some((x) => x.id === e.id)) events.push(e);
|
|
}
|
|
|
|
function broadcast(e) {
|
|
for (const s of subs) if (s.filters.some((f) => matches(f, e)) && s.allowed(e)) s.conn.send(JSON.stringify(['EVENT', s.id, e]));
|
|
}
|
|
|
|
function accept(e) {
|
|
stats.published += 1;
|
|
store(e);
|
|
broadcast(e);
|
|
walletBot(e);
|
|
}
|
|
|
|
// ---- the relay
|
|
const relayServer = http.createServer((req, res) => {
|
|
res.writeHead(200, { 'content-type': 'application/nostr+json', 'access-control-allow-origin': '*' });
|
|
res.end(JSON.stringify({ name: 'slyng devnet', supported_nips: [1, 11, 42] }));
|
|
});
|
|
acceptWebSockets(relayServer, (conn) => {
|
|
const challenge = crypto.randomBytes(8).toString('hex');
|
|
let authedAs = null;
|
|
const mine = new Map();
|
|
const private1059 = (e) => !auth || e.kind !== 1059 || (authedAs && e.tags.some((t) => t[0] === 'p' && t[1] === authedAs));
|
|
if (auth) conn.send(JSON.stringify(['AUTH', challenge]));
|
|
conn.onmessage = (raw) => {
|
|
let msg;
|
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
const [type, a, ...rest] = msg;
|
|
if (type === 'EVENT') {
|
|
const e = a;
|
|
if (!verifyEvent(e)) { stats.rejected += 1; conn.send(JSON.stringify(['OK', e?.id || '', false, 'invalid: bad signature'])); return; }
|
|
conn.send(JSON.stringify(['OK', e.id, true, '']));
|
|
accept(e);
|
|
} else if (type === 'REQ') {
|
|
const filters = rest.filter((f) => f && typeof f === 'object');
|
|
if (auth && !authedAs && filters.some((f) => f.kinds?.includes(1059))) {
|
|
conn.send(JSON.stringify(['CLOSED', a, 'auth-required: private notes need AUTH']));
|
|
return;
|
|
}
|
|
const s = { id: a, filters, conn, allowed: private1059 };
|
|
mine.set(a, s);
|
|
subs.add(s);
|
|
const sent = new Set();
|
|
for (const f of filters) {
|
|
const hits = events.filter((e) => matches(f, e) && private1059(e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || 500);
|
|
for (const e of hits) if (!sent.has(e.id)) { sent.add(e.id); conn.send(JSON.stringify(['EVENT', a, e])); }
|
|
}
|
|
conn.send(JSON.stringify(['EOSE', a]));
|
|
} else if (type === 'CLOSE') {
|
|
const s = mine.get(a);
|
|
if (s) { subs.delete(s); mine.delete(a); }
|
|
} else if (type === 'AUTH') {
|
|
const e = a;
|
|
const ok = verifyEvent(e) && e.kind === 22242 && e.tags.some((t) => t[0] === 'challenge' && t[1] === challenge)
|
|
&& Math.abs(e.created_at - now()) < 600;
|
|
if (ok) { authedAs = e.pubkey; stats.authed += 1; }
|
|
conn.send(JSON.stringify(['OK', e?.id || '', ok, ok ? '' : 'auth-required: bad auth']));
|
|
}
|
|
};
|
|
conn.onclose = () => { for (const s of mine.values()) subs.delete(s); mine.clear(); };
|
|
});
|
|
|
|
await new Promise((r) => relayServer.listen(port, '127.0.0.1', r));
|
|
const relayUrl = `ws://127.0.0.1:${relayServer.address().port}`;
|
|
|
|
// ---- the lightning address server (LUD-06 / LUD-16 with NIP-57 zaps)
|
|
function settle(pr) {
|
|
const inv = invoices.get(pr);
|
|
if (!inv) return null;
|
|
if (!inv.paid) {
|
|
inv.paid = true;
|
|
stats.paid += 1;
|
|
if (inv.zapRequest) {
|
|
const zr = inv.zapRequest;
|
|
const tags = [['p', zr.tags.find((t) => t[0] === 'p')[1]], ['P', zr.pubkey], ['bolt11', pr], ['description', JSON.stringify(zr)], ['preimage', inv.preimage]];
|
|
const e = zr.tags.find((t) => t[0] === 'e');
|
|
if (e) tags.push(['e', e[1]]);
|
|
accept(finalizeEvent({ kind: 9735, created_at: now(), tags, content: '' }, lnKey));
|
|
}
|
|
}
|
|
return inv.preimage;
|
|
}
|
|
|
|
const lnServer = http.createServer((req, res) => {
|
|
const u = new URL(req.url, 'http://x');
|
|
const json = (code, body) => {
|
|
res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (req.method === 'OPTIONS') { res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }); res.end(); return; }
|
|
let m = /^\/\.well-known\/lnurlp\/([a-z0-9._-]+)$/.exec(u.pathname);
|
|
if (m) {
|
|
json(200, {
|
|
tag: 'payRequest', callback: `${lnBase}/lnurlp/callback/${m[1]}`, minSendable: 1000, maxSendable: 100_000_000,
|
|
metadata: JSON.stringify([['text/plain', `sats for ${m[1]}`]]), allowsNostr: true, nostrPubkey: lnPub, commentAllowed: 0,
|
|
});
|
|
return;
|
|
}
|
|
m = /^\/lnurlp\/callback\/([a-z0-9._-]+)$/.exec(u.pathname);
|
|
if (m) {
|
|
const amount = Number(u.searchParams.get('amount'));
|
|
if (!amount || amount % 1000) { json(200, { status: 'ERROR', reason: 'amount must be whole sats' }); return; }
|
|
let zapRequest = null;
|
|
const raw = u.searchParams.get('nostr');
|
|
if (raw) {
|
|
try { zapRequest = JSON.parse(raw); } catch {}
|
|
const amt = zapRequest?.tags?.find((t) => t[0] === 'amount')?.[1];
|
|
if (!zapRequest || zapRequest.kind !== 9734 || !verifyEvent(zapRequest) || (amt && Number(amt) !== amount)) {
|
|
json(200, { status: 'ERROR', reason: 'bad zap request' });
|
|
return;
|
|
}
|
|
}
|
|
const tail = Array.from(crypto.randomBytes(40), (b) => 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'[b & 31]).join('');
|
|
const pr = `lnbc${amount / 100}n1p${tail}`;
|
|
invoices.set(pr, { amount, zapRequest, paid: false, preimage: crypto.randomBytes(32).toString('hex'), name: m[1] });
|
|
json(200, { pr, routes: [] });
|
|
return;
|
|
}
|
|
if (u.pathname === '/settle') {
|
|
const preimage = settle(u.searchParams.get('pr'));
|
|
json(preimage ? 200 : 404, preimage ? { preimage } : { error: 'no such invoice' });
|
|
return;
|
|
}
|
|
if (u.pathname === '/stats') { json(200, { ...stats, events: events.length }); return; }
|
|
json(404, { error: 'not found' });
|
|
});
|
|
await new Promise((r) => lnServer.listen(lnPort, '127.0.0.1', r));
|
|
const lnHost = `127.0.0.1:${lnServer.address().port}`;
|
|
const lnBase = `http://${lnHost}`;
|
|
|
|
// ---- the Nostr Wallet Connect wallet: pays our own invoices, answers in NIP-44 or NIP-04 as asked
|
|
async function walletBot(e) {
|
|
if (e.kind !== 23194 || !e.tags.some((t) => t[0] === 'p' && t[1] === walletPub)) return;
|
|
const nip44 = e.tags.some((t) => t[0] === 'encryption' && t[1] === 'nip44_v2');
|
|
const ck = nip44ConversationKey(walletKey, unhex(e.pubkey));
|
|
let req;
|
|
try {
|
|
req = JSON.parse(nip44 ? nip44Decrypt(e.content, ck) : await nip04Decrypt(e.content, walletKey, unhex(e.pubkey)));
|
|
} catch { return; }
|
|
let body;
|
|
if (req.method === 'pay_invoice') {
|
|
const preimage = settle(req.params?.invoice);
|
|
body = preimage ? { result_type: 'pay_invoice', result: { preimage } } : { result_type: 'pay_invoice', error: { code: 'PAYMENT_FAILED', message: 'no route to that invoice' } };
|
|
} else {
|
|
body = { result_type: req.method, error: { code: 'NOT_IMPLEMENTED', message: 'not here' } };
|
|
}
|
|
const text = JSON.stringify(body);
|
|
const content = nip44 ? nip44Encrypt(text, ck) : await nip04Encrypt(text, walletKey, unhex(e.pubkey));
|
|
const tags = [['p', e.pubkey], ['e', e.id]];
|
|
if (nip44) tags.push(['encryption', 'nip44_v2']);
|
|
accept(finalizeEvent({ kind: 23195, created_at: now(), tags, content }, walletKey));
|
|
}
|
|
accept(finalizeEvent({ kind: 13194, created_at: now(), tags: [['encryption', 'nip44_v2 nip04']], content: 'pay_invoice' }, walletKey));
|
|
|
|
// ---- people: alice follows bob (who takes sats) and carol (notes only)
|
|
const people = {};
|
|
for (const [name, lud16] of [['alice', ''], ['bob', `bob@${lnHost}`], ['carol', '']]) {
|
|
const sk = generatePrivateKey();
|
|
people[name] = { sk: hex(sk), pk: hex(getPublicKey(sk)), npub: npubEncode(hex(getPublicKey(sk))) };
|
|
accept(finalizeEvent({ kind: 0, content: JSON.stringify({ name, ...(lud16 ? { lud16 } : {}) }), tags: [] }, sk));
|
|
accept(finalizeEvent({ kind: 10050, content: '', tags: [['relay', relayUrl]] }, sk));
|
|
}
|
|
accept(finalizeEvent({ kind: 3, content: '', tags: [['p', people.bob.pk], ['p', people.carol.pk]] }, unhex(people.alice.sk)));
|
|
|
|
const nwc = `nostr+walletconnect://${walletPub}?relay=${encodeURIComponent(relayUrl)}&secret=${hex(walletClient)}`;
|
|
return {
|
|
relay: relayUrl, ln: lnBase, nwc, people, stats, events,
|
|
close: () => {
|
|
for (const sock of relayServer.sockets) sock.destroy();
|
|
relayServer.closeAllConnections?.();
|
|
lnServer.closeAllConnections?.();
|
|
return Promise.all([new Promise((r) => relayServer.close(r)), new Promise((r) => lnServer.close(r))]);
|
|
},
|
|
};
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
const net = await startDevnet({ auth: process.argv.includes('--auth') });
|
|
const { close, stats, events, ...out } = net;
|
|
console.log(JSON.stringify(out));
|
|
process.on('SIGTERM', () => process.exit(0));
|
|
}
|