clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / src / fuel.ts
fuel.ts 16.9K
1// Fuel: quotas on what a relay costs to run, topped up by zaps. Four things2// are metered and priced using related Cloudflare resource rates:3// events stored (SQLite), media stored (R2), estimated time awake and4// metered rows written. Traffic has no fuel price; provider operations5// attached to traffic remain host costs. These meters are not an invoice.6// Every relay gets a free monthly allowance of each; beyond it, usage burns7// sats credited by NIP-57 zap receipts that arrive at the relay itself as8// ordinary events. Receipts are9// the ledger: signed by the lightning provider, stored like any event, and10// re-validated by the same rules whenever the balance is computed.11import { sha256 } from "@noble/hashes/sha2.js";12import { now, tag, tagValues, validate, type Event } from "./event.ts";13import { bytesToHex } from "./negentropy.ts";14import { ERR_DUPLICATE } from "./store.ts";15import { hostOf } from "./edge.ts";16import type { Relay } from "./relay.ts";1718const LNURL_TTL = 24 * 3600;1920export interface FuelConfig {21 lightningAddress: string; // "" disables top-ups (allowances still apply)22 servicePubkey: string; // recipient pubkey zap requests must name23 // Free per relay per month.24 freeEventsMB: number; // SQLite: events, tags, search index25 freeMediaMB: number; // R2: blobs, dumps, imports and retained Git objects26 freeActiveHours: number; // the relay's entrypoint-based awake estimate27 freeRowsWritten: number; // SQLite rows written28 // Beyond the allowances. Each tracks what Cloudflare bills for the same29 // thing, so the prices in wrangler.jsonc are set from those rates.30 satsPerGBMonthEvents: number;31 satsPerGBMonthMedia: number;32 satsPerActiveHour: number;33 satsPerMillionRows: number;34}3536export function fuelConfig(env: Record<string, unknown>): FuelConfig {37 const num = (k: string, d: number) => {38 const v = Number(env[k]);39 return Number.isFinite(v) && v >= 0 ? v : d;40 };41 return {42 lightningAddress: typeof env.LIGHTNING_ADDRESS === "string" ? env.LIGHTNING_ADDRESS : "",43 servicePubkey: typeof env.SERVICE_PUBKEY === "string" ? env.SERVICE_PUBKEY.toLowerCase() : "",44 freeEventsMB: num("FREE_EVENTS_MB", 100),45 freeMediaMB: num("FREE_MEDIA_MB", 1024),46 freeActiveHours: num("FREE_ACTIVE_HOURS", 100),47 freeRowsWritten: num("FREE_ROWS_WRITTEN", 250_000),48 satsPerGBMonthEvents: num("SATS_PER_GB_MONTH_EVENTS", 400),49 satsPerGBMonthMedia: num("SATS_PER_GB_MONTH_MEDIA", 30),50 satsPerActiveHour: num("SATS_PER_ACTIVE_HOUR", 11),51 satsPerMillionRows: num("SATS_PER_MILLION_ROWS", 2000),52 };53}5455export const FUEL_SCHEMA = `56CREATE TABLE IF NOT EXISTS usage (57 month TEXT PRIMARY KEY,58 bytes_in INTEGER NOT NULL DEFAULT 0,59 bytes_out INTEGER NOT NULL DEFAULT 0,60 rows_read INTEGER NOT NULL DEFAULT 0,61 rows_written INTEGER NOT NULL DEFAULT 0,62 storage_msats INTEGER NOT NULL DEFAULT 0,63 storage_day INTEGER NOT NULL DEFAULT 064);65CREATE TABLE IF NOT EXISTS credits (66 id TEXT PRIMARY KEY,67 msats INTEGER NOT NULL,68 payer TEXT NOT NULL,69 at INTEGER NOT NULL70);71`;7273const MB = 1024 * 1024;74const GB = 1024 * MB;75const HOUR_MS = 3600_000;7677export const monthOf = (unix: number) => new Date(unix * 1000).toISOString().slice(0, 7);78export const dayOf = (unix: number) => Math.floor(unix / 86400);7980export type Usage = {81 month: string;82 bytes_in: number;83 bytes_out: number;84 rows_read: number;85 rows_written: number;86 active_ms: number;87 storage_msats: number;88 storage_day: number;89};9091export interface FuelStatus {92 enabled: boolean;93 lightningAddress: string;94 servicePubkey: string;95 month: string;96 // The four things that cost something, each with its allowance.97 eventBytes: number;98 freeEventBytes: number;99 mediaBytes: number;100 freeMediaBytes: number;101 activeMs: number;102 freeActiveMs: number;103 rowsWritten: number;104 freeRowsWritten: number;105 // Metered for the owner's information; not priced.106 bytesOut: number;107 bytesIn: number;108 rowsRead: number;109 creditedMsats: number;110 chargedMsats: number;111 balanceMsats: number;112 outOfFuel: boolean;113 rates: { satsPerGBMonthEvents: number; satsPerGBMonthMedia: number; satsPerActiveHour: number; satsPerMillionRows: number };114}115116export class Fuel {117 // The provider's LNURL-pay parameters, cached in storage for a day (lnurlParams).118 lnurl: LnurlParams | null = null;119120 constructor(private sql: SqlStorage, public cfg: FuelConfig) {}121122 init() {123 this.sql.exec(FUEL_SCHEMA);124 // Relays created before active time was metered.125 try {126 this.sql.exec(`ALTER TABLE usage ADD COLUMN active_ms INTEGER NOT NULL DEFAULT 0`);127 } catch {128 // column exists129 }130 }131132 private usageRow(month: string): Usage {133 this.sql.exec(`INSERT OR IGNORE INTO usage(month) VALUES(?)`, month);134 return this.sql.exec<Usage>(`SELECT * FROM usage WHERE month=?`, month).one();135 }136137 // record adds usage counters to the current month.138 record(now: number, d: { bytesIn?: number; bytesOut?: number; rowsRead?: number; rowsWritten?: number; activeMs?: number }) {139 const month = monthOf(now);140 this.sql.exec(`INSERT OR IGNORE INTO usage(month) VALUES(?)`, month);141 this.sql.exec(142 `UPDATE usage SET bytes_in=bytes_in+?, bytes_out=bytes_out+?, rows_read=rows_read+?, rows_written=rows_written+?, active_ms=active_ms+? WHERE month=?`,143 d.bytesIn ?? 0, d.bytesOut ?? 0, d.rowsRead ?? 0, d.rowsWritten ?? 0, d.activeMs ?? 0, month,144 );145 }146147 // chargeStorage bills storage beyond the allowances once per day, pro rata.148 chargeStorage(now: number, eventBytes: number, mediaBytes: number) {149 const u = this.usageRow(monthOf(now));150 const day = dayOf(now);151 if (u.storage_day === day) return;152 const days = u.storage_day === 0 ? 1 : Math.min(day - u.storage_day, 31);153 const overEvents = Math.max(0, eventBytes - this.cfg.freeEventsMB * MB);154 const overMedia = Math.max(0, mediaBytes - this.cfg.freeMediaMB * MB);155 const perMonth = (overEvents / GB) * this.cfg.satsPerGBMonthEvents + (overMedia / GB) * this.cfg.satsPerGBMonthMedia;156 const msats = Math.round(perMonth * 1000 * (days / 30));157 this.sql.exec(`UPDATE usage SET storage_msats=storage_msats+?, storage_day=? WHERE month=?`, msats, day, u.month);158 }159160 // meteredMsats prices a month's active time and row writes beyond the allowances.161 private meteredMsats(u: Usage): number {162 const overActive = Math.max(0, u.active_ms - this.cfg.freeActiveHours * HOUR_MS);163 const overRows = Math.max(0, u.rows_written - this.cfg.freeRowsWritten);164 return Math.round((overActive / HOUR_MS) * this.cfg.satsPerActiveHour * 1000 + (overRows / 1_000_000) * this.cfg.satsPerMillionRows * 1000);165 }166167 status(now: number, eventBytes: number, mediaBytes: number): FuelStatus {168 const u = this.usageRow(monthOf(now));169 const credited = this.sql.exec<{ n: number | null }>(`SELECT sum(msats) AS n FROM credits`).one().n ?? 0;170 let charged = 0;171 for (const row of this.sql.exec<Usage>(`SELECT * FROM usage`)) charged += row.storage_msats + this.meteredMsats(row);172 const balance = credited - charged;173 const freeEventBytes = this.cfg.freeEventsMB * MB;174 const freeMediaBytes = this.cfg.freeMediaMB * MB;175 const freeActiveMs = this.cfg.freeActiveHours * HOUR_MS;176 const over = eventBytes > freeEventBytes || mediaBytes > freeMediaBytes || u.active_ms > freeActiveMs || u.rows_written > this.cfg.freeRowsWritten;177 return {178 enabled: this.cfg.lightningAddress !== "" && this.cfg.servicePubkey !== "",179 lightningAddress: this.cfg.lightningAddress,180 servicePubkey: this.cfg.servicePubkey,181 month: u.month,182 eventBytes,183 freeEventBytes,184 mediaBytes,185 freeMediaBytes,186 activeMs: u.active_ms,187 freeActiveMs,188 rowsWritten: u.rows_written,189 freeRowsWritten: this.cfg.freeRowsWritten,190 bytesOut: u.bytes_out,191 bytesIn: u.bytes_in,192 rowsRead: u.rows_read,193 creditedMsats: credited,194 chargedMsats: charged,195 balanceMsats: balance,196 outOfFuel: over && balance <= 0,197 rates: {198 satsPerGBMonthEvents: this.cfg.satsPerGBMonthEvents,199 satsPerGBMonthMedia: this.cfg.satsPerGBMonthMedia,200 satsPerActiveHour: this.cfg.satsPerActiveHour,201 satsPerMillionRows: this.cfg.satsPerMillionRows,202 },203 };204 }205206 // credit records a validated receipt once. Returns false if already known.207 credit(receiptId: string, msats: number, payer: string, now: number): boolean {208 return this.sql.exec(`INSERT OR IGNORE INTO credits(id,msats,payer,at) VALUES(?,?,?,?)`, receiptId, msats, payer, now).rowsWritten > 0;209 }210211 recentCredits(limit = 20) {212 return this.sql.exec<{ id: string; msats: number; payer: string; at: number }>(`SELECT * FROM credits ORDER BY at DESC LIMIT ?`, limit).toArray();213 }214215 // validateReceipt checks a kind 9735 event against NIP-57 appendix F for216 // this relay: signed by the provider, naming the service pubkey, carrying217 // a signed zap request that lists this relay, with an invoice whose amount218 // matches. Returns the credit or a reason.219 validateReceipt(e: Event, providerPubkey: string, relayHost: string): { msats: number; payer: string } | string {220 if (e.kind !== 9735) return "not a zap receipt";221 if (!providerPubkey || e.pubkey !== providerPubkey) return "receipt not signed by the lightning provider";222 if (tag(e, "p") !== this.cfg.servicePubkey) return "receipt is not for this service";223 let req: Event;224 try {225 req = JSON.parse(tag(e, "description"));226 } catch {227 return "receipt description is not a zap request";228 }229 if (validate(req) !== "" || req.kind !== 9734) return "embedded zap request is invalid";230 if (tag(req, "p") !== this.cfg.servicePubkey) return "zap request is not for this service";231 const relays = req.tags.filter((t) => t[0] === "relays").flatMap((t) => t.slice(1));232 if (!relays.some((r) => hostOfURL(r) === relayHost.toLowerCase())) return "zap request does not name this relay";233 const msats = bolt11Msats(tag(e, "bolt11"));234 if (msats <= 0) return "invoice has no amount";235 const asked = Number(tag(req, "amount"));236 if (asked > 0 && asked !== msats) return "invoice amount does not match the zap request";237 return { msats, payer: req.pubkey };238 }239}240241function hostOfURL(s: string): string {242 try {243 return new URL(s).host.toLowerCase();244 } catch {245 return "";246 }247}248249// bolt11Msats reads the amount from an invoice's human-readable part:250// ln<currency><digits><multiplier>. Returns 0 when absent or malformed.251export function bolt11Msats(invoice: string): number {252 const s = invoice.toLowerCase();253 const sep = s.lastIndexOf("1");254 if (!s.startsWith("ln") || sep < 0) return 0;255 const hrp = s.slice(2, sep);256 const m = /^(bcrt|bc|tbs|tb)(\d+)([munp])?$/.exec(hrp);257 if (!m) return 0;258 const digits = BigInt(m[2]);259 const exp = { m: 3n, u: 6n, n: 9n, p: 12n }[m[3] ?? ""] ?? 0n;260 // amount in BTC = digits * 10^-exp; msats = BTC * 10^11261 if (exp <= 11n) return Number(digits * 10n ** (11n - exp));262 const scaled = digits * 10n ** 11n;263 const div = 10n ** exp;264 return scaled % div === 0n ? Number(scaled / div) : 0;265}266267// LNURL-pay parameters for a lightning address, per LUD-16 and NIP-57.268export interface LnurlParams {269 address: string; // which lightning address these belong to270 callback: string;271 minSendable: number;272 maxSendable: number;273 nostrPubkey: string;274 allowsNostr: boolean;275 fetchedAt: number;276}277278export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;279280export async function fetchLnurl(lightningAddress: string, fetcher: Fetcher = (u, i) => fetch(u, i)): Promise<LnurlParams> {281 const [user, domain] = lightningAddress.split("@");282 if (!user || !domain) throw new Error("bad lightning address");283 const resp = await fetcher(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(user)}`, { headers: { accept: "application/json" } });284 if (!resp.ok) throw new Error(`lnurl: HTTP ${resp.status}`);285 const j = (await resp.json()) as Record<string, unknown>;286 if (typeof j.callback !== "string") throw new Error("lnurl: no callback");287 return {288 address: lightningAddress,289 callback: j.callback,290 minSendable: Number(j.minSendable) || 1000,291 maxSendable: Number(j.maxSendable) || 0,292 nostrPubkey: typeof j.nostrPubkey === "string" ? j.nostrPubkey.toLowerCase() : "",293 allowsNostr: j.allowsNostr === true,294 fetchedAt: Math.floor(Date.now() / 1000),295 };296}297298// requestInvoice asks the provider for an invoice carrying the zap request.299export async function requestInvoice(params: LnurlParams, zapRequest: Event, msats: number, fetcher: Fetcher = (u, i) => fetch(u, i)): Promise<string> {300 const u = new URL(params.callback);301 u.searchParams.set("amount", String(msats));302 u.searchParams.set("nostr", JSON.stringify(zapRequest));303 const resp = await fetcher(u.toString(), { headers: { accept: "application/json" } });304 if (!resp.ok) throw new Error(`lnurl callback: HTTP ${resp.status}`);305 const j = (await resp.json()) as Record<string, unknown>;306 if (typeof j.pr !== "string") throw new Error(typeof j.reason === "string" ? j.reason : "lnurl callback returned no invoice");307 return j.pr;308}309310export function zapRequestHash(req: Event): string {311 return bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(req))));312}313314export { tagValues };315316// lnurlParams fetches and caches the provider's LNURL-pay parameters.317export async function lnurlParams(relay: Relay): Promise<LnurlParams> {318 const t = now();319 if (relay.fuel.lnurl && relay.fuel.lnurl.address === relay.fuel.cfg.lightningAddress && t - relay.fuel.lnurl.fetchedAt < LNURL_TTL) return relay.fuel.lnurl;320 const p = await fetchLnurl(relay.fuel.cfg.lightningAddress, relay.fetcher);321 relay.fuel.lnurl = p;322 await relay.storage.put("lnurl", p);323 return p;324}325326// acceptReceipt handles a kind 9735 that credits this relay. It stores the327// receipt regardless of write policy (the provider is nobody's member) and328// records the credit once. Returns null if the event is not fuel for us.329export async function acceptReceipt(relay: Relay, e: Event, host: string): Promise<{ ok: boolean; msg: string; stored: boolean } | null> {330 if (!relay.fuel.cfg.lightningAddress || !relay.fuel.cfg.servicePubkey || relay.settings.policy.owner === "") return null;331 let provider = "";332 try {333 provider = (await lnurlParams(relay)).nostrPubkey;334 } catch {335 return null;336 }337 const v = relay.fuel.validateReceipt(e, provider, host);338 if (typeof v === "string") {339 console.log(`receipt ${e.id.slice(0, 8)} from ${e.pubkey.slice(0, 8)} not credited: ${v}`);340 return null;341 }342 console.log(`receipt ${e.id.slice(0, 8)} credits ${Math.floor(v.msats / 1000)} sats from ${v.payer.slice(0, 8)}`);343 const err = relay.store.save(e, now());344 if (err && err !== ERR_DUPLICATE) return { ok: false, msg: err, stored: false };345 const fresh = relay.fuel.credit(e.id, v.msats, v.payer, now());346 await relay.ensureAlarm();347 return { ok: true, msg: fresh ? `fuel: credited ${Math.floor(v.msats / 1000)} sats` : ERR_DUPLICATE, stored: !err };348}349350// fuelInvoice turns a signed zap request into an invoice via the provider.351export async function fuelInvoice(relay: Relay, req: Request): Promise<Response> {352 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });353 if (!relay.fuel.cfg.lightningAddress || !relay.fuel.cfg.servicePubkey) return json({ error: "unsupported: this service does not take zaps" }, 400);354 let body: { zapRequest?: unknown };355 try {356 body = await req.json();357 } catch {358 return json({ error: "invalid: body is not JSON" }, 400);359 }360 const zr = body.zapRequest as Event;361 const bad = validate(zr);362 if (bad) return json({ error: bad }, 400);363 if (zr.kind !== 9734) return json({ error: "invalid: zap request must be kind 9734" }, 400);364 if (tagValues(zr, "p")[0] !== relay.fuel.cfg.servicePubkey) return json({ error: "invalid: zap request must name the service pubkey" }, 400);365 const host = new URL(req.url).host.toLowerCase();366 const relays = zr.tags.filter((t) => t[0] === "relays").flatMap((t) => t.slice(1));367 if (!relays.some((r) => hostOf(r) === hostOf("ws://" + host))) return json({ error: "invalid: zap request must list this relay in its relays tag" }, 400);368 const msats = Number(tagValues(zr, "amount")[0]);369 if (!Number.isInteger(msats) || msats < 1000) return json({ error: "invalid: amount tag must be at least 1000 msats" }, 400);370 try {371 const params = await lnurlParams(relay);372 if (!params.allowsNostr || !params.nostrPubkey) return json({ error: "unsupported: the lightning provider does not support zaps" }, 502);373 if (msats < params.minSendable || (params.maxSendable && msats > params.maxSendable)) return json({ error: `invalid: amount must be between ${params.minSendable} and ${params.maxSendable} msats` }, 400);374 const invoice = await requestInvoice(params, zr, msats, relay.fetcher);375 return json({ invoice, providerPubkey: params.nostrPubkey, msats });376 } catch (err) {377 return json({ error: "error: " + (err instanceof Error ? err.message : String(err)) }, 502);378 }379}