clone

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

fuel: the LNURL cache, the receipt door and the invoice door move from relay.ts into fuel.ts; hostOf, a URL helper both need, lives in edge.ts

Dami committed c93450a
commit
c93450af5b2911282696d5589edafd055b8ffb41
parent
a98d1114865508344943a356eef56cc661a7fb04
author
Dami <dami.etoile@gmail.com>
3 files changed, +89 -83
Msrc/edge.ts9+++
Msrc/fuel.ts75+++++++++++++++++++++++++-
Msrc/relay.ts88++----------------------------
M src/edge.ts +9 -0
M src/fuel.ts +74 -1
@@ -8,8 +8,13 @@
88 // the ledger: signed by the lightning provider, stored like any event, and99 // re-validated by the same rules whenever the balance is computed.1010 import { sha256 } from "@noble/hashes/sha2.js";11-import { tag, tagValues, validate, type Event } from "./event.ts";11+import { now, tag, tagValues, validate, type Event } from "./event.ts";1212 import { bytesToHex } from "./negentropy.ts";13+import { ERR_DUPLICATE } from "./store.ts";14+import { hostOf } from "./edge.ts";15+import type { Relay } from "./relay.ts";16+17+const LNURL_TTL = 24 * 3600;1318 1419 export interface FuelConfig {1520 lightningAddress: string; // "" disables top-ups (allowances still apply)
@@ -108,6 +113,9 @@ export interface FuelStatus {
108113 }109114 110115 export class Fuel {116+ // The provider's LNURL-pay parameters, cached in storage for a day (lnurlParams).117+ lnurl: LnurlParams | null = null;118+111119 constructor(private sql: SqlStorage, public cfg: FuelConfig) {}112120 113121 init() {
@@ -303,3 +311,68 @@ export function zapRequestHash(req: Event): string {
303311 }304312 305313 export { tagValues };314+315+// lnurlParams fetches and caches the provider's LNURL-pay parameters.316+export async function lnurlParams(relay: Relay): Promise<LnurlParams> {317+ const t = now();318+ if (relay.fuel.lnurl && relay.fuel.lnurl.address === relay.fuel.cfg.lightningAddress && t - relay.fuel.lnurl.fetchedAt < LNURL_TTL) return relay.fuel.lnurl;319+ const p = await fetchLnurl(relay.fuel.cfg.lightningAddress, relay.fetcher);320+ relay.fuel.lnurl = p;321+ await relay.storage.put("lnurl", p);322+ return p;323+}324+325+// acceptReceipt handles a kind 9735 that credits this relay. It stores the326+// receipt regardless of write policy (the provider is nobody's member) and327+// records the credit once. Returns null if the event is not fuel for us.328+export async function acceptReceipt(relay: Relay, e: Event, host: string): Promise<{ ok: boolean; msg: string; stored: boolean } | null> {329+ if (!relay.fuel.cfg.lightningAddress || !relay.fuel.cfg.servicePubkey || relay.settings.policy.owner === "") return null;330+ let provider = "";331+ try {332+ provider = (await lnurlParams(relay)).nostrPubkey;333+ } catch {334+ return null;335+ }336+ const v = relay.fuel.validateReceipt(e, provider, host);337+ if (typeof v === "string") {338+ console.log(`receipt ${e.id.slice(0, 8)} from ${e.pubkey.slice(0, 8)} not credited: ${v}`);339+ return null;340+ }341+ console.log(`receipt ${e.id.slice(0, 8)} credits ${Math.floor(v.msats / 1000)} sats from ${v.payer.slice(0, 8)}`);342+ const err = relay.store.save(e, now());343+ if (err && err !== ERR_DUPLICATE) return { ok: false, msg: err, stored: false };344+ const fresh = relay.fuel.credit(e.id, v.msats, v.payer, now());345+ await relay.ensureAlarm();346+ return { ok: true, msg: fresh ? `fuel: credited ${Math.floor(v.msats / 1000)} sats` : ERR_DUPLICATE, stored: !err };347+}348+349+// fuelInvoice turns a signed zap request into an invoice via the provider.350+export async function fuelInvoice(relay: Relay, req: Request): Promise<Response> {351+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });352+ if (!relay.fuel.cfg.lightningAddress || !relay.fuel.cfg.servicePubkey) return json({ error: "unsupported: this service does not take zaps" }, 400);353+ let body: { zapRequest?: unknown };354+ try {355+ body = await req.json();356+ } catch {357+ return json({ error: "invalid: body is not JSON" }, 400);358+ }359+ const zr = body.zapRequest as Event;360+ const bad = validate(zr);361+ if (bad) return json({ error: bad }, 400);362+ if (zr.kind !== 9734) return json({ error: "invalid: zap request must be kind 9734" }, 400);363+ if (tagValues(zr, "p")[0] !== relay.fuel.cfg.servicePubkey) return json({ error: "invalid: zap request must name the service pubkey" }, 400);364+ const host = new URL(req.url).host.toLowerCase();365+ const relays = zr.tags.filter((t) => t[0] === "relays").flatMap((t) => t.slice(1));366+ if (!relays.some((r) => hostOf(r) === hostOf("ws://" + host))) return json({ error: "invalid: zap request must list this relay in its relays tag" }, 400);367+ const msats = Number(tagValues(zr, "amount")[0]);368+ if (!Number.isInteger(msats) || msats < 1000) return json({ error: "invalid: amount tag must be at least 1000 msats" }, 400);369+ try {370+ const params = await lnurlParams(relay);371+ if (!params.allowsNostr || !params.nostrPubkey) return json({ error: "unsupported: the lightning provider does not support zaps" }, 502);372+ if (msats < params.minSendable || (params.maxSendable && msats > params.maxSendable)) return json({ error: `invalid: amount must be between ${params.minSendable} and ${params.maxSendable} msats` }, 400);373+ const invoice = await requestInvoice(params, zr, msats, relay.fetcher);374+ return json({ invoice, providerPubkey: params.nostrPubkey, msats });375+ } catch (err) {376+ return json({ error: "error: " + (err instanceof Error ? err.message : String(err)) }, 502);377+ }378+}
M src/relay.ts +6 -82
@@ -15,7 +15,7 @@ import { manage } from "./manage.ts";
1515 import { Succession } from "./succession.ts";1616 import { guestPass, readGate, writeGate } from "./gates.ts";1717 import { dashboard } from "./dashboard.ts";18-import { Fuel, fuelConfig, fetchLnurl, requestInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";18+import { Fuel, fuelConfig, acceptReceipt, fuelInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";1919 import { Identity } from "./identity.ts";2020 import { Bucket } from "./ratelimit.ts";2121 import { bridge } from "./bridge.ts";
@@ -32,6 +32,7 @@ import type { PullFilter, PullJob, PullResult } from "./pull.ts";
3232 import { leaseDays, leaseNames, validName } from "./names.ts";3333 import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, pullView, runRound, startRun, type Job, type JobSpec } from "./jobs.ts";3434 import { Hostnames, forgetDomains } from "./domains.ts";35+import { hostOf } from "./edge.ts";3536 import { groupFacts, handleGroupEvent, isGroupManagement, isNIP43Request } from "./groups.ts";3637 import { SIGNER_JS } from "./gen/signer.ts";3738 import { isPagePath, pages } from "./pages.ts";
@@ -114,7 +115,6 @@ export const IP_LIMIT_MULTIPLE = 4;
114115 function clientIP(req: Request): string {115116 return req.headers.get("x-relay-ip") || "unknown";116117 }117-const LNURL_TTL = 24 * 3600;118118 // How long Cloudflare keeps an idle object awake before hibernating it.119119 const IDLE_GRACE_MS = 10_000;120120
@@ -136,7 +136,6 @@ export class Relay extends DurableObject<Env> {
136136 private meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };137137 // When the object last did work, for the active-time meter (ms). Zero after a wake.138138 private lastActive = 0;139- private lnurl: LnurlParams | null = null;140139 // Outbound HTTP to the lightning provider and to Cloudflare; tests substitute a fake.141140 fetcher: Fetcher = (u, i) => fetch(u, i);142141 // The heir and the dead-man's switch (succession.ts).
@@ -168,7 +167,7 @@ export class Relay extends DurableObject<Env> {
168167 this.store.hidden = this.settings.hiddenEvents;169168 this.fuel.init();170169 this.slug = (await ctx.storage.get<string>("slug")) ?? "";171- this.lnurl = (await ctx.storage.get<LnurlParams>("lnurl")) ?? null;170+ this.fuel.lnurl = (await ctx.storage.get<LnurlParams>("lnurl")) ?? null;172171 await this.succession.load();173172 await this.identity.load();174173 });
@@ -560,7 +559,6 @@ export class Relay extends DurableObject<Env> {
560559 this.buckets.clear();561560 this.ipBuckets.clear();562561 this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };563- this.lnurl = null;564562 this.store = new Store(this.sql);565563 this.settings = new Settings(this.sql);566564 this.fuel = new Fuel(this.sql, this.fuel.cfg);
@@ -819,47 +817,13 @@ export class Relay extends DurableObject<Env> {
819817 820818 // ensureAlarm keeps a daily tick scheduled for storage charging and usage821819 // flushes, without pre-empting a sooner NIP-40 expiry.822- private async ensureAlarm(latest = now() + 86400) {820+ async ensureAlarm(latest = now() + 86400) {823821 const lease = this.settings.policy.lease;824822 if (this.settings.isLeased() && lease && lease.until < latest) latest = lease.until;825823 const cur = await this.ctx.storage.getAlarm();826824 if (cur === null || cur > latest * 1000) await this.ctx.storage.setAlarm(latest * 1000);827825 }828826 829- // lnurlParams fetches and caches the provider's LNURL-pay parameters.830- async lnurlParams(): Promise<LnurlParams> {831- const t = now();832- if (this.lnurl && this.lnurl.address === this.fuel.cfg.lightningAddress && t - this.lnurl.fetchedAt < LNURL_TTL) return this.lnurl;833- const p = await fetchLnurl(this.fuel.cfg.lightningAddress, this.fetcher);834- this.lnurl = p;835- await this.ctx.storage.put("lnurl", p);836- return p;837- }838-839- // acceptReceipt handles a kind 9735 that credits this relay. It stores the840- // receipt regardless of write policy (the provider is nobody's member) and841- // records the credit once. Returns null if the event is not fuel for us.842- private async acceptReceipt(e: Event, host: string): Promise<{ ok: boolean; msg: string; stored: boolean } | null> {843- if (!this.fuel.cfg.lightningAddress || !this.fuel.cfg.servicePubkey || this.settings.policy.owner === "") return null;844- let provider = "";845- try {846- provider = (await this.lnurlParams()).nostrPubkey;847- } catch {848- return null;849- }850- const v = this.fuel.validateReceipt(e, provider, host);851- if (typeof v === "string") {852- console.log(`receipt ${e.id.slice(0, 8)} from ${e.pubkey.slice(0, 8)} not credited: ${v}`);853- return null;854- }855- console.log(`receipt ${e.id.slice(0, 8)} credits ${Math.floor(v.msats / 1000)} sats from ${v.payer.slice(0, 8)}`);856- const err = this.store.save(e, now());857- if (err && err !== ERR_DUPLICATE) return { ok: false, msg: err, stored: false };858- const fresh = this.fuel.credit(e.id, v.msats, v.payer, now());859- await this.ensureAlarm();860- return { ok: true, msg: fresh ? `fuel: credited ${Math.floor(v.msats / 1000)} sats` : ERR_DUPLICATE, stored: !err };861- }862-863827 // claimInviteRequest joins the NIP-98 signer through an invite code. It is864828 // open to non-members by design; that is what an invite is for.865829 private async claimInviteRequest(req: Request): Promise<Response> {
@@ -883,37 +847,6 @@ export class Relay extends DurableObject<Env> {
883847 return json({ status: "joined", role: "member" });884848 }885849 886- // fuelInvoice turns a signed zap request into an invoice via the provider.887- private async fuelInvoice(req: Request): Promise<Response> {888- const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });889- if (!this.fuel.cfg.lightningAddress || !this.fuel.cfg.servicePubkey) return json({ error: "unsupported: this service does not take zaps" }, 400);890- let body: { zapRequest?: unknown };891- try {892- body = await req.json();893- } catch {894- return json({ error: "invalid: body is not JSON" }, 400);895- }896- const zr = body.zapRequest as Event;897- const bad = validate(zr);898- if (bad) return json({ error: bad }, 400);899- if (zr.kind !== 9734) return json({ error: "invalid: zap request must be kind 9734" }, 400);900- if (tagValues(zr, "p")[0] !== this.fuel.cfg.servicePubkey) return json({ error: "invalid: zap request must name the service pubkey" }, 400);901- const host = new URL(req.url).host.toLowerCase();902- const relays = zr.tags.filter((t) => t[0] === "relays").flatMap((t) => t.slice(1));903- if (!relays.some((r) => hostOf(r) === hostOf("ws://" + host))) return json({ error: "invalid: zap request must list this relay in its relays tag" }, 400);904- const msats = Number(tagValues(zr, "amount")[0]);905- if (!Number.isInteger(msats) || msats < 1000) return json({ error: "invalid: amount tag must be at least 1000 msats" }, 400);906- try {907- const params = await this.lnurlParams();908- if (!params.allowsNostr || !params.nostrPubkey) return json({ error: "unsupported: the lightning provider does not support zaps" }, 502);909- if (msats < params.minSendable || (params.maxSendable && msats > params.maxSendable)) return json({ error: `invalid: amount must be between ${params.minSendable} and ${params.maxSendable} msats` }, 400);910- const invoice = await requestInvoice(params, zr, msats, this.fetcher);911- return json({ invoice, providerPubkey: params.nostrPubkey, msats });912- } catch (err) {913- return json({ error: "error: " + (err instanceof Error ? err.message : String(err)) }, 502);914- }915- }916-917850 async fetch(req: Request): Promise<Response> {918851 this.touch();919852 const name = req.headers.get("x-relay-name");
@@ -978,7 +911,7 @@ export class Relay extends DurableObject<Env> {
978911 // owner's, in the stats method.979912 return Response.json(this.fuelStatus(), { headers: { "access-control-allow-origin": "*" } });980913 }981- if (url.pathname === "/fuel/invoice" && req.method === "POST") return this.fuelInvoice(req);914+ if (url.pathname === "/fuel/invoice" && req.method === "POST") return fuelInvoice(this, req);982915 if (url.pathname === "/card.json" || url.pathname === "/card.nostr" || url.pathname === "/card.svg" || url.pathname === "/qr.svg") return card(this, req);983916 if (req.method === "OPTIONS") {984917 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } });
@@ -1220,7 +1153,7 @@ export class Relay extends DurableObject<Env> {
12201153 // falls back to the ordinary write path. Callers broadcast when stored.12211154 async acceptAny(e: Event, conn: ConnState): Promise<{ ok: boolean; msg: string; stored: boolean }> {12221155 if (e.kind === 9735) {1223- const r = await this.acceptReceipt(e, conn.host);1156+ const r = await acceptReceipt(this, e, conn.host);12241157 if (r) return r;12251158 }12261159 if (e.kind === KIND_REPORT) return this.acceptReport(e, conn.host);
@@ -1592,12 +1525,3 @@ export class Relay extends DurableObject<Env> {
15921525 }15931526 }15941527 }1595-1596-// hostOf lower-cases the hostname of a URL, ignoring scheme, port and path.1597-export function hostOf(s: string): string {1598- try {1599- return new URL(s.trim()).hostname.toLowerCase();1600- } catch {1601- return "";1602- }1603-}