clone

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

main
bindws / test / object / fuel.test.ts
fuel.test.ts 10.0K
1import { SELF, env, runInDurableObject } from "cloudflare:test";2import { describe, it, expect } from "vitest";3import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";4import { getToken } from "nostr-tools/nip98";5import type { Relay } from "../../src/relay.ts";6import { now, ev, pk, rpc } from "../helpers/relay.ts";7import { WS } from "../helpers/ws.ts";89const SERVICE = "ab".repeat(32);10const provider = generateSecretKey();11const providerPk = getPublicKey(provider);1213// A fake lightning provider, injected into the relay instance: the LNURL14// document names our provider key, and the callback returns a fixed invoice.15async function mockProvider(name: string) {16 const stub = env.RELAY.getByName(name);17 await runInDurableObject(stub, async (r: Relay) => {18 r.fetcher = async (url: string) => {19 if (url === "https://ln.test/.well-known/lnurlp/fuel") {20 return Response.json({ callback: "https://ln.test/cb", minSendable: 1000, maxSendable: 100_000_000_000, nostrPubkey: providerPk, allowsNostr: true });21 }22 if (url.startsWith("https://ln.test/cb?")) {23 const nostr = new URL(url).searchParams.get("nostr") ?? "";24 return Response.json(JSON.parse(nostr).kind === 9734 ? { pr: "lnbc10u1fakeinvoice" } : { status: "ERROR", reason: "bad request" });25 }26 return new Response("unexpected " + url, { status: 500 });27 };28 });29}3031async function claim(host: string, sk: Uint8Array) {32 const url = `http://${host}/`;33 const payload = { method: "claim", params: [] };34 const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);35 const r = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });36 expect(r.status).toBe(200);37}3839function zapRequest(payer: Uint8Array, host: string, msats: number) {40 return ev(payer, 9734, "", [["p", SERVICE], ["amount", String(msats)], ["relays", "wss://" + host]]);41}4243function receipt(req: Event, bolt11: string, signer = provider, p = SERVICE) {44 return ev(signer, 9735, "", [["p", p], ["P", req.pubkey], ["bolt11", bolt11], ["description", JSON.stringify(req)]]);45}4647describe("fuel", () => {48 it("credits a provider receipt while a Git response is streaming", async () => {49 const host = "fuel-stream.bind.ws";50 await mockProvider("fuel-stream");51 const owner = generateSecretKey();52 const payer = generateSecretKey();53 await claim(host, owner);54 const req = zapRequest(payer, host, 1_000_000);55 const good = receipt(req, "lnbc10u1streaminvoice");56 const forged = receipt(req, "lnbc10u1forgedinvoice", generateSecretKey());57 const stub = env.RELAY.getByName("fuel-stream");5859 await runInDurableObject(stub, async (relay: Relay) => {60 const response = await relay.repositoryAccess.response(61 "git",62 async () => new Response(new ReadableStream<Uint8Array>({ start() {} })),63 () => new Response("busy", { status: 429 }),64 );65 expect(relay.repositoryAccess.busy).toBe(true);6667 const credited = await relay.acceptAny(good, relay.virtualConn(host, pk(provider)));68 expect(credited.ok).toBe(true);69 expect(credited.msg).toBe("fuel: credited 1000 sats");70 expect(relay.fuelStatus().creditedMsats).toBe(1_000_000);7172 const ordinary = await relay.acceptAny(ev(owner, 1, "during Git"), relay.virtualConn(host, pk(owner)));73 expect(ordinary.ok).toBe(false);74 expect(ordinary.msg).toContain("relay operation in progress");7576 const invalid = await relay.acceptAny(forged, relay.virtualConn(host, pk(owner)));77 expect(invalid.ok).toBe(false);78 expect(invalid.msg).toContain("relay operation in progress");7980 await response.body!.cancel();81 expect(relay.repositoryAccess.busy).toBe(false);82 });83 });8485 it("issues invoices, credits valid receipts once, ignores forgeries, and lifts the out-of-fuel restriction", async () => {86 const host = "fuel-a.bind.ws";87 await mockProvider("fuel-a");88 const owner = generateSecretKey();89 const payer = generateSecretKey();90 await claim(host, owner);9192 // Fresh relay: within allowances, fuel status public.93 let status: any = await (await SELF.fetch(`http://${host}/fuel`)).json();94 expect(status.enabled).toBe(true);95 expect(status.outOfFuel).toBe(false);96 expect(status.balanceMsats).toBe(0);9798 // Blow past the row-write allowance: writes stop, reads continue.99 const stub = env.RELAY.getByName("fuel-a");100 // 150k over the 250k allowance is 300 sats, which the zap below covers.101 await runInDurableObject(stub, async (r: Relay) => r.fuel.record(now(), { rowsWritten: 400_000 }));102 status = await (await SELF.fetch(`http://${host}/fuel`)).json();103 expect(status.outOfFuel).toBe(true);104 expect(status.chargedMsats).toBeGreaterThan(0);105 const c = await WS.connect(host);106 expect((await c.ok(ev(owner, 1, "no fuel"))).msg).toMatch(/^restricted: .*out of fuel/);107 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();108 expect(info.limitation.payment_required).toBe(true);109 expect(info.payments_url).toBe("https://" + host + "/");110111 // Invoice: a signed zap request naming the service and this relay.112 const req = zapRequest(payer, host, 1_000_000);113 const inv = await (await SELF.fetch(`http://${host}/fuel/invoice`, { method: "POST", body: JSON.stringify({ zapRequest: req }) })).json<any>();114 expect(inv.invoice).toBe("lnbc10u1fakeinvoice");115 expect(inv.providerPubkey).toBe(providerPk);116 const badReq = zapRequest(payer, "other.bind.ws", 1_000_000);117 expect((await SELF.fetch(`http://${host}/fuel/invoice`, { method: "POST", body: JSON.stringify({ zapRequest: badReq }) })).status).toBe(400);118119 // Forgeries: wrong signer, wrong service, wrong relay, amount mismatch.120 expect((await c.ok(receipt(req, "lnbc10u1x", generateSecretKey()))).msg).toMatch(/^restricted/);121 expect((await c.ok(receipt(req, "lnbc10u1x", provider, "cd".repeat(32)))).msg).toMatch(/^restricted/);122 expect((await c.ok(receipt(badReq, "lnbc10u1x"))).msg).toMatch(/^restricted/);123 expect((await c.ok(receipt(req, "lnbc20u1x"))).msg).toMatch(/^restricted/);124125 // The real receipt, published by the provider, is accepted despite the126 // restriction and credited exactly once.127 const good = receipt(req, "lnbc10u1realinvoice");128 let r = await c.ok(good);129 expect(r.ok).toBe(true);130 expect(r.msg).toBe("fuel: credited 1000 sats");131 r = await c.ok(good);132 expect(r.msg).toMatch(/^duplicate:/);133 status = await (await SELF.fetch(`http://${host}/fuel`)).json();134 expect(status.creditedMsats).toBe(1_000_000);135 // Who paid is the owner's to see, not the public meter's.136 expect(status.credits).toBeUndefined();137 expect((await rpc(host, owner, "stats")).result.credits[0].payer).toBe(getPublicKey(payer));138 expect(status.outOfFuel).toBe(false);139 expect((await c.ok(ev(owner, 1, "fueled"))).ok).toBe(true);140141 // Time awake beyond the allowance burns the balance down; traffic never does.142 await runInDurableObject(stub, async (r: Relay) => r.fuel.record(now(), { bytesOut: 50 * 1024 * 1024 * 1024 }));143 status = await (await SELF.fetch(`http://${host}/fuel`)).json();144 const afterTraffic = status.balanceMsats;145 await runInDurableObject(stub, async (r: Relay) => r.fuel.record(now(), { activeMs: 150 * 3600_000 }));146 status = await (await SELF.fetch(`http://${host}/fuel`)).json();147 expect(status.balanceMsats).toBeLessThan(afterTraffic);148 // The relay's own rows from this test add a few msats on top.149 const expected = 0.15 * status.rates.satsPerMillionRows * 1000 + 50 * status.rates.satsPerActiveHour * 1000; // 150k rows over, 50 hours over150 expect(status.chargedMsats).toBeGreaterThanOrEqual(expected);151 expect(status.chargedMsats).toBeLessThan(expected + 5000);152 });153154 it("accepts provider receipts even on owner-only relays, and charges storage daily", async () => {155 const host = "fuel-b.bind.ws";156 await mockProvider("fuel-b");157 const owner = generateSecretKey();158 await claim(host, owner);159 const url = `http://${host}/`;160 const payload = { method: "setpolicy", params: [{ writes: "owner" }] };161 const token = await getToken(url, "POST", (e) => finalizeEvent(e, owner), true, payload);162 await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });163 const c = await WS.connect(host);164 expect((await c.ok(ev(generateSecretKey(), 1, "stranger"))).msg).toMatch(/^restricted:/);165 const req = zapRequest(generateSecretKey(), host, 21_000);166 expect((await c.ok(receipt(req, "lnbc210n1x"))).msg).toBe("fuel: credited 21 sats");167168 const stub = env.RELAY.getByName("fuel-b");169 await runInDurableObject(stub, async (r: Relay) => {170 const t = now();171 r.fuel.cfg.freeEventsMB = 0; // everything is over the allowance now172 r.fuel.cfg.freeMediaMB = 0;173 const GB = 1024 * 1024 * 1024;174 r.fuel.chargeStorage(t, GB, 2 * GB); // first tick: one day of 1 GB events and 2 GB media175 const s1 = r.fuel.status(t, GB, 2 * GB);176 expect(s1.chargedMsats).toBe(Math.round(((400 + 2 * 30) * 1000) / 30));177 r.fuel.chargeStorage(t, GB, 2 * GB); // same day: no double charge178 expect(r.fuel.status(t, GB, 2 * GB).chargedMsats).toBe(s1.chargedMsats);179 });180 });181182 it("meters traffic, rows and time awake through the websocket path", async () => {183 const host = "fuel-c.bind.ws";184 const owner = generateSecretKey();185 await claim(host, owner);186 const c = await WS.connect(host);187 for (let i = 0; i < 5; i++) await c.ok(ev(owner, 1, "metered " + i));188 const stub = env.RELAY.getByName("fuel-c");189 await runInDurableObject(stub, async (r: Relay) => {190 const s = r.fuelStatus();191 expect(s.bytesIn).toBeGreaterThan(1000);192 expect(s.bytesOut).toBeGreaterThan(100);193 expect(s.rowsWritten).toBeGreaterThanOrEqual(5);194 expect(s.activeMs).toBeGreaterThanOrEqual(10_000);195 expect(s.eventBytes).toBeGreaterThan(0);196 });197 });198});