SatRank

Methodology

How SatRank V3 crawls, probes, scores, and ranks L402 endpoints. Every formula and constant below is extracted from github.com/proofoftrust21/satrank. ~1600 LOC, 14 source files, 9 Postgres tables.
v3.0 Beta(α,β) per stage Wilson CI95 Bitcoin-pure

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):

SourceShapeHow
l402.directoryJSONGET /api/services — flattens services × endpoints into one row per (service, endpoint) pair. URLs containing {template} placeholders are skipped.
l402-index RSSRSS feedAggregation 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:

#StageWhat is observed
1challengeHTTP responds 402 with WWW-Authenticate: L402 macaroon=<b64> invoice=<bolt11>. Confirms the endpoint is L402-compliant and reachable.
2invoiceThe BOLT11 returned in the challenge decodes successfully and prices ≤ PROBE_MAX_INVOICE_SATS (default 1000 sats).
3paymentThe probe pays the invoice via local LND (paid probes only). HTLC settles within PROBE_FETCH_TIMEOUT_MS (default 15s).
4deliveryThe retry with the preimage as bearer returns 2xx with a non-empty body within budget.
5qualityThe 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:

success → α += 1  |  failure → β += 1

Stage mean (point estimate of the success probability):

pstage = α / (α + β)

End-to-end success probability — multiplicative, assuming stage independence (true enough since stages are sequential and represent distinct failure modes):

pe2e = ∏s ∈ stages ps

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:

p̂ = α / (α + β) ,   n = α + β ,   z = 1.95996
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

  1. SQL pre-filter : $1 = ANY(category_tags) AND price_sats ≤ budget_sats, GIN-indexed, hard LIMIT 200.
  2. Score each match via scoreEndpoint(url_hash) — loads all 5 posteriors + median latency.
  3. 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_successp_e2e descending (default)
latencymedian_latency_ms ascending (NULL → +∞, i.e. last)
costprice_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.)

RouteAuthRate
GET /healthnone120/min
GET /.well-known/satrank-keynone120/min
GET /api/services/categoriesnone120/min
GET /api/services/bestnone, 5-min cache120/min
GET /api/services/:url_hashnone120/min
GET /api/oracle/budgetnone120/min
GET /api/deposit/:macaroon_idnone120/min
POST /api/depositnone (returns BOLT11 to settle)120/min
POST /api/intentL402 paid 2 sats30/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):

  1. Macaroon HMAC matches.
  2. TTL not expired (10-min default).
  3. sha256(preimage) === payment_hash.
  4. Single-use : the (payment_hash) hasn't been settled yet in revenue_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).

ToolWhatCost
intentForwards POST /api/intent2 sats
get_endpoint_scoreForwards GET /api/services/:url_hash0
verify_assertionLocal Schnorr verify of a kind 30782 event0

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 on X-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_method at 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_meaningful flags this honestly. Resolves with cadence × time.
  • Latency-only ranking degenerate on free endpoints. Most catalog endpoints today are 0-sat ; optimize=cost doesn't discriminate between them. Add a budget filter or use optimize=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.