scripts/margin.mjs reads the prices and allowances from wrangler.jsonc, the Cloudflare list prices from a dated table, and the bitcoin price from mempool.space or Coinbase, and prints margin per line, the price at which each line's target stops holding, the cost of the free tier, the unpriced lines, and a fleet projection. It exits 1 under the target, and a Monday workflow puts the tables in the run summary and keeps one issue open while a line is under.
- commit
- 64eb29a5b4e6e71d6b914e84a6509e67d5c84f19
- parent
- 8dee16394d020c21b3bcbee92dc42fb0533524a5
- author
- Dami <dami.etoile@gmail.com>
| A | .github/ | 34 | ++++++ |
| M | README.md | 1 | + |
| M | docs/ | 2 | +- |
| A | docs/ | 66 | ++++++++++++ |
| A | scripts/ | 162 | ++++++++++++++++++++++++++++++ |
| M | wrangler.jsonc | 3 | +- |
@@ -0,0 +1,34 @@
1+name: margin2+# Weekly: fuel prices against Cloudflare's rates at the current bitcoin price.3+# The table lands in the run summary; a line under the target margin opens or4+# refreshes one issue.5+on:6+ schedule:7+ - cron: "0 9 * * 1"8+ workflow_dispatch:9+permissions:10+ contents: read11+ issues: write12+jobs:13+ margin:14+ runs-on: ubuntu-latest15+ steps:16+ - uses: actions/checkout@v517+ - uses: actions/setup-node@v518+ with:19+ node-version: 2220+ - name: Compute21+ id: compute22+ run: |23+ set +e24+ node scripts/margin.mjs > margin.md25+ echo "below=$?" >> "$GITHUB_OUTPUT"26+ cat margin.md >> "$GITHUB_STEP_SUMMARY"27+ - name: Flag28+ if: steps.compute.outputs.below == '1'29+ env:30+ GH_TOKEN: ${{ github.token }}31+ run: |32+ title="Fuel prices below the margin target"33+ n=$(gh issue list --state open --search "$title in:title" --json number --jq '.[0].number')34+ if [ -n "$n" ]; then gh issue comment "$n" --body-file margin.md; else gh issue create --title "$title" --body-file margin.md; fi@@ -49,6 +49,7 @@ It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it
4949 - [Architecture](docs/10-architecture.md): routing, the object, storage, alarm, jobs, identity, groups.5050 - [Hosting bind.ws](docs/11-hosting-bindws.md): run your own on your own domain.5151 - [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console.52+- [Costs and margins](docs/15-costs-and-margins.md): what a relay costs the host, what fuel charges, the weekly check against the bitcoin price.5253 5354 ## Quick start5455 @@ -22,7 +22,7 @@ Edit `wrangler.jsonc`:
2222 | `LEASE_DAYS` | how long a temporary relay from `POST /lease` lives; 14 if unset |2323 | `ratelimits` | how many leases a minute, per address and overall; the defaults are 5 and 60 |2424 25-The prices carry a comment with the Cloudflare rates they were set from. Revisit them when the exchange rate moves.25+The prices carry a comment with the Cloudflare rates they were set from. `node scripts/margin.mjs` compares them with those rates at the current bitcoin price, and a weekly workflow does the same; see [Costs and margins](15-costs-and-margins.md).2626 2727 Point `routes` at your zone: the apex as a custom domain and `*.<domain>/*` as a zone route. Create the R2 bucket named in `r2_buckets`.2828 @@ -0,0 +1,66 @@
1+---2+title: Costs and margins3+audience: developer4+---5+6+# Costs and margins7+8+What a relay costs the host, what fuel charges for it, and how to keep the two apart as the bitcoin price moves. The numbers come from one script:9+10+```11+node scripts/margin.mjs # today's price12+node scripts/margin.mjs --btc 60000 # a price you pick13+node scripts/margin.mjs --relays 5000 # the deployment at that size14+```15+16+It prints markdown and exits with code 1 when any priced line is under the target margin. A GitHub Actions workflow runs it every Monday, puts the tables in the run summary and opens or refreshes one issue when a line is under target.17+18+## The model19+20+Fuel prices four things because Cloudflare bills four things: SQLite storage, R2 storage, Durable Object duration and rows written. Each fuel price is a sats figure in `wrangler.jsonc`. Each Cloudflare price is a dollar figure on their pricing pages, copied into the script with the date it was checked. The bitcoin price joins the two.21+22+For a line with cost `c` dollars per unit and price `p` sats per unit at `B` dollars per bitcoin, revenue per unit is `p / 1e8 * B` and margin is `1 - c / revenue`. The margin target is 33% of revenue, which is a 1.5x markup on cost. The floor is 20%. Under the target the weekly run flags it; under the floor, reprice.23+24+Because prices are in sats and costs in dollars, margins move with the exchange rate on their own. A rising bitcoin widens them. A falling one narrows them, and the script prints, for each line, the bitcoin price under which the target no longer holds and the price under which the floor breaks.25+26+## The lines, checked Sept. 3, 202627+28+At $81,500 per bitcoin:29+30+| Line | Cloudflare | Fuel | Margin | Target holds above |31+|---|---|---|---|---|32+| Events stored | $0.20 per GB-month | 400 sats | 39% | $74,600 |33+| Files stored | $0.015 per GB-month | 30 sats | 39% | $74,600 |34+| Time awake | $0.0058 per hour | 11 sats | 36% | $78,200 |35+| Rows written | $1.00 per million | 2,000 sats | 39% | $74,600 |36+37+Time awake is an hour of a Durable Object held in memory at 128 MB, which Cloudflare bills as 460.8 GB-seconds. Hibernating objects are not billed, which is why an idle relay costs nothing.38+39+## The free tier40+41+A relay that spends its whole allowance costs the host about $1.61 a month, and rows written are $1.00 of that. Time awake is most of the rest. Storage is nearly free at these sizes.42+43+| Allowance | Amount | Cost |44+|---|---|---|45+| Events stored | 100 MB | $0.02 |46+| Files stored | 1 GB | $0.015 |47+| Time awake | 100 hours | $0.58 |48+| Rows written | 1 million | $1.00 |49+50+Few relays get near the allowance. A typical personal relay, 20 MB of events, 100 MB of files, eight hours awake and 100,000 rows a month, costs about 15 cents once the plan's included quotas are used up, and those quotas cover the first few dozen relays outright.51+52+## The deployment53+54+The script projects a fleet from two shapes, a typical relay and a heavy one that passes its allowances and pays fuel, at a chosen share of heavy relays. At 1,000 relays and 5% heavy, the Cloudflare bill is about $420 a month, fuel brings in about $220, and the gap is the free tier. Rows written are the largest line on the bill, then duration, then object requests, which nobody pays for because traffic is free to relays.55+56+Two things follow. The free tier is a cost the paid relays and the host share, and its size is the lever: `FREE_ROWS_WRITTEN` is the expensive allowance. And the unpriced lines, requests above all, are real; the 33% target exists to absorb them, which is why the target is not lower.57+58+## Weekly59+60+The workflow is `.github/workflows/margin.yml`. Read the summary. When a line is flagged:61+62+1. Run the script locally to see the suggested prices for the target at today's rate.63+2. Change the `SATS_PER_*` values in `wrangler.jsonc`. The comment above them records the rate they were set at; update it.64+3. Deploy. Fuel meters are unchanged; only the price of what is over the allowance moves, from the next charge.65+66+Prices go up when bitcoin falls. When it rises, do nothing until a line has sat above twice the target for a month, then bring it down. Relay owners see prices on the front page and on their Fuel section, so every change is visible the moment it deploys.@@ -0,0 +1,162 @@
1+// Fuel prices against what Cloudflare bills, at today's bitcoin price.2+//3+// node scripts/margin.mjs tables, markdown4+// node scripts/margin.mjs --btc 77000 at a given price5+// node scripts/margin.mjs --target 0.4 a different margin target6+// node scripts/margin.mjs --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.12+import { readFileSync } from "node:fs";13+14+const 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));15+const TARGET = Number(args.target ?? 0.33); // margin on revenue: 0.33 is a 1.5x markup16+const FLOOR = Number(args.floor ?? 0.2); // below this, reprice now17+18+// Cloudflare list prices, Workers Paid, USD. Checked 2026-09-03 against19+// developers.cloudflare.com/{durable-objects/platform,r2,workers/platform}/pricing.20+const 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+};32+33+// The relay's own numbers, from wrangler.jsonc (comments stripped).34+const cfg = JSON.parse(readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));35+const v = cfg.vars;36+const num = (k) => Number(v[k]);37+const GBS_PER_HOUR = 3600 * CF.doMemoryGB; // 460.8 GB-s per hour awake at 128 MB38+39+async 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+}55+56+const btc = await btcPrice();57+const usdToSats = (usd) => (usd / btc) * 1e8;58+const satsToUsd = (s) => (s / 1e8) * btc;59+const pct = (x) => (x * 100).toFixed(0) + "%";60+const money = (x) => (x < 0 ? "-" : "") + "$" + (Math.abs(x) < 0.1 ? Math.abs(x).toFixed(4) : Math.abs(x).toFixed(2));61+const amount = (x, unit) => (unit === "GB" && x < 1 ? `${Math.round(x * 1024)} MB` : `${x} ${unit}`);62+const n = (x) => Math.round(x).toLocaleString("en-US");63+64+// The four priced lines: what one unit costs us, what we charge.65+const 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+];71+for (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+}79+80+let out = [];81+out.push(`# Fuel margins at $${n(btc)} per bitcoin`, "");82+out.push(`Target margin ${pct(TARGET)} of revenue (a ${(1 / (1 - TARGET)).toFixed(2)}x markup). Reprice when a line falls under ${pct(FLOOR)}.`, "");83+out.push("| Line | Cloudflare cost | Cost in sats | Our price | Margin | Target holds while BTC is above | Price for target today |");84+out.push("|---|---|---|---|---|---|---|");85+for (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+}89+out.push("");90+91+// The free tier: what a relay that uses all of its allowance costs us.92+const freeCost = lines.reduce((a, l) => a + l.free * l.cost, 0);93+out.push("## The free tier", "");94+out.push("A relay that spends its whole monthly allowance costs, before the plan's included quotas absorb any of it:", "");95+out.push("| Allowance | Amount | Cost to us |");96+out.push("|---|---|---|");97+for (const l of lines) out.push(`| ${l.name} | ${amount(l.free, l.freeUnit)} | ${money(l.free * l.cost)} |`);98+out.push(`| total | | ${money(freeCost)} (${n(usdToSats(freeCost))} sats) |`, "");99+const coveredHours = CF.doDurationIncluded / GBS_PER_HOUR;100+out.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.`, "");101+102+// Lines nobody pays for: requests, reads, R2 operations, KV. Absorbed by margin.103+out.push("## What is not priced", "");104+out.push("Traffic is free to relays, but Cloudflare bills these. They come out of the margin on the four lines above.", "");105+out.push("| Line | Cloudflare cost | Included per month |");106+out.push("|---|---|---|");107+out.push(`| Durable Object requests (every websocket message, HTTP request and alarm) | $${CF.doRequestsPerM} per million | ${n(CF.doRequestsIncluded)} |`);108+out.push(`| Worker requests | $${CF.workerRequestsPerM} per million | ${n(CF.workerRequestsIncluded)} |`);109+out.push(`| rows read | $${CF.rowsReadPerM} per million | ${n(CF.rowsReadIncluded / 1e9)} billion |`);110+out.push(`| R2 writes (class A) | $${CF.r2ClassAPerM} per million | ${n(CF.r2ClassAIncluded)} |`);111+out.push(`| R2 reads (class B) | $${CF.r2ClassBPerM} per million | ${n(CF.r2ClassBIncluded)} |`);112+out.push(`| KV reads (custom domains) | $${CF.kvReadsPerM} per million | ${n(CF.kvReadsIncluded)} |`);113+out.push(`| the plan | $${CF.base} | |`, "");114+115+// A deployment projection: what N relays cost at a typical shape, and what the paid ones bring in.116+const relays = Number(args.relays ?? 1000);117+// Websocket messages count as object requests but never reach the Worker, so Worker requests are the HTTP door and page loads only.118+const typical = { eventsGB: 0.02, mediaGB: 0.1, hours: 8, rowsM: 0.1, doReqM: 0.3, workerReqM: 0.01, rowsReadM: 5, r2AM: 0.002, r2BM: 0.02 };119+const heavy = { eventsGB: 0.5, mediaGB: 3, hours: 200, rowsM: 3, doReqM: 5, workerReqM: 0.2, rowsReadM: 100, r2AM: 0.05, r2BM: 0.5 };120+function bill(count, heavyShare) {121+ const h = Math.round(count * heavyShare), t = count - h;122+ const sum = (k) => t * typical[k] + h * heavy[k];123+ const over = (x, inc) => Math.max(0, x - inc);124+ const items = {125+ "SQLite storage": over(sum("eventsGB"), CF.sqlIncludedGB) * CF.sqlGBMonth,126+ "R2 storage": over(sum("mediaGB"), CF.r2IncludedGB) * CF.r2GBMonth,127+ "DO duration": (over(sum("hours") * GBS_PER_HOUR, CF.doDurationIncluded) / 1e6) * CF.doDurationPerMGBs,128+ "rows written": (over(sum("rowsM") * 1e6, CF.rowsWrittenIncluded) / 1e6) * CF.rowsWrittenPerM,129+ "DO requests": (over(sum("doReqM") * 1e6, CF.doRequestsIncluded) / 1e6) * CF.doRequestsPerM,130+ "Worker requests": (over(sum("workerReqM") * 1e6, CF.workerRequestsIncluded) / 1e6) * CF.workerRequestsPerM,131+ "rows read": (over(sum("rowsReadM") * 1e6, CF.rowsReadIncluded) / 1e6) * CF.rowsReadPerM,132+ "R2 operations": (over(sum("r2AM") * 1e6, CF.r2ClassAIncluded) / 1e6) * CF.r2ClassAPerM + (over(sum("r2BM") * 1e6, CF.r2ClassBIncluded) / 1e6) * CF.r2ClassBPerM,133+ "the plan": CF.base,134+ };135+ // Revenue: only heavy relays pass an allowance; charge them for what they use past it.136+ const overUse = (k, free, price) => h * Math.max(0, heavy[k] - free) * satsToUsd(price);137+ 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"));138+ return { items, total: Object.values(items).reduce((a, b) => a + b, 0), revenue, heavy: h };139+}140+out.push(`## The deployment at ${n(relays)} relays`, "");141+out.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.doReqM}M object requests a month. A heavy one: ${heavy.eventsGB * 1024} MB, ${heavy.mediaGB} GB, ${heavy.hours} hours, ${heavy.rowsM}M rows, ${heavy.doReqM}M requests. Heavy relays pass their allowances and pay fuel; typical ones ride free.`, "");142+out.push("| Heavy share | Cloudflare bill | Fuel revenue | Net | Per relay |");143+out.push("|---|---|---|---|---|");144+for (const share of [0, 0.02, 0.05, 0.1]) {145+ const b = bill(relays, share);146+ out.push(`| ${pct(share)} (${n(b.heavy)} relays) | ${money(b.total)} | ${money(b.revenue)} | ${money(b.revenue - b.total)} | ${money(b.total / relays)} |`);147+}148+const b5 = bill(relays, 0.05);149+out.push("", `Where the bill goes 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(", ") + ".", "");150+151+// Repricing guidance.152+out.push("## Weekly check", "");153+const worst = lines.reduce((a, l) => (l.margin < a.margin ? l : a));154+out.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.`, "");155+out.push("To reprice at today's rate for the target margin, set in `wrangler.jsonc`:", "");156+out.push("```");157+for (const l of lines) out.push(`"${l.key}": "${l.suggest}"${l.suggest === l.price ? "" : ` // was ${l.price}`}`);158+out.push("```", "");159+out.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.");160+161+console.log(out.join("\n"));162+process.exitCode = lines.some((l) => l.margin < TARGET) ? 1 : 0;@@ -37,7 +37,8 @@
3737 "FREE_ACTIVE_HOURS": "100",3838 "FREE_ROWS_WRITTEN": "1000000",3939 // Prices follow what Cloudflare bills us for the same thing, at about40- // 1.5x, converted at 77,000 USD per bitcoin. Revisit when the rate moves.40+ // 1.5x, converted at 77,000 USD per bitcoin. `node scripts/margin.mjs`41+ // checks them against the rate of the day (docs/15-costs-and-margins.md).4142 // SQLite storage 0.20 USD/GB-month -> 260 sats4243 // R2 storage 0.015 USD/GB-month -> 20 sats4344 // DO duration 12.50 USD/M GB-s at 128 MB -> 7.3 sats per hour awake