Methodology
github.com/proofoftrust21/satrank. ~1600 LOC, 14 source files, 9 Postgres tables.1. Overview
SatRank is a trust oracle for L402 endpoints on Bitcoin Lightning. It crawls a catalogue of paid HTTP services, probes each one across five conditional stages, and maintains a streaming Bayesian posterior per (endpoint, stage) pair. Agents query a category + budget + SLA and receive the top-K candidates ranked by end-to-end success probability, with per-stage breakdown and 95% credibility intervals.
The oracle does not pay endpoints on the agent's behalf. It does not custody funds. It does not maintain a bond pool, an audit chain, or a fulfill proxy. It is a discovery + scoring layer that sits BEFORE the payment hop.
2. Anti-goals
V3 is a deliberate compression of earlier prototypes. The following capabilities, all present in V1 / V2, are not in V3:
- No fulfill proxy. Agents pay endpoints with their own Lightning wallet.
- No operator hierarchy. The catalogue is unowned ; nobody claims endpoints.
- No federation. No peer-oracle discovery (kind 30784 dropped), no cross-oracle aggregation.
- No calibration history publication. kind 30783 dropped.
- No crowd reports. kind 7402 dropped — single source of truth is the oracle's own probes.
- No NIP-90 DVM surface. Three transports collapsed to one (HTTP) plus the MCP wrapper.
- No deterministic scoring version. The score is the streaming Beta posterior — there is no "v18" or "v26".
- No SDK packages. Three HTTP routes you can curl. The MCP package wraps them ; nothing else.
What remains : a Bayesian probe ledger and a ranking endpoint. That is the entirety of V3.
3. Catalog
The crawler ingests endpoints from three sources, deduplicated by url_hash = sha256(url):
| Source | Shape | How |
|---|---|---|
| l402.directory | JSON | GET /api/services — flattens services × endpoints into one row per (service, endpoint) pair. URLs containing {template} placeholders are skipped. |
| l402-index RSS | RSS feed | Aggregation feed published by the L402 community ; parsed for <link> + <category> tags. |
| DNS TXT | _l402.<host> | Operator self-publish. The TXT value carries a JSON descriptor (name, category, price_sats, http_method). |
Storage shape
service_endpoints (
url_hash TEXT PRIMARY KEY,
url TEXT UNIQUE,
category TEXT, -- primary tag (= category_tags[0])
category_tags TEXT[] DEFAULT '{}', -- multi-cat, GIN-indexed
name TEXT,
description TEXT,
http_method TEXT CHECK (http_method IN ('GET','POST','PUT','DELETE')),
price_sats INTEGER,
source TEXT, -- 'l402_directory' | 'rss' | 'dns'
added_at BIGINT,
last_probe_at BIGINT
)
The category_tags array is GIN-indexed so the ranking query $1 = ANY(category_tags) stays O(log n). A service legitimately listed under {video, streaming, content} matches any of those in /api/intent.
Cadence
The crawler runs every CRAWLER_INTERVAL_SEC seconds (default 900s = 15 min). Each tick probes endpoints whose last_probe_at is older than the interval, capped at LIMIT 200. At cadence 15 min, every endpoint reaches n_obs ≈ 10 in 2.5 hours.
4. Five-stage decomposition
Every probe writes one row to endpoint_observations. The L402 contract decomposes into five conditional stages — each conditioned on the previous succeeding:
| # | Stage | What is observed |
|---|---|---|
| 1 | challenge | HTTP responds 402 with WWW-Authenticate: L402 macaroon=<b64> invoice=<bolt11>. Confirms the endpoint is L402-compliant and reachable. |
| 2 | invoice | The BOLT11 returned in the challenge decodes successfully and prices ≤ PROBE_MAX_INVOICE_SATS (default 1000 sats). |
| 3 | payment | The probe pays the invoice via local LND (paid probes only). HTLC settles within PROBE_FETCH_TIMEOUT_MS (default 15s). |
| 4 | delivery | The retry with the preimage as bearer returns 2xx with a non-empty body within budget. |
| 5 | quality | The body parses as expected (heuristic per content-type — JSON valid, etc.). Body size capped at 256 KB to prevent OOM from a malicious endpoint streaming GB. |
A probe stops at the first failed stage. A 401-without-challenge counts as challenge_ok = false only — no other stage is observed. A non-paying probe (free probe) records stages 1–2 only ; payment_ok / delivery_ok / quality_ok are NULL on that row.
5. Probing
Two probe modes coexist:
5.1 Free probe (default)
The probe poses a fresh GET / POST as configured in http_method, expects the L402 challenge, decodes the invoice, and stops. Records challenge_ok + invoice_ok only. Costs zero sats.
5.2 Paid probe (opt-in)
When PAID_PROBE_ENABLED=true AND a Lightning invoice is below PROBE_MAX_INVOICE_SATS AND the cumulative paid-probe spend in the current UTC day is under PAID_PROBE_DAILY_BUDGET_SATS (default 2000 sats), the probe pays the invoice via LND and continues through stages 3-4-5. Live spend visible at /api/oracle/budget.
5.3 SSRF guard
Every probe URL passes through assertSafeUrl() (src/ssrf.ts). Rejects:
- Non-HTTPS schemes (only
https://is probed). - RFC1918 private ranges :
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. - Loopback :
127.0.0.0/8,::1. - Link-local :
169.254.0.0/16,fe80::/10. - IPv6 ULA :
fc00::/7(fc, fd prefixes). - Multicast :
ff00::/8.
This stops a malicious catalog entry from pivoting probes into the host's internal network.
6. Bayesian scoring
For each (url_hash, stage) pair, SatRank maintains a streaming Beta posterior with uniform prior α₀ = β₀ = 1 (Beta(1,1) is the maximum-entropy prior on [0,1]).
On every observation:
Stage mean (point estimate of the success probability):
End-to-end success probability — multiplicative, assuming stage independence (true enough since stages are sequential and represent distinct failure modes):
The independence assumption is conservative : in practice the stages are positively correlated (an endpoint that fails delivery also tends to fail quality), so the multiplicative bound is a slight under-estimate of the true joint success rate. We treat that as a feature : ranking by p_e2e gives bias-toward-evidence.
Storage shape:
endpoint_posteriors (
url_hash TEXT,
stage TEXT, -- 'challenge'|'invoice'|'payment'|'delivery'|'quality'
alpha DOUBLE PRECISION DEFAULT 1.0,
beta DOUBLE PRECISION DEFAULT 1.0,
n_obs INTEGER DEFAULT 0,
PRIMARY KEY (url_hash, stage)
)
The update is one UPSERT per stage per observation, all wrapped in a transaction with the parent endpoint_observations row. n_obs reported back to consumers strips the prior : n = round(α + β − 2).
7. Wilson interval (CI95)
Each per-stage posterior ships a 95% credibility interval. SatRank uses Wilson's closed-form approximation rather than the inverse-Beta CDF — no special functions, no GSL dependency, sub-microsecond evaluation:
center = (p̂ + z²/2n) / (1 + z²/n)
margin = z · √(p̂(1−p̂)/n + z²/4n²) / (1 + z²/n)
CI95 = [center − margin, center + margin] ∩ [0, 1]
Wilson is preferred over the normal-approximation Wald interval because it stays inside [0, 1] for boundary cases (p̂ = 0 or p̂ = 1) without ad-hoc clamping. For α + β < 2 the function returns [0, 1] — total ignorance.
8. Meaningfulness
Every ranked candidate carries an is_meaningful flag. It is true iff the challenge stage has at least MEANINGFUL_N_OBS_MIN observations (default 3).
The challenge stage is the only one observed on every probe (free or paid), so its n_obs converges fastest. Earlier versions of this rule required all five stages to clear the threshold ; that meant endpoints with broken delivery (very common in the wild) stayed marked unmeaningful even when SatRank had reliable evidence about their L402 surface. The current rule treats is_meaningful as "we have enough challenge-stage evidence to say something about this endpoint". p_e2e remains the honest end-to-end product, separately.
Agents are advised to skip candidates with is_meaningful=false — their score is dominated by the prior, not data.
9. Ranking
Input shape (Zod-validated):
{
category: string, // required
budget_sats?: number, // filter price_sats ≤ budget
max_latency_ms?: number, // filter median_latency ≤ max
optimize?: 'p_success' | 'latency' | 'cost', // default 'p_success'
limit?: number // default 10, cap 50
}
Filter
- SQL pre-filter :
$1 = ANY(category_tags) AND price_sats ≤ budget_sats, GIN-indexed, hardLIMIT 200. - Score each match via
scoreEndpoint(url_hash)— loads all 5 posteriors + median latency. - Latency post-filter : drop candidates with
median_latency_ms > max_latency_ms(NULL latency passes through ; an endpoint never probed for latency is not penalized for it).
Sort
| optimize= | Sort key |
|---|---|
| p_success | p_e2e descending (default) |
| latency | median_latency_ms ascending (NULL → +∞, i.e. last) |
| cost | price_sats ascending |
No composite score, no weighted blend. Agents pick one objective ; SatRank doesn't editorialize.
Median latency
Computed from the last 50 observations with latency_ms > 0, sorted, middle value. A 50-sample median is cheap (single SQL query) and stable enough at the cadence the ranking endpoint serves.
10. HTTP API
Nine functional routes. Express on a single port (3000), proxied through nginx (443) with TLS 1.2/1.3. trust proxy = 1 so rate-limit keys honor X-Forwarded-For. (The four doc surfaces — /, /methodology, /api, /openapi.json — are listed separately; see /api.)
| Route | Auth | Rate |
|---|---|---|
| GET /health | none | 120/min |
| GET /.well-known/satrank-key | none | 120/min |
| GET /api/services/categories | none | 120/min |
| GET /api/services/best | none, 5-min cache | 120/min |
| GET /api/services/:url_hash | none | 120/min |
| GET /api/oracle/budget | none | 120/min |
| GET /api/deposit/:macaroon_id | none | 120/min |
| POST /api/deposit | none (returns BOLT11 to settle) | 120/min |
| POST /api/intent | L402 paid 2 sats | 30/min + 120/min |
Body cap : 64 KB at Express, 16 KB at nginx (defense-in-depth). Security headers : HSTS (max-age 1y), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Permissions-Policy: geolocation=() microphone=() camera=().
11. L402 paid gate (single-use)
The native L402 middleware sits in front of POST /api/intent. No Authorization header → 402 challenge :
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="<base64url>", invoice="lnbc20n1p..."
X-L402-Hint: multi-use credit available at POST /api/deposit
The macaroon is a HMAC-signed token of shape base64url(payment_hash:expires_at.hmac_sha256_hex), secret = L402_MACAROON_SECRET (server-side, hex 64). Verification is constant-time via crypto.timingSafeEqual — leaking byte-by-byte timing on signature compare is the classic mistake we explicitly avoid.
The agent pays the BOLT11 with their wallet, then replays the request with:
Authorization: L402 <macaroon>:<preimage_hex>
The gate verifies (in order):
- Macaroon HMAC matches.
- TTL not expired (10-min default).
sha256(preimage) === payment_hash.- Single-use : the
(payment_hash)hasn't been settled yet inrevenue_log. INSERT-on-success makes replay impossible.
Successful settle writes one row to revenue_log. Live revenue at /api/oracle/budget.
12. Deposit credits (multi-use)
For agents that will make many /api/intent calls in a window, the deposit primitive trades one Lightning round-trip per call for one round-trip per N calls. The L402 preimage doubles as a bearer secret for the macaroon's TTL.
# 1. Mint a 100-sat deposit (≈ 50 calls at 2 sats each). Free to call.
curl -s -X POST https://satrank.dev/api/deposit \
-H 'Content-Type: application/json' \
-d '{"sats":100}'
# → {macaroon: "deposit_<id>", invoice: "lnbc...", payment_hash: "..."}
# 2. Pay the BOLT11 with any LN wallet.
# 3. The preimage your wallet returns becomes the bearer.
curl -s -X POST https://satrank.dev/api/intent \
-H 'Authorization: L402 deposit_<id>:<preimage_hex>' \
-H 'Content-Type: application/json' \
-d '{"category":"data"}'
# 4. Check remaining balance any time.
curl -s https://satrank.dev/api/deposit/<id>
Atomicity
Concurrent /api/intent calls against the same deposit are linearized by a single-statement UPDATE … RETURNING:
UPDATE agent_credits
SET sats_remaining = sats_remaining - $price,
activated_at = COALESCE(activated_at, $now)
WHERE macaroon_id = $id
AND payment_hash = $sha256_preimage
AND expires_at > $now
AND sats_remaining >= $price
RETURNING sats_remaining;
Returns 0 rows iff: macaroon unknown / wrong preimage / expired / insufficient balance. The single statement is the entire transaction — no race window between SELECT and UPDATE.
TTL is 30 days from issued_at, regardless of activation. Latency on a cache-warm path measures 234–277 ms end-to-end (vs 3–7 s for a single-shot call that round-trips Lightning).
13. Nostr trust assertions (kind 30782)
For each scored endpoint, SatRank publishes a signed Nostr event of kind 30782 (NIP-33 addressable replaceable). The d-tag is url_hash, so each endpoint has at most one current assertion at any time across all relays.
{
"kind": 30782,
"created_at": <unix>,
"tags": [
["d", "<url_hash>"],
["url", "<url>"],
["category", "<primary_tag>"],
["p_e2e", "0.812345"],
["n_obs", "12"],
["valid_until", "<unix>"]
],
"content": "<JSON: {url, url_hash, p_e2e, stages:{...}, n_obs, median_latency_ms, valid_until}>",
"sig": "<Schnorr signature>"
}
Relays : wss://relay.damus.io, wss://nos.lol, wss://nostr.mom by default (configurable via NOSTR_RELAYS). Each publish() call connects with an 8-second timeout per relay so a slow relay can't block the cron.
Offline verification
Any agent can verify an assertion without contacting SatRank — Schnorr verification is purely local. The MCP tool verify_assertion does exactly this : checks the signature, the id matches the canonical event hash, and the pubkey matches the oracle published at /.well-known/satrank-key.
14. MCP surface
Three tools, ships verbatim to npm as satrank-mcp. Stdio transport, no HTTP server in the package itself — the MCP wrapper proxies to SATRANK_API_BASE (default https://satrank.dev).
| Tool | What | Cost |
|---|---|---|
| intent | Forwards POST /api/intent | 2 sats |
| get_endpoint_score | Forwards GET /api/services/:url_hash | 0 |
| verify_assertion | Local Schnorr verify of a kind 30782 event | 0 |
Install : claude mcp add satrank -- npx -y satrank-mcp
15. Security hardening
The hardened V3 surface (post-audit 2026-05-09, commit f22235b) :
- SSRF block on every outbound URL (catalog ingest + probe). Private IP / link-local / IPv6 ULA / multicast all rejected.
- L402 single-use via
revenue_log+ 600-second macaroon TTL. Preimage replay impossible. - Timing-safe HMAC compare (
crypto.timingSafeEqual) on macaroon verification. - Rate-limit 120/min global + 30/min on
/api/intent, both keyed onX-Forwarded-For. - Body cap 64 KB at Express, 16 KB at nginx.
- Probe body cap 256 KB — streams stop reading past that, protects against malicious GB-streaming endpoints.
- Security headers set at nginx : HSTS, X-Content-Type-Options, X-Frame-Options DENY, Referrer-Policy no-referrer, Permissions-Policy denying geolocation/microphone/camera.
- 5-minute cache on
/api/services/best(DB-heavy endpoint). - Schema CHECK on
http_methodat the table level — no SQL-injection pivot can write a non-allowlisted method. - Read-only filesystem in container,
cap_drop: ALL,no-new-privileges, non-root user (uid 1001).
16. Known limitations
- Catalog size. ~18 endpoints across 5 categories at the time of writing. The crawler discovers what l402.directory + RSS + DNS surface ; it does not synthesize endpoints.
- Prior dominates with low n_obs. An endpoint with n_obs < 3 carries a posterior close to the Beta(1,1) prior.
is_meaningfulflags this honestly. Resolves with cadence × time. - Latency-only ranking degenerate on free endpoints. Most catalog endpoints today are 0-sat ;
optimize=costdoesn't discriminate between them. Add a budget filter or useoptimize=p_success. - Single-oracle topology. No federation, no peer cross-attestation. The accuracy claim rests on the oracle's own probe history. For falsifiability across operators, expect a future spec.
- Lightning-only. No x402, no Base, no EVM. By design.
- Body quality is heuristic. Stage 5 (
quality_ok) does light validation — content-type matches, JSON parses, body non-empty. Schema-strict validation is endpoint-specific and out of scope.