- commit
- df3cd487ef72e9bf8ce0fabe1b0c7770ef806560
- parent
- 8f146fd30c7b93ca460f013d644e848450f5746a
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | docs/ | 11 | +- |
| M | docs/ | 152 | +++++++++++++++++++++++++++++- |
| M | scripts/ | 27 | +++-- |
| M | src/ | 11 | +- |
| M | src/ | 9 | +- |
| M | test/ | 12 | +- |
| A | test/ | 32 | ++++++ |
@@ -55,7 +55,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena
5555 - [Hosting bind.ws](docs/11-hosting-bindws.md): run your own on your own domain, and watch it through logs, traces and metrics.5656 - [Hosting without Cloudflare](docs/16-hosting-without-cloudflare.md): the same Worker on celld, with your own bucket and proxy, and what "supported" promises.5757 - [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console, the templates.58-- [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.58+- [Costs and margins](docs/15-costs-and-margins.md): tenant meters, host accounting gaps, margin scenarios and the proposed maintenance budget contract.5959 6060 ### Draft NIPs6161 @@ -26,12 +26,11 @@ charge attached to moving or storing those bytes.
2626 2727 **Time awake** estimates how long the relay is in memory from its entrypoints.2828 It can sleep between messages, while alarms and scheduled jobs wake it even29-when no client is connected. A relay with a client connected all day is awake only while messages30-pass, plus a few seconds after each. Jobs on the Sync tab, pulls, backfills and31-rebroadcasts wake the relay on their own and spend this line while they run; a32-standing job spends it on every run. Git requests and site work also wake the33-relay, although the exact provider duration can differ from the relay's34-entrypoint meter.29+when no client is connected. The meter adds ten seconds after a wake, then30+the gap between entrypoints capped at ten seconds. It does not time a whole31+long-running job. Jobs on the Sync tab, pulls, backfills, rebroadcasts, Git32+requests and site work all wake the relay. The host's actual duration cost33+can be higher or lower than this estimate.3534 3635 Dumps, the JSONL files the Data tab can write on a schedule, live next to uploaded files and count as files stored. What each job and dump does is in [Data and names](04-data-and-names.md).3736 @@ -40,7 +40,7 @@ operation is not measured as an exact request duration.
4040 Those costs belong in the host's deployment review and margin target; they do4141 not silently change the four meters shown to a relay owner.4242 43-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.43+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 script defaults to a 33% margin on revenue, which prices a line at about 1.493x cost. The floor is 20%. Under the target the weekly run flags it; under the floor, review pricing.4444 4545 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.4646 @@ -59,7 +59,7 @@ Time awake is an hour of a Durable Object held in memory at 128 MB, which Cloudf
5959 6060 ## The free tier6161 62-At the dated rates above, a relay that spends its whole allowance costs the host about $0.87 a month. Time awake is most of it. Storage is nearly free at these sizes. The rows allowance was a million until Sept. 2026, which made it $1.00 of a $1.61 tier; it came down to 250,000, about 15 times what a personal relay writes, because rows are the one line Cloudflare charges real money per unit.62+At the dated rates above, a relay that spends its whole allowance costs the host about $0.86 a month. Time awake is most of it. Storage is nearly free at these sizes. The rows allowance was a million until Sept. 2026, which made it $1.00 of a $1.61 tier; it came down to 250,000, about 15 times what a personal relay writes, because rows are the one line Cloudflare charges real money per unit.6363 6464 | Allowance | Amount | Cost |6565 |---|---|---|@@ -72,9 +72,9 @@ Few relays get near the allowance. At the dated rates above, a typical personal
7272 7373 ## The deployment7474 75-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. In the dated 1,000-relay example with 5% heavy, the Cloudflare bill is about $420 a month, fuel brings in about $280, and the gap is the free tier. Rows written are the largest modeled line on the bill, then duration. The fleet projection includes platform requests, rows read and R2 operations as host costs even though these have no separate tenant price. It lists KV read pricing, but does not project site-index KV usage or custom-hostname fees.75+The script projects a fleet from two illustrative shapes, a typical relay and a heavy one that passes its allowances and pays fuel, at a chosen share of heavy relays. These are assumptions, not measured customer workloads or a validated provider bill. The fleet projection includes platform requests, rows read and R2 operations as host costs even though these have no separate tenant price. Worker CPU, logs, traces, Analytics Engine, site-index KV operations, custom-hostname fees, payment fees and support still need deployment measurements or separate budgets.7676 77-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 provider lines, especially request operations, are real; the 33% target exists to absorb them, which is why the target is not lower.77+The free tier is a cost the paid relays and the host share. Unpriced operations consume part of the margin on paid usage. A target on four priced lines alone does not establish a fleet margin after free users and shared costs.7878 7979 The bounded GRASP implementation adds a relay-wide ceiling of 320 MiB for8080 retained Git objects, with 4 MiB packs, 16 MiB packed history and bounded@@ -100,3 +100,147 @@ The workflow is `.github/workflows/margin.yml`. Read the summary. When a line is
100100 3. Deploy. Fuel meters are unchanged; only the price of what is over the allowance moves, from the next charge.101101 102102 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. The table above was checked on Sept. 3, 2026; rerun `npm run margin` and verify current provider pricing before using it for a new forecast.103+104+## Accounting coverage, checked Sept. 4, 2026105+106+The following inventory describes the host with ntig 0.1.1. Observing an107+expense does not authorize a new customer charge. `/fuel` and the Health tab108+expose aggregate tenant meters, not every operation the host pays for.109+110+| Dimension | Existing source and persistence | Coverage limit |111+|---|---|---|112+| Usage and credits | [Fuel](../src/fuel.ts), monthly SQLite usage and unique receipt IDs | Counters become durable only after a flush; receipts prevent duplicate credit |113+| SQLite storage | `Relay.eventBytes`, the database's allocated size | Includes tables beyond events; daily charging uses the current size, not a continuous storage integral |114+| R2 storage | `Relay.mediaBytes`, blobs + dumps + imports + `grasp_objects` | Shared allowance; retained history and conservative reservations can outlive visible refs |115+| Awake time | `Relay.touch`, in-memory ten-second entrypoint gaps | No handler completion timing; long async work, idle gaps and restart estimates differ from provider duration |116+| SQL rows | [Store](../src/store.ts), cursors from `Store.x`, then `Relay.tally` | Direct SQL in settings, GRASP, sites, media and other modules bypasses the meter; DO key-value and alarm work is not covered |117+| Traffic | `Relay.meterBytes` and socket counters, then monthly usage | No fuel price; socket string lengths and attempted/produced HTTP bytes are not exact delivered wire bytes |118+| Requests | [Worker routing](../src/index.ts), HTTP, RPC, sockets and alarms | No durable per-tenant request ledger; application REQ counters are not platform request counts |119+| R2 operations | [Git object wrapper](../src/grasp.ts), [Blossom](../src/blossom.ts), [site mirrors](../src/site-mirror.ts) and dumps | No durable per-tenant Class A/B ledger; observer callbacks are best-effort evidence |120+| Jobs and alarms | [Jobs](../src/jobs.ts) and `Relay.alarm` | Required cleanup, mirrors and retries consume host work; no optional maintenance budget exists |121+| KV and external services | [Host routing](../src/edge.ts), [site index](../src/sites.ts), notifications and provider fetches | No tenant operation meter for reads, writes, misses, retries or external fees |122+| Observability | `Relay.flushUsage`, console deltas and optional Analytics Engine | Logs, traces and analytics are expense and diagnosis sources, not the durable fuel ledger |123+124+[Relay](../src/relay.ts) batches usage in memory. A restart can lose an125+unflushed batch. `flushUsage` resets counters before `Fuel.record`; a failed126+record can therefore lose that batch too. Its final `Store.drain` discards127+pending cursors, so a direct flush without `tally` can omit rows. Usage is128+assigned to the month of the flush rather than split across the work's month129+boundary. These are accounting gaps, not grounds for reconstructing charges130+against old tenant balances.131+132+Git reserves the larger retained object size in SQLite before PUT. Success133+reconciles the size; a definite CAS loss uses HEAD to reconcile it. An134+exception leaves the reservation because the write may have succeeded. No135+automatic reconciliation queue or orphan collector exists. Counting PUT136+bytes again as stored bytes would double-charge existing media accounting.137+Future temporary packs also need reservations while both copies exist.138+139+`graspBusy`, `graspControls` and the job round's `working` flag fence work140+within a live instance. Async handlers can interleave across awaits. Those141+flags are admission controls, not persisted crash recovery or authority to142+delete data. Repository CAS and persisted authority remain necessary.143+144+Provider request accounting separates HTTP/RPC/alarm invocations from145+incoming WebSocket messages, which have a 20:1 billing ratio. Socket frames146+do not become Worker requests. DO duration counts overlapping work once per147+object; idle objects eligible for hibernation have no duration charge.148+DO key-value operations and alarm writes also consume SQLite rows. Included149+allocations and billing-unit rounding apply at account level.150+See [Durable Object pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/).151+152+R2 Standard includes pooled storage and operation allowances. Beyond them,153+storage is $0.015/GB-month, Class A operations $4.50/million and Class B154+$0.36/million, with billing-unit rounding. Free egress does not remove those155+operation costs. See [R2 pricing](https://developers.cloudflare.com/r2/pricing/).156+[Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/),157+[KV pricing](https://developers.cloudflare.com/kv/platform/pricing/) and158+[Cloudflare for SaaS plans](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/plans/)159+cover other host expenses. These sources were checked on Sept. 4, 2026.160+161+## A 40% margin scenario162+163+This is a planning target; the deployed rates, allowances and weekly default164+remain unchanged. A 40% gross margin means `price = cost / 0.60`, or about165+1.667x cost. Multiplying cost by 1.40 produces only a 28.57% margin.166+167+```168+npm run margin -- --btc 60000 --target 0.40169+npm run margin -- --btc 80000 --target 0.40170+npm run margin -- --btc 100000 --target 0.40171+```172+173+These are Sept. 4 planning scenarios, not bitcoin spot quotes. At the174+current sats rates, before free users and other expenses:175+176+| BTC scenario | Events, files and rows margin | Awake margin |177+|---|---|---|178+| $60,000 | 16.67% | 12.73% |179+| $80,000 | 37.50% | 34.55% |180+| $100,000 | 50.00% | 47.64% |181+182+The 40% threshold is about $83,333/BTC for events, files and rows, and183+$87,273/BTC for awake time at $0.00576 per provider hour. The script already184+uses gross margin; its existing 33% default was never a 40% markup formula.185+186+For a whole deployment, the planning condition is187+`revenue >= (provider bill + shared operating costs + subsidy) / 0.60`.188+The subsidy is only free-user cost not already included in the provider bill;189+counting it twice overstates expense. Provider allocations are pooled once,190+including other services on the account, rather than granted anew per relay.191+192+An illustrative personal relay consumes about $0.152 of the four resource193+lines at marginal list rates: 20 MB events, 100 MB files, eight provider hours194+and 100,000 written rows. A relay consuming all current allowances reaches195+about $0.861 before other costs and account rounding. Actual duration and row196+usage must come from provider evidence because the tenant meters are partial.197+198+Storage-light workloads can still be expensive. One million Class A and ten199+million Class B operations beyond the pooled allocation cost $8.10 even if200+they repeatedly access only a few kilobytes. Across 100 such relays that is201+$810 before duration, requests or rows; storage revenue alone cannot cover it.202+A fleet forecast therefore needs active tenant share, utilization, operation203+mix, cache misses, retries and concentration in its busiest tenants, alongside204+FX and payment-fee sensitivity. The script's two shapes do not establish this205+distribution.206+207+## What can be bundled208+209+These are proposed operating boundaries, not new allowances or charges.210+211+| Work | Proposed treatment |212+|---|---|213+| Bounded metadata and health reads during ordinary use | Bundled with request and abuse caps; no separate tiny surcharge |214+| Service-caused retries and ambiguous-write reconciliation | Host operating budget; record attempts and expense without charging a second logical operation |215+| Existing retained data | Existing storage meter, including reserved and temporary bytes; no second Git storage charge |216+| Free tenants | Explicit monthly fleet subsidy budget with utilization and heavy-tail review |217+| High-volume scans, repeated cold clones and uncached site fetches | Bounded work and request quotas; operation-heavy use cannot be treated as unlimited because bytes are unpriced |218+| Optional optimization | Tenant-visible budget and deferral; extra wake-ups require opt-in |219+220+## Proposed maintenance contract221+222+ntig 0.1.1 has no resumable optimization runner. No maintenance job, budget223+setting, wake schedule, format migration or garbage collection is enabled by224+this contract. Existing ref cleanup, retention and alarms keep their current225+roles. An eventual integration has these boundaries:226+227+| Stage | Host and backend contract |228+|---|---|229+| Eligibility | Cheap stored statistics identify pack/read amplification, reclaimable bytes or a limit approaching; no daily full scan or unconditional repack |230+| Admission | Optional work starts only while the tenant is already active by default; foreground requests take priority before each bounded step |231+| Reservation | A durable job ID and UTC budget month reserve bounded work, operation counts and temporary retained bytes before execution |232+| Execution | A resumable cursor, bounded I/O and a cooperative deadline limit each step; a timer alone cannot preempt arbitrary work safely |233+| Completion | Persist outcome and actual expense once per step; stable attempt IDs separate retries from completed logical work |234+| Recovery | A crash or ambiguous write keeps its reservation until reconciliation; CAS and persisted authority protect state across restarts |235+| Scheduling | Extra wakes require opt-in, a monthly budget and an allowed window; later window, manual run and pause are separate choices |236+| Exhaustion | Optional work defers; a write that cannot safely fit a hard limit fails clearly without discarding protected data |237+| Retention | Optimization never authorizes deleting retained history; reachability, readers and retention rules need a separate proof |238+239+Host expense accounting stays separate from tenant charge calculation. A240+future duration observer measures the union of registered work intervals,241+including long steps, without claiming it is the provider's entire active242+lifetime. Its validation covers overlap, mid-step flush, month boundaries,243+crash before and after persistence, retries, reservation exhaustion and244+foreground arrival. The host contract also needs tests for ambiguous PUT,245+temporary copies, authority changes and paused scheduling before it runs246+against customer data.@@ -12,10 +12,10 @@
1212 import { readFileSync } from "node:fs";1313 1414 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 markup15+const TARGET = Number(args.target ?? 0.33); // gross margin on revenue; price is cost / (1 - target)1616 const FLOOR = Number(args.floor ?? 0.2); // below this, reprice now1717 18-// Cloudflare list prices, Workers Paid, USD. Checked 2026-09-03 against18+// Cloudflare list prices, Workers Paid, USD. Checked 2026-09-04 against1919 // developers.cloudflare.com/{durable-objects/platform,r2,workers/platform}/pricing.2020 const CF = {2121 base: 5, // per month, the plan itself@@ -79,7 +79,7 @@ for (const l of lines) {
7979 8080 let out = [];8181 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)}.`, "");82+out.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)}.`, "");8383 out.push("| Line | Cloudflare cost | Cost in sats | Our price | Margin | Target holds while BTC is above | Price for target today |");8484 out.push("|---|---|---|---|---|---|---|");8585 for (const l of lines) {@@ -101,10 +101,11 @@ out.push(`The plan includes ${n(coveredHours)} awake hours, ${n(CF.rowsWrittenIn
101101 102102 // Lines nobody pays for: requests, reads, R2 operations, KV. Absorbed by margin.103103 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.", "");104+out.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.", "");105105 out.push("| Line | Cloudflare cost | Included per month |");106106 out.push("|---|---|---|");107-out.push(`| Durable Object requests (every websocket message, HTTP request and alarm) | $${CF.doRequestsPerM} per million | ${n(CF.doRequestsIncluded)} |`);107+out.push(`| Durable Object requests (HTTP, RPC and alarms) | $${CF.doRequestsPerM} per million | ${n(CF.doRequestsIncluded)} |`);108+out.push(`| Durable Object inbound WebSocket messages (20:1 request ratio) | $${CF.doRequestsPerM} per million billable requests | included with DO requests |`);108109 out.push(`| Worker requests | $${CF.workerRequestsPerM} per million | ${n(CF.workerRequestsIncluded)} |`);109110 out.push(`| rows read | $${CF.rowsReadPerM} per million | ${n(CF.rowsReadIncluded / 1e9)} billion |`);110111 out.push(`| R2 writes (class A) | $${CF.r2ClassAPerM} per million | ${n(CF.r2ClassAIncluded)} |`);@@ -115,18 +116,22 @@ out.push(`| the plan | $${CF.base} | |`, "");
115116 // A deployment projection: what N relays cost at a typical shape, and what the paid ones bring in.116117 const relays = Number(args.relays ?? 1000);117118 // 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 };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.122+const 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 };123+const 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 };120124 function bill(count, heavyShare) {121125 const h = Math.round(count * heavyShare), t = count - h;122126 const sum = (k) => t * typical[k] + h * heavy[k];127+ const doBillableRequestsM = sum("doHttpRpcAlarmM") + sum("wsMessagesM") / 20;123128 const over = (x, inc) => Math.max(0, x - inc);124129 const items = {125130 "SQLite storage": over(sum("eventsGB"), CF.sqlIncludedGB) * CF.sqlGBMonth,126131 "R2 storage": over(sum("mediaGB"), CF.r2IncludedGB) * CF.r2GBMonth,127132 "DO duration": (over(sum("hours") * GBS_PER_HOUR, CF.doDurationIncluded) / 1e6) * CF.doDurationPerMGBs,128133 "rows written": (over(sum("rowsM") * 1e6, CF.rowsWrittenIncluded) / 1e6) * CF.rowsWrittenPerM,129- "DO requests": (over(sum("doReqM") * 1e6, CF.doRequestsIncluded) / 1e6) * CF.doRequestsPerM,134+ "DO requests": (over(doBillableRequestsM * 1e6, CF.doRequestsIncluded) / 1e6) * CF.doRequestsPerM,130135 "Worker requests": (over(sum("workerReqM") * 1e6, CF.workerRequestsIncluded) / 1e6) * CF.workerRequestsPerM,131136 "rows read": (over(sum("rowsReadM") * 1e6, CF.rowsReadIncluded) / 1e6) * CF.rowsReadPerM,132137 "R2 operations": (over(sum("r2AM") * 1e6, CF.r2ClassAIncluded) / 1e6) * CF.r2ClassAPerM + (over(sum("r2BM") * 1e6, CF.r2ClassBIncluded) / 1e6) * CF.r2ClassBPerM,@@ -138,15 +143,15 @@ function bill(count, heavyShare) {
138143 return { items, total: Object.values(items).reduce((a, b) => a + b, 0), revenue, heavy: h };139144 }140145 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 |");146+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.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.`, "");147+out.push("| Heavy share | Modeled provider cost | Fuel revenue | Net before omitted costs | Cost per relay |");143148 out.push("|---|---|---|---|---|");144149 for (const share of [0, 0.02, 0.05, 0.1]) {145150 const b = bill(relays, share);146151 out.push(`| ${pct(share)} (${n(b.heavy)} relays) | ${money(b.total)} | ${money(b.revenue)} | ${money(b.revenue - b.total)} | ${money(b.total / relays)} |`);147152 }148153 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(", ") + ".", "");154+out.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(", ") + ".", "");150155 151156 // Repricing guidance.152157 out.push("## Weekly check", "");@@ -1,7 +1,8 @@
11 // Fuel: quotas on what a relay costs to run, topped up by zaps. Four things2-// are metered and priced, each mirroring a line on the Cloudflare bill:3-// events stored (SQLite), media stored (R2), time awake (Durable Object4-// duration) and rows written. Traffic is metered but free, as it is for us.2+// 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.56 // Every relay gets a free monthly allowance of each; beyond it, usage burns67 // sats credited by NIP-57 zap receipts that arrive at the relay itself as78 // ordinary events. Receipts are@@ -21,8 +22,8 @@ export interface FuelConfig {
2122 servicePubkey: string; // recipient pubkey zap requests must name2223 // Free per relay per month.2324 freeEventsMB: number; // SQLite: events, tags, search index24- freeMediaMB: number; // R2: Blossom blobs25- freeActiveHours: number; // wall-clock time the object is awake25+ freeMediaMB: number; // R2: blobs, dumps, imports and retained Git objects26+ freeActiveHours: number; // the relay's entrypoint-based awake estimate2627 freeRowsWritten: number; // SQLite rows written2728 // Beyond the allowances. Each tracks what Cloudflare bills for the same2829 // thing, so the prices in wrangler.jsonc are set from those rates.@@ -100,7 +100,7 @@ export const IP_LIMIT_MULTIPLE = 4;
100100 function clientIP(req: Request): string {101101 return req.headers.get("x-relay-ip") || "unknown";102102 }103-// How long Cloudflare keeps an idle object awake before hibernating it.103+// The legacy fuel estimate caps entrypoint gaps at this window.104104 const IDLE_GRACE_MS = 10_000;105105 106106 export class Relay extends DurableObject<Env> {@@ -201,10 +201,9 @@ export class Relay extends DurableObject<Env> {
201201 this.meter.bytesOut += bytesOut;202202 }203203 204- // touch accounts for the object being awake. Cloudflare bills wall-clock205- // time from a wake until the object has been idle for about ten seconds206- // and hibernates, so each piece of work is charged the gap since the last207- // one, capped at that idle window, and a wake is charged the window once.204+ // touch estimates awake time from entrypoint gaps, with one window on wake.205+ // It does not measure long handlers or Cloudflare's billable duration:206+ // idle objects eligible for hibernation incur no provider duration charge.208207 private touch() {209208 const t = Date.now();210209 this.meter.activeMs += this.lastActive === 0 ? IDLE_GRACE_MS : Math.min(t - this.lastActive, IDLE_GRACE_MS);@@ -34,10 +34,12 @@ const receivePack = (path: string, host: string, old: string | null, next: strin
3434 headers: { "content-type": "application/x-git-receive-pack-request" },3535 body: concat(packet(`${old ?? "0".repeat(40)} ${next ?? "0".repeat(40)} ${ref}\0report-status\n`), flush(), ...(pack ? [pack] : [])),3636 });37+// receiveSettled waits for transient alarm contention before testing the Git response.3738 const receiveSettled = async (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array) => {3839 for (let attempt = 0; attempt < 20; attempt++) {3940 const response = await receivePack(path, host, old, next, ref, pack);4041 if (response.status !== 429) return response;42+ await response.arrayBuffer();4143 await new Promise((resolve) => setTimeout(resolve, 10));4244 }4345 throw new Error("Git transaction did not become available");@@ -214,10 +216,10 @@ describe("GRASP", () => {
214216 expect((await c.ok(state)).ok).toBe(true); c.ws.close();215217 const path = gitRepositoryPath(npubEncode(pk(owner)), "pr-refs");216218 217- expect((await receivePack(path, host, null, pack.commitID, "refs/heads/main", pack.pack)).status).toBe(200);219+ expect((await receiveSettled(path, host, null, pack.commitID, "refs/heads/main", pack.pack)).status).toBe(200);218220 const realPR = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:pr-refs`], ["c", pack.commitID]]);219221 const unknownRef = `refs/nostr/${realPR.id}`;220- expect((await receivePack(path, host, null, pack.commitID, unknownRef, pack.pack)).status).toBe(200);222+ expect((await receiveSettled(path, host, null, pack.commitID, unknownRef, pack.pack)).status).toBe(200);221223 expect(await (await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`)).text()).toContain(`${pack.commitID} ${unknownRef}`);222224 const deletion = await receiveSettled(path, host, pack.commitID, null, unknownRef);223225 expect(deletion.status).toBe(200);@@ -225,20 +227,20 @@ describe("GRASP", () => {
225227 226228 const late = await WS.connect(host);227229 expect((await late.ok(realPR)).ok).toBe(true);228- expect((await receivePack(path, host, pack.commitID, pack.commitID, "refs/heads/main")).status).toBe(200);230+ expect((await receiveSettled(path, host, pack.commitID, pack.commitID, "refs/heads/main")).status).toBe(200);229231 expect((await late.req({ ids: [realPR.id] })).map((e) => e.id)).toEqual([realPR.id]);230232 const listed = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);231233 expect(await listed.text()).toContain(`refs/nostr/${realPR.id}`);232234 const wrongPR = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:pr-refs`], ["c", "b".repeat(40)]]);233235 const wrongEvent = await WS.connect(host);234236 expect((await wrongEvent.ok(wrongPR)).ok).toBe(true);235- const mismatch = await receivePack(path, host, null, pack.commitID, `refs/nostr/${wrongPR.id}`);237+ const mismatch = await receiveSettled(path, host, null, pack.commitID, `refs/nostr/${wrongPR.id}`);236238 expect(await mismatch.text()).toContain(`ng refs/nostr/${wrongPR.id}`);237239 wrongEvent.ws.close();238240 239241 const expiringID = "c".repeat(64);240242 const expiringRef = `refs/nostr/${expiringID}`;241- expect(await (await receivePack(path, host, null, pack.commitID, expiringRef, pack.pack)).text()).toContain(`ok ${expiringRef}`);243+ expect(await (await receiveSettled(path, host, null, pack.commitID, expiringRef, pack.pack)).text()).toContain(`ok ${expiringRef}`);242244 await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (relay) => {243245 relay.sql.exec("UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?", `30617:${pk(owner)}:pr-refs`, expiringRef);244246 });@@ -0,0 +1,32 @@
1+import { spawnSync } from "node:child_process";2+import { fileURLToPath } from "node:url";3+import { describe, expect, it } from "vitest";4+5+const script = fileURLToPath(new URL("../../scripts/ops/margin.mjs", import.meta.url).href);6+const forecast = (...args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8" });7+8+describe("margin", () => {9+ it("uses gross margin for target prices and keeps the default target unchanged", () => {10+ const result = forecast("--btc", "80000", "--target", "0.4");11+ expect(result.status).toBe(1);12+ expect(result.stdout).toContain("price is cost / 0.60; 67% markup on cost");13+ expect(result.stdout).toContain("$83,333 | 417 sats");14+ expect(result.stdout).toContain("$83,333 | 32 sats");15+ expect(result.stdout).toContain("$87,273 | 13 sats");16+ expect(result.stdout).toContain("$83,333 | 2,084 sats");17+ const normal = forecast("--btc", "80000");18+ expect(normal.status).toBe(0);19+ expect(normal.stdout).toContain("Target gross margin 33%");20+ });21+22+ it("discounts incoming socket messages once and pools the account request allowance once", () => {23+ const result = forecast("--btc", "100000", "--relays", "1000");24+ expect(result.status).toBe(0);25+ // 950 * (10k + 290k/20) + 50 * (200k + 4.8M/20) = 45.275M.26+ // Less one pooled 1M allowance, at $0.15/M, gives $6.64125.27+ expect(result.stdout).toContain("DO requests $6.64");28+ // 950 * 10k + 50 * 200k = 19.5M Worker requests; frames stay in the DO.29+ expect(result.stdout).toContain("Worker requests $2.85");30+ expect(result.stdout).toContain("not an invoice");31+ });32+});