clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / scripts / ops / margin.mjs
margin.mjs 12.5K
1// Fuel prices against what Cloudflare bills, at today's bitcoin price.2//3// npm run margin tables, markdown4// npm run margin -- --btc 77000 at a given price5// npm run margin -- --target 0.4 a different margin target6// npm run margin -- --relays 500 the deployment projection at that count7//8// Exit code 1 when a priced line sits below the target margin, so a weekly9// run can raise a flag. Cloudflare rates are list prices for Workers Paid,10// copied from the pricing pages on the date noted; revisit them with the11// prices in wrangler.jsonc.12import { readFileSync } from "node:fs";1314const args = Object.fromEntries(process.argv.slice(2).map((a, i, all) => (a.startsWith("--") ? [a.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "true"] : [])).filter((x) => x.length));15const TARGET = Number(args.target ?? 0.33); // gross margin on revenue; price is cost / (1 - target)16const FLOOR = Number(args.floor ?? 0.2); // below this, reprice now1718// Cloudflare list prices, Workers Paid, USD. Checked 2026-09-04 against19// developers.cloudflare.com/{durable-objects/platform,r2,workers/platform}/pricing.20const CF = {21 base: 5, // per month, the plan itself22 doRequestsPerM: 0.15, doRequestsIncluded: 1e6, // HTTP, RPC, websocket messages, alarms23 doDurationPerMGBs: 12.5, doDurationIncluded: 400_000, doMemoryGB: 0.128,24 rowsWrittenPerM: 1.0, rowsWrittenIncluded: 50e6,25 rowsReadPerM: 0.001, rowsReadIncluded: 25e9,26 sqlGBMonth: 0.2, sqlIncludedGB: 5,27 r2GBMonth: 0.015, r2IncludedGB: 10,28 r2ClassAPerM: 4.5, r2ClassAIncluded: 1e6, r2ClassBPerM: 0.36, r2ClassBIncluded: 10e6,29 workerRequestsPerM: 0.3, workerRequestsIncluded: 10e6,30 kvReadsPerM: 0.5, kvReadsIncluded: 10e6,31};3233// The relay's own numbers, from wrangler.jsonc (comments stripped).34const cfg = JSON.parse(readFileSync(new URL("../../wrangler.jsonc", import.meta.url), "utf8").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));35const v = cfg.vars;36const num = (k) => Number(v[k]);37const GBS_PER_HOUR = 3600 * CF.doMemoryGB; // 460.8 GB-s per hour awake at 128 MB3839async function btcPrice() {40 if (args.btc) return Number(args.btc);41 for (const [url, pick] of [42 ["https://mempool.space/api/v1/prices", (j) => j.USD],43 ["https://api.coinbase.com/v2/prices/BTC-USD/spot", (j) => Number(j.data.amount)],44 ]) {45 try {46 const r = await fetch(url, { signal: AbortSignal.timeout(5000) });47 const p = pick(await r.json());48 if (p > 0) return p;49 } catch {50 /* next source */51 }52 }53 throw new Error("no bitcoin price; pass --btc");54}5556const btc = await btcPrice();57const usdToSats = (usd) => (usd / btc) * 1e8;58const satsToUsd = (s) => (s / 1e8) * btc;59const pct = (x) => (x * 100).toFixed(0) + "%";60const money = (x) => (x < 0 ? "-" : "") + "$" + (Math.abs(x) < 0.1 ? Math.abs(x).toFixed(4) : Math.abs(x).toFixed(2));61const amount = (x, unit) => (unit === "GB" && x < 1 ? `${Math.round(x * 1024)} MB` : `${x} ${unit}`);62const n = (x) => Math.round(x).toLocaleString("en-US");6364// The four priced lines: what one unit costs us, what we charge.65const lines = [66 { name: "events stored", unit: "GB-month", cost: CF.sqlGBMonth, price: num("SATS_PER_GB_MONTH_EVENTS"), key: "SATS_PER_GB_MONTH_EVENTS", free: num("FREE_EVENTS_MB") / 1024, freeUnit: "GB" },67 { name: "files stored", unit: "GB-month", cost: CF.r2GBMonth, price: num("SATS_PER_GB_MONTH_MEDIA"), key: "SATS_PER_GB_MONTH_MEDIA", free: num("FREE_MEDIA_MB") / 1024, freeUnit: "GB" },68 { name: "time awake", unit: "hour", cost: (GBS_PER_HOUR / 1e6) * CF.doDurationPerMGBs, price: num("SATS_PER_ACTIVE_HOUR"), key: "SATS_PER_ACTIVE_HOUR", free: num("FREE_ACTIVE_HOURS"), freeUnit: "hours" },69 { name: "rows written", unit: "million", cost: CF.rowsWrittenPerM, price: num("SATS_PER_MILLION_ROWS"), key: "SATS_PER_MILLION_ROWS", free: num("FREE_ROWS_WRITTEN") / 1e6, freeUnit: "million" },70];71for (const l of lines) {72 l.costSats = usdToSats(l.cost);73 l.revenue = satsToUsd(l.price);74 l.margin = 1 - l.cost / l.revenue;75 l.floorBtc = (l.cost * 1e8) / (l.price * (1 - TARGET)); // the price at which margin falls to the target76 l.repriceBtc = (l.cost * 1e8) / (l.price * (1 - FLOOR));77 l.suggest = Math.ceil(usdToSats(l.cost) / (1 - TARGET));78}7980let out = [];81out.push(`# Fuel margins at $${n(btc)} per bitcoin`, "");82out.push(`Target gross margin ${pct(TARGET)} of revenue (price is cost / ${(1 - TARGET).toFixed(2)}; ${pct(1 / (1 - TARGET) - 1)} markup on cost). Reprice when a line falls under ${pct(FLOOR)}.`, "");83out.push("| Line | Cloudflare cost | Cost in sats | Our price | Margin | Target holds while BTC is above | Price for target today |");84out.push("|---|---|---|---|---|---|---|");85for (const l of lines) {86 const flag = l.margin < FLOOR ? " REPRICE" : l.margin < TARGET ? " low" : "";87 out.push(`| ${l.name} | ${money(l.cost)} per ${l.unit} | ${n(l.costSats)} sats | ${n(l.price)} sats | ${pct(l.margin)}${flag} | $${n(l.floorBtc)} | ${n(l.suggest)} sats |`);88}89out.push("");9091// The free tier: what a relay that uses all of its allowance costs us.92const freeCost = lines.reduce((a, l) => a + l.free * l.cost, 0);93out.push("## The free tier", "");94out.push("A relay that spends its whole monthly allowance costs, before the plan's included quotas absorb any of it:", "");95out.push("| Allowance | Amount | Cost to us |");96out.push("|---|---|---|");97for (const l of lines) out.push(`| ${l.name} | ${amount(l.free, l.freeUnit)} | ${money(l.free * l.cost)} |`);98out.push(`| total | | ${money(freeCost)} (${n(usdToSats(freeCost))} sats) |`, "");99const coveredHours = CF.doDurationIncluded / GBS_PER_HOUR;100out.push(`The plan includes ${n(coveredHours)} awake hours, ${n(CF.rowsWrittenIncluded / 1e6)} million rows written, ${CF.sqlIncludedGB} GB of SQLite and ${CF.r2IncludedGB} GB of R2 a month before anything is billed. That covers about ${n(coveredHours / num("FREE_ACTIVE_HOURS"))} relays' worth of awake time, ${n(CF.rowsWrittenIncluded / num("FREE_ROWS_WRITTEN"))} relays' worth of rows, ${n(CF.sqlIncludedGB / (num("FREE_EVENTS_MB") / 1024))} relays' worth of events and ${n(CF.r2IncludedGB / (num("FREE_MEDIA_MB") / 1024))} relays' worth of files, if every relay used its whole allowance. Most do not.`, "");101102// Lines nobody pays for: requests, reads, R2 operations, KV. Absorbed by margin.103out.push("## What is not priced", "");104out.push("Traffic has no fuel price, but Cloudflare bills these. They consume margin on the four lines above. This is a partial, pro-rata projection, not an invoice: DO compute and R2 billing-unit rounding are omitted. Worker CPU, logs, traces, Analytics Engine, KV usage, custom hostnames, payment fees, support and additional retry or maintenance work also need separate budgets.", "");105out.push("| Line | Cloudflare cost | Included per month |");106out.push("|---|---|---|");107out.push(`| Durable Object requests (HTTP, RPC and alarms) | $${CF.doRequestsPerM} per million | ${n(CF.doRequestsIncluded)} |`);108out.push(`| Durable Object inbound WebSocket messages (20:1 request ratio) | $${CF.doRequestsPerM} per million billable requests | included with DO requests |`);109out.push(`| Worker requests | $${CF.workerRequestsPerM} per million | ${n(CF.workerRequestsIncluded)} |`);110out.push(`| rows read | $${CF.rowsReadPerM} per million | ${n(CF.rowsReadIncluded / 1e9)} billion |`);111out.push(`| R2 writes (class A) | $${CF.r2ClassAPerM} per million | ${n(CF.r2ClassAIncluded)} |`);112out.push(`| R2 reads (class B) | $${CF.r2ClassBPerM} per million | ${n(CF.r2ClassBIncluded)} |`);113out.push(`| KV reads (custom domains) | $${CF.kvReadsPerM} per million | ${n(CF.kvReadsIncluded)} |`);114out.push(`| the plan | $${CF.base} | |`, "");115116// A deployment projection: what N relays cost at a typical shape, and what the paid ones bring in.117const relays = Number(args.relays ?? 1000);118// Websocket messages count as object requests but never reach the Worker, so Worker requests are the HTTP door and page loads only.119// Illustrative raw traffic mixes total 0.3M and 5M messages/requests. The120// split is an assumption, not measured traffic. Incoming socket messages121// use the 20:1 ratio; HTTP/RPC/alarms count individually.122const typical = { eventsGB: 0.02, mediaGB: 0.1, hours: 8, rowsM: 0.1, doHttpRpcAlarmM: 0.01, wsMessagesM: 0.29, workerReqM: 0.01, rowsReadM: 5, r2AM: 0.002, r2BM: 0.02 };123const heavy = { eventsGB: 0.5, mediaGB: 3, hours: 200, rowsM: 3, doHttpRpcAlarmM: 0.2, wsMessagesM: 4.8, workerReqM: 0.2, rowsReadM: 100, r2AM: 0.05, r2BM: 0.5 };124function bill(count, heavyShare) {125 const h = Math.round(count * heavyShare), t = count - h;126 const sum = (k) => t * typical[k] + h * heavy[k];127 const doBillableRequestsM = sum("doHttpRpcAlarmM") + sum("wsMessagesM") / 20;128 const over = (x, inc) => Math.max(0, x - inc);129 const items = {130 "SQLite storage": over(sum("eventsGB"), CF.sqlIncludedGB) * CF.sqlGBMonth,131 "R2 storage": over(sum("mediaGB"), CF.r2IncludedGB) * CF.r2GBMonth,132 "DO duration": (over(sum("hours") * GBS_PER_HOUR, CF.doDurationIncluded) / 1e6) * CF.doDurationPerMGBs,133 "rows written": (over(sum("rowsM") * 1e6, CF.rowsWrittenIncluded) / 1e6) * CF.rowsWrittenPerM,134 "DO requests": (over(doBillableRequestsM * 1e6, CF.doRequestsIncluded) / 1e6) * CF.doRequestsPerM,135 "Worker requests": (over(sum("workerReqM") * 1e6, CF.workerRequestsIncluded) / 1e6) * CF.workerRequestsPerM,136 "rows read": (over(sum("rowsReadM") * 1e6, CF.rowsReadIncluded) / 1e6) * CF.rowsReadPerM,137 "R2 operations": (over(sum("r2AM") * 1e6, CF.r2ClassAIncluded) / 1e6) * CF.r2ClassAPerM + (over(sum("r2BM") * 1e6, CF.r2ClassBIncluded) / 1e6) * CF.r2ClassBPerM,138 "the plan": CF.base,139 };140 // Revenue: only heavy relays pass an allowance; charge them for what they use past it.141 const overUse = (k, free, price) => h * Math.max(0, heavy[k] - free) * satsToUsd(price);142 const revenue = overUse("eventsGB", num("FREE_EVENTS_MB") / 1024, num("SATS_PER_GB_MONTH_EVENTS")) + overUse("mediaGB", num("FREE_MEDIA_MB") / 1024, num("SATS_PER_GB_MONTH_MEDIA")) + overUse("hours", num("FREE_ACTIVE_HOURS"), num("SATS_PER_ACTIVE_HOUR")) + overUse("rowsM", num("FREE_ROWS_WRITTEN") / 1e6, num("SATS_PER_MILLION_ROWS"));143 return { items, total: Object.values(items).reduce((a, b) => a + b, 0), revenue, heavy: h };144}145out.push(`## The deployment at ${n(relays)} relays`, "");146out.push(`A typical relay: ${Math.round(typical.eventsGB * 1024)} MB of events, ${Math.round(typical.mediaGB * 1024)} MB of files, ${typical.hours} hours awake, ${typical.rowsM * 1e6 / 1000}k rows written, ${typical.doHttpRpcAlarmM}M raw DO HTTP/RPC/alarm requests plus ${typical.wsMessagesM}M inbound WebSocket messages (illustrative ${(typical.doHttpRpcAlarmM + typical.wsMessagesM / 20).toFixed(4)}M billable DO requests) a month. A heavy one: ${heavy.eventsGB * 1024} MB, ${heavy.mediaGB} GB, ${heavy.hours} hours, ${heavy.rowsM}M rows, ${heavy.doHttpRpcAlarmM}M raw DO requests plus ${heavy.wsMessagesM}M inbound WebSocket messages (illustrative ${(heavy.doHttpRpcAlarmM + heavy.wsMessagesM / 20).toFixed(4)}M billable DO requests). WebSocket messages do not count as Worker requests. Heavy relays pass their allowances and pay fuel; typical ones ride free.`, "");147out.push("| Heavy share | Modeled provider cost | Fuel revenue | Net before omitted costs | Cost per relay |");148out.push("|---|---|---|---|---|");149for (const share of [0, 0.02, 0.05, 0.1]) {150 const b = bill(relays, share);151 out.push(`| ${pct(share)} (${n(b.heavy)} relays) | ${money(b.total)} | ${money(b.revenue)} | ${money(b.revenue - b.total)} | ${money(b.total / relays)} |`);152}153const b5 = bill(relays, 0.05);154out.push("", `Modeled costs at 5% heavy: ` + Object.entries(b5.items).filter(([, x]) => x > 0.005).sort((a, b) => b[1] - a[1]).map(([k, x]) => `${k} ${money(x)}`).join(", ") + ".", "");155156// Repricing guidance.157out.push("## Weekly check", "");158const worst = lines.reduce((a, l) => (l.margin < a.margin ? l : a));159out.push(`The thinnest line is ${worst.name} at ${pct(worst.margin)}. Its target holds while bitcoin stays above $${n(worst.floorBtc)}; under $${n(worst.repriceBtc)} it needs repricing.`, "");160out.push("To reprice at today's rate for the target margin, set in `wrangler.jsonc`:", "");161out.push("```");162for (const l of lines) out.push(`"${l.key}": "${l.suggest}"${l.suggest === l.price ? "" : ` // was ${l.price}`}`);163out.push("```", "");164out.push("Prices only go up in this model when bitcoin falls. When it rises, margins widen on their own, and the same table says by how much; lower them when the margin passes twice the target and the lines have been stable for a month.");165166console.log(out.join("\n"));167process.exitCode = lines.some((l) => l.margin < TARGET) ? 1 : 0;