API reference

Every endpoint, every field, no guessing.

The REST API, the MCP server, and the node-consensus network all call the exact same deterministic core (orai/service.py) — the number never disagrees with itself depending on how you asked for it. Everything below is read-only public-ledger data unless stated otherwise: no API key, no OAuth, no rate limit enforced today (be reasonable anyway).

Overview

Base URL: https://vaultscore.io/api — every path below is relative to that. All request and response bodies are JSON; set Content-Type: application/json on any POST.

Three ways to reach the exact same scoring core:

  • REST API (this page) — for a backend, a script, or curl.
  • MCP server (below) — for an AI agent (Claude Desktop, Claude Code, any MCP-compatible client) to call directly, no HTTP client needed.
  • Client-side JS (web/assets/orai-scoring.js) — a byte-for-byte port of the Layer 1 math for the what-if simulator, checked against the Python original in tests/test_js_parity.py on every commit.
No auth today. Every scoring/tracking endpoint reads public XRPL ledger data and needs no credential. The two exceptions — the cron endpoint and, implicitly, node attestations you don't want spoofed — are called out where relevant.

Quickstart

One request, no setup:

$ curl https://vaultscore.io/api/score/rrrrrrrrrrrrrrrrrrrrrhoLvTp

That's the whole integration for most use cases. Everything past this point is either a different scoring mode (agent, batch, what-if) or an opt-in feature (tracking, multi-source verification, node consensus).

Common response shapes

Both scoring endpoints (/score and /agent) return the same envelope, so client code can share one parser. This is the shape referenced as ScoreResponse throughout this page:

FieldTypeDescription
addressstringThe XRPL address that was scored, echoed back.
scoreint300–850 for a Wallet Score, 0–1000 for an Agent Reliability Index.
scalestringe.g. "300-850", or "300-850 (THIN_FILE)" when there's no repayment history to draw on.
thin_fileboolTrue when the address has no on-chain lending history yet — true for nearly every address today, since XLS-66 isn't live on mainnet. Every other observable signal still counts fully; see Methodology.
reason_codesReasonCode[]The factors that explain the score, ranked by actual weighted impact. See below.
cachedboolTrue if this result came from the Supabase cache rather than a live ledger fetch. Cache TTL is set by ORAI_CACHE_TTL_HOURS (default 12h) server-side.

Each entry in reason_codes is a ReasonCode:

FieldTypeDescription
factorstringe.g. repayment_history, utilization, longevity, credential. See the weight tables on the Methodology page for the full list per score type.
weightfloatThis factor's share of the overall score, 0–1 (e.g. 0.35 for repayment history's 35%). Layer 2 adjustments (cluster cap, funding-source flag) report 0.0 here since they apply on top of, not as part of, the Layer 1 weighting.
subscorefloatHow well this specific factor scored, 0–1, where 1.0 is perfect. score is a weighted sum of every factor's weight × subscore.
directionstring"positive" or "negative" — whether this factor helped or hurt the score. A factor only gets "positive" when its subscore is genuinely near-max (≥0.95); everything else that makes the top-4-by-drag list is "negative". See Methodology for why a flat cutoff was rejected.
detailstringPlain-language explanation, e.g. "14 on-time / 1 late / 0 defaulted".

Concepts & glossary

The terms below recur across every endpoint. Read this once and the per-endpoint docs stop needing to re-explain them.

Wallet Score vs. Agent Reliability Index

Two different models for two different kinds of account, deliberately not one score forced to fit both. Wallet Score (300–850) is for human-controlled or institutional wallets, matched to the familiar FICO range on purpose. Agent Reliability Index (0–1000) is for autonomous agents, on a visually distinct scale so nobody mistakes one for the other — it isn't measuring the same thing (obligation completion and counterparty diversity, not repayment history). Full weight tables for both are on the Methodology page.

Layer 1 vs. Layer 2

Layer 1 is deterministic, fixed-arithmetic scoring anyone can reproduce by hand from public ledger data — this is most of every score's weight, on purpose, and it's what /simulate exposes directly. Layer 2 is pattern detection on top: funding-source classification and wallet-cluster detection (orai/layer2.py). Layer 2 never becomes the majority of a score — it can only apply a capped ceiling and an attributed reason code on top of the Layer 1 result, never replace or reweight it. See Methodology.

THIN_FILE

Appears in scale (e.g. "300-850 (THIN_FILE)") and as the boolean thin_file field. Means the address has zero on-chain repayment history — not a bad score, a neutral one. Right now that's true for essentially every real address on mainnet, because XRPL's native lending protocol (XLS-66) isn't live yet, so nothing has ever been repaid by anyone. A thin-file wallet still gets full credit for every other observable signal — account age, activity depth, credentials — it's only the repayment-history factor itself that's scored neutral (0.5) rather than being allowed to drag the whole result down for a gap in the network, not the wallet. See Methodology.

Reason codes & explainability

Every score ships with the 3–4 factors that actually explain it, ranked by real weighted drag (weight × (1 - subscore)) rather than a flat cutoff — this is the same function as a credit bureau's "adverse action" disclosure, offered up front instead of on request. A factor is only ever labeled "positive" when it's genuinely near-max (subscore ≥ 0.95); everything that makes the top of the drag ranking is "negative", even a factor with a decent-looking raw subscore, because a flat threshold there produced a real bug once (see Methodology).

Node, round, quorum, tolerance band

Only relevant to the decentralized consensus network, not the everyday scoring endpoints. A node is one independent operator, identified by an XRPL keypair. A round (round_id) is one attestation cycle for one address — an arbitrary string the participating nodes agree on out of band. Quorum (default 3) is the minimum number of verified submissions required before a number gets published at all; below it, the network refuses to guess. The tolerance band (default 40 points, ~7% of the Wallet Score's 550-point range) is how far a submission can sit from the preliminary median before it's dropped as an outlier and flagged for review.

Cache & freshness

A score computed once is cached in Supabase for ORAI_CACHE_TTL_HOURS (default 12h, server-configured) so a repeat lookup or a shared link doesn't re-hit a public XRPL node every time — ledger state doesn't change fast enough to need a live recompute on every single hit. Pass ?refresh=true on /score or /agent to force a live recompute regardless of cache age. Tracked addresses get a fresh point automatically every month (or immediately, on real-time monitoring — see Tracking) independent of this TTL.

Wallet Score

GET/score/{address}

Computes (or returns a cached) VaultScore Wallet Score — 300–850 — for a real XRPL mainnet address. Also runs the Layer 2 funding-source check (not cluster detection, which needs a batch — see /score-batch).

ParamTypeDescription
addresspath, stringAny XRPL classic address (starts with r).
refreshquery, boolDefault false. Set true to bypass the cache and recompute from live ledger data — use this after a wallet's on-chain state has changed and you don't want to wait out the cache TTL.
$ curl https://vaultscore.io/api/score/rrrrrrrrrrrrrrrrrrrrrhoLvTp

{
  "address": "rrrrrrrrrrrrrrrrrrrrrhoLvTp",
  "score": 614,
  "scale": "300-850 (THIN_FILE)",
  "thin_file": true,
  "reason_codes": [
    { "factor": "repayment_history", "weight": 0.35, "subscore": 0.5,
      "direction": "negative", "detail": "no repayment history on record (no loans taken, or XLS-66 not yet live)" },
    { "factor": "utilization", "weight": 0.2, "subscore": 0.5,
      "direction": "negative", "detail": "no collateral posted; utilization not applicable" },
    { "factor": "credential", "weight": 0.1, "subscore": 0.3,
      "direction": "negative", "detail": "no Credential attached" },
    { "factor": "inquiry_velocity", "weight": 0.1, "subscore": 1.0,
      "direction": "positive", "detail": "0 loan inquiries in the last 30 days" }
  ],
  "cached": false
}

Errors: 404 if the address has never been funded on mainnet; 502 if the public XRPL node returned an error (safe to retry).

Agent Reliability Index

GET/agent/{address}

Computes the Agent Reliability Index (0–1000) for an XRPL address acting as an autonomous agent — a separate model from the Wallet Score, deliberately on a visually distinct scale so nobody mistakes one for the other (see Methodology). Same ScoreResponse envelope as /score, cached under its own agent:<address> key so a wallet score and an agent score for the same address never collide.

ParamTypeDescription
addresspath, stringAny XRPL classic address.
refreshquery, boolDefault false, same meaning as on /score.
What's actually observed today: Escrow and Payment Channel completion stands in for "committed obligations met on time" — a real on-chain signal. Dispute rate and precise responsiveness have no on-chain primitive yet, so they're left honestly neutral rather than guessed at (see orai/agent_indexer.py).
$ curl https://vaultscore.io/api/agent/rrrrrrrrrrrrrrrrrrrrrhoLvTp

{
  "address": "rrrrrrrrrrrrrrrrrrrrrhoLvTp",
  "score": 471,
  "scale": "0-1000",
  "thin_file": false,
  "reason_codes": [
    { "factor": "completion_rate", "weight": 0.4, "subscore": 0.5,
      "direction": "negative", "detail": "no obligation history on record" },
    { "factor": "counterparty_diversity", "weight": 0.15, "subscore": 0.0,
      "direction": "negative", "detail": "0 distinct counterparties" }
  ],
  "cached": false
}

Errors: same as /score — 404 unfunded, 502 node error.

Batch scoring + cluster detection

POST/score-batch

Scores up to 25 wallet addresses together and runs Layer 2 wallet-cluster detection across the batch — a shared-funding-source, synchronized-timing pattern (the classic sybil-ring setup) is only visible when multiple addresses are compared side by side, which a single /score call can never do. This is the vault-side use case: scoring a set of loan applicants together. Not cached — a batch result depends on which other addresses it was scored alongside, so caching it under one address's key would be misleading.

Request body

FieldTypeDescription
addressesstring[]1–25 XRPL addresses. More than 25 returns 400.
$ curl -X POST https://vaultscore.io/api/score-batch \
    -H "Content-Type: application/json" \
    -d '{"addresses": ["rAddr1", "rAddr2", "rAddr3"]}'

Response

An object keyed by address — not an array, so you can look up any one address's result directly without scanning:

{
  "rAddr1": {
    "address": "rAddr1", "score": 650, "scale": "300-850 (THIN_FILE)", "thin_file": true,
    "reason_codes": [
      { "factor": "layer2_cluster", "weight": 0.0, "subscore": 0.0, "direction": "negative",
        "detail": "flagged as part of a suspected coordinated cluster with 1 other wallet(s) analyzed together, based on shared funding source and synchronized transaction timing -- score capped at 650" }
    , ... ]
  },
  "rAddr2": { ... },
  "rAddr3": { "error": "rAddr3 has never been funded on XRPL mainnet." }
}

An address that failed to score (unfunded, node error) gets {"error": "..."} in its slot instead of a score object — the whole batch still returns 200 so one bad address doesn't fail the others.

How the cluster cap shows up: if two or more addresses in the batch were funded by the same source within a synchronized window (default 1 hour), every member's score is capped at 650 and gets a layer2_cluster reason code explaining why — the cap and the explanation are never separated. See Sybil resistance.

Errors: 400 if addresses has more than 25 entries.

What-if simulator

POST/simulate

Runs the exact Layer 1 scoring math (orai/scoring.py) against arbitrary, hypothetical inputs — no ledger fetch, no address, no cache. This is the canonical Python answer behind the what-if simulator page, which otherwise runs the identical computation client-side via a byte-for-byte JS port for instant feedback with no network round trip (verified identical in tests/test_js_parity.py on every push).

Request body — every field is optional, defaults shown

FieldTypeDefaultDescription
repayments_on_timeint0Lifetime count of on-time repayments.
repayments_lateint0Lifetime count of late repayments.
repayments_defaultedint0Lifetime count of defaults.
most_recent_default_days_agoint | nullnullRecency-weights the default penalty: ≤90 days is the harshest penalty, ≤365 a smaller one, beyond that none.
collateral_postedfloat0.0Total collateral currently posted.
amount_borrowedfloat0.0Amount currently borrowed against that collateral.
account_age_daysint0Wallet age. 730 days (2 years) is full credit for the longevity factor's age component.
active_days_ratiofloat0.00–1: fraction of the account's lifetime with on-chain activity.
distinct_protocols_usedint0Breadth of protocol interaction; 5 is full credit for this component.
escrows_completed / escrows_totalint0Escrow completion rate feeds the activity-depth factor.
payment_channels_completed / payment_channels_totalint0Same idea, for payment channels.
has_credentialboolfalseWhether a Permissioned Domain / KYC Credential is attached.
loan_inquiries_last_30dint0New loan applications in the last 30 days — this factor is scored negatively as the count rises.
$ curl -X POST https://vaultscore.io/api/simulate \
    -H "Content-Type: application/json" \
    -d '{"repayments_on_time": 14, "repayments_late": 1, "account_age_days": 420, "active_days_ratio": 0.72, "collateral_posted": 5000, "amount_borrowed": 1800}'

{
  "score": 731,
  "scale": "300-850",
  "thin_file": false,
  "reason_codes": [ ... same ReasonCode[] shape as /score ]
}

Multi-source verification

GET/verify-sources/{address}

Cross-checks this address's raw account_info across three independent public XRPL full-history nodes (xrplcluster.com, s1.ripple.com, s2.ripple.com) instead of trusting whichever single node the main scoring path happens to be pointed at. Without this, if that one node were stale, compromised, or simply wrong, a score would be wrong with complete internal consistency and no way to tell. Heavier than a normal lookup (three node round trips instead of one), so it's opt-in rather than run on every score — call it before a high-stakes decision, not on every page load.

What counts as disagreement

  • Fewer than 2 of the 3 sources reachable at all → can't reach a verdict.
  • Some sources say the account exists, others say it doesn't → one has stale or wrong data.
  • Sequence numbers among sources that agree the account exists differ by more than 20 → wider than ordinary between-call ledger activity can explain (two calls moments apart can legitimately differ by a little if a transaction lands in between; this catches more than that).
$ curl https://vaultscore.io/api/verify-sources/rrrrrrrrrrrrrrrrrrrrrhoLvTp

{
  "address": "rrrrrrrrrrrrrrrrrrrrrhoLvTp",
  "agreed": true,
  "detail": "3 source(s) agree within tolerance (Sequence spread 0)"
}
FieldTypeDescription
agreedboolWhether the sources are consistent with each other, per the rules above.
detailstringPlain-language explanation of the verdict, including specifics when they disagree (which sources, what the Sequence spread was).

Tracking

Opt any address into monthly re-scoring. No accounts, no API keys — the address is the identity, same as the rest of VaultScore. A tracked address is also what powers real-time monitoring: the moment a tracked wallet appears in a validated on-chain transaction, its score is recomputed immediately and a fresh point lands on the same history/trend chart, instead of waiting for the next monthly check (see orai/monitor.py).

POST/track/{address}

Starts tracking. Safe to call more than once — returns the existing record instead of resetting the anniversary if it's already tracked. The first check happens immediately (using whatever score is computed/cached right now); every subsequent one lands on the same day-of-month, indefinitely.

{
  "tracked": true,
  "first_tracked_at": "2026-09-10T02:00:00Z",
  "next_check_due": "2026-10-10T02:00:00Z",
  "last_checked_at": "2026-09-10T02:00:00Z"
}

Errors: 503 if tracking isn't configured on this deployment (no Supabase connection) or if the write itself fails.

GET/track/{address}

Current tracking status for an address.

FieldTypeDescription
trackedboolFalse (with no other fields) if this address was never tracked, or was tracked and later stopped.
first_tracked_atstring | nullISO 8601 timestamp — the anniversary anchor.
next_check_duestring | nullISO 8601 timestamp of the next scheduled monthly check.
last_checked_atstring | nullISO 8601 timestamp of the most recent check, monthly or event-driven.
GET/track

Every address currently opted into tracking, active or not yet due for its next check:

{ "addresses": [ "rAddr1", "rAddr2" ] }

This is exactly what scripts/monitor_wallets.py subscribes to on rippled's WebSocket feed for real-time rescoring — a tracked address already is the set of addresses worth watching continuously, so this deliberately isn't a separate watchlist concept that could drift out of sync.

DELETE/track/{address}

Opts an address back out of monthly and event-driven tracking. History is kept — this only stops future checks, so a later re-POST to /track/{address} starts a fresh anniversary rather than resurrecting the old one.

{ "status": "stopped", "address": "rAddr1" }
GET/track/{address}/history

Every score snapshot recorded so far — the initial one at signup, plus one per monthly check, plus one per real-time event-driven recheck — in chronological order, ready to plot as a trend line.

{
  "address": "rAddr1",
  "history": [
    { "score": 614, "scale": "300-850 (THIN_FILE)", "thin_file": true, "checked_at": "2026-09-10T02:00:00Z" },
    { "score": 629, "scale": "300-850 (THIN_FILE)", "thin_file": true, "checked_at": "2026-10-10T02:00:00Z" }
  ]
}

Decentralized consensus network

The endpoints above are the centralized Phase 0/1 backend — one trusted server computing scores, per the architecture doc's own phasing. These three endpoints are the actual multi-node network: independent operators each compute a score for the same address from the same public ledger data, sign their submission, and the network reduces those to one published number via a trimmed median — deliberately not stake-weighted (a stake-weighted median lets one well-capitalized node pull the outlier-detection reference point toward itself, inverting the entire protection; see Methodology for the full story of why this changed).

Phase-1 prototype, not production infra: the attestation store is in-memory on the backend (backend/attestations.py) — submissions don't survive a restart or share across instances. Durable storage is real follow-on work, not a design decision. What is real: the signing identity, the exact-field signature, and each node's independent ledger read.

The aggregation algorithm, precisely

  1. Verify every submission's signature (see below). Anything that fails — bad signature, or a node_id that doesn't match the supplied public key — is excluded before the next step even runs.
  2. If fewer than 3 verified submissions remain (the default quorum, DEFAULT_MIN_QUORUM), refuse to publish: quorum_met: false.
  3. Compute a preliminary median across all verified submissions.
  4. Drop any submission more than 40 points (the default tolerance, DEFAULT_TOLERANCE — about 7% of the Wallet Score's 550-point range) from that preliminary median. These are flagged, not silently discarded — see flagged_outliers below.
  5. If fewer than 3 submissions survive tolerance filtering, refuse to publish for the same reason as step 2.
  6. Otherwise, the final published score is the median of the surviving submissions.

Both thresholds are illustrative starting points (see Methodology's open-parameters note), not backtested constants — but they're the real numbers this deployment runs today, not placeholders.

Node identity & signing

A node's identity is a real XRPL-style keypair (xrpl.core.keypairs — the same primitive the rest of VaultScore already depends on, no new crypto scheme introduced). node_id is the classic address derived from the public key, not a free-text label a node could just claim.

  1. Generate an identity once, and persist the seed yourself (env var, secrets manager — never written to disk by this code):
    from xrpl.core import keypairs
    seed = keypairs.generate_seed()
    public_key, private_key = keypairs.derive_keypair(seed)
    node_id = keypairs.derive_classic_address(public_key)
  2. Sign a submission. The exact bytes signed are canonical and pipe-delimited — reproduce this exactly if you're not using orai/node.py directly:
    message = f"VAULTSCORE_SUBMISSION|{node_id}|{address}|{score}|{round_id}".encode("utf-8")
    signature = keypairs.sign(message, private_key)  # hex string
    round_id (a timestamp bucket or an explicit round identifier you coordinate with other operators) is part of the signed message specifically to stop an old, otherwise-valid signature from being replayed into a later round.
  3. Submit it to POST /attest with your node_id, public_key, and signature alongside the plain fields.

Verification (what the backend does before a submission can influence any consensus result) checks two things, both must hold:

  • The claimed node_id is actually derivable from the supplied public_key — otherwise a node could sign honestly with its own key but claim to be a different, better-reputed node's identity.
  • The signature verifies against the exact canonical message for the submitted node_id, address, score, and round_id — changing any one of those four fields after signing invalidates it.

Submitting an attestation

POST/attest

Accepts one signed node attestation for a given (address, round_id). A second submission from the same node_id for the same round replaces the first rather than accumulating duplicates. Signature verification happens at consensus time, not here — an invalid submission is stored as-is rather than silently dropped, which is what lets an operator later distinguish "this node never submitted" from "this node submitted something forged."

FieldTypeDescription
addressstringThe XRPL address this submission scores.
round_idstringWhatever round identifier the participating nodes agreed on out of band.
node_idstringThe submitting node's classic address (derived from its public key).
stakefloatSelf-reported stake. Bonds the node's identity conceptually; does not weight the aggregate (see above). On-chain bonding/slashing is a later phase — see the tokenomics doc.
scoreintThis node's independently computed score for address.
public_keystringHex-encoded public key.
signaturestringHex-encoded signature over the canonical message — see Node identity & signing.
$ curl -X POST https://vaultscore.io/api/attest \
    -H "Content-Type: application/json" \
    -d '{
      "address": "rSomeXRPLAddress", "round_id": "demo-round-1", "node_id": "rNodeIdentity...",
      "stake": 1000, "score": 641, "public_key": "ED...", "signature": "..."
    }'

{ "status": "recorded" }

Reading consensus

GET/consensus/{address}?round_id=...

Returns the current signature-verified consensus for one (address, round_id), plus every individual signed submission that went into it — so anyone can independently re-run the trimmed-median aggregation themselves instead of trusting the published number. A submission that fails signature verification is excluded from contributing_nodes the same way it would be in the test suite, not waved through because it arrived over HTTP.

ParamTypeDescription
addresspath, stringThe address to fetch consensus for.
round_idquery, string, requiredMust match the round_id submissions were recorded under.
$ curl "https://vaultscore.io/api/consensus/rSomeXRPLAddress?round_id=demo-round-1"

{
  "consensus": {
    "quorum_met": true,
    "final_score": 641,
    "contributing_nodes": [ "rNode1...", "rNode2...", "rNode3..." ],
    "flagged_outliers": [],
    "reason": "",
    "address": "rSomeXRPLAddress",
    "round_id": "demo-round-1",
    "submission_count": 3
  },
  "submissions": [
    { "node_id": "rNode1...", "stake": 1000, "score": 638, "public_key": "ED...", "signature": "..." }
    , ...
  ]
}
FieldTypeDescription
quorum_metboolWhether enough verified submissions were present to publish a number at all.
final_scoreint | nullThe trimmed median across verified submissions. Null if quorum wasn't met.
contributing_nodesstring[]node_ids whose submission verified and counted toward final_score.
flagged_outliersstring[]node_ids whose submission verified but fell outside the tolerance band and was excluded from the median.
reasonstringExplanation when quorum isn't met (e.g. too few verified submissions).

Running a node

node_runner/run_node.py in the repo is a full runnable reference node — it generates or loads an identity, fetches the address's ledger data itself (the same shared orai.service code path every other entry point uses), computes a score, signs it, and submits it.

# One node, local demo
export ORAI_NODE_SEED=$(python3 -c "from xrpl.core import keypairs; print(keypairs.generate_seed())")
python run_node.py rSomeXRPLAddress

# A real 3-node quorum against a local backend -- three DIFFERENT identities
uvicorn backend.app:app --reload --port 8000   # terminal 1

export ORAI_NODE_SEED=$(python3 -c "from xrpl.core import keypairs; print(keypairs.generate_seed())")
python node_runner/run_node.py rSomeXRPLAddress --round-id demo-round-1 --submit-url http://localhost:8000
# repeat in two more terminals with two more seeds, then:
curl "http://localhost:8000/api/consensus/rSomeXRPLAddress?round_id=demo-round-1"

What's not yet real, stated plainly: stake is self-reported rather than bonded on-chain (slashing today is a governance-multisig recommendation, not automatic enforcement), and there's no node discovery — operators currently coordinate a shared round_id and backend URL out of band. See node_runner/README.md for the full picture.

MCP server

The scoring core also ships as two MCP tools — any MCP-compatible client (Claude Desktop, Claude Code, another agent framework) can call them directly, no web UI or HTTP client involved. This is what actually delivers "AI utility" as a callable capability, not just AI branding on a website.

score_xrpl_wallet

score_xrpl_wallet(address="rrrrrrrrrrrrrrrrrrrrrhoLvTp")
# -> same ScoreResponse shape as GET /score/{address} (minus "cached")
{ "score": 614, "scale": "300-850 (THIN_FILE)", "thin_file": true, "reason_codes": [...] }

# An address that's never been funded returns an error field, not an exception:
{ "error": "rSomeAddress has never been funded on XRPL mainnet." }

score_xrpl_agent

score_xrpl_agent(address="rrrrrrrrrrrrrrrrrrrrrhoLvTp")
# -> same shape, 0-1000 scale -- see GET /agent/{address} above
{ "score": 471, "scale": "0-1000", "thin_file": false, "reason_codes": [...] }

Run mcp_server/server.py locally over stdio for testing with a local MCP client, or with --http as a standalone remote server. The same file's top-level app is a plain ASGI app, deployable as its own Vercel Service with no extra wrapper — see vercel.json in the repo.

System endpoints

GET/health

Liveness/config check — no auth, safe to poll.

{ "status": "ok", "cache_enabled": true, "tracking_enabled": true }
FieldTypeDescription
cache_enabledboolWhether SUPABASE_URL/SUPABASE_KEY are configured on this deployment.
tracking_enabledboolSame underlying config — tracking and caching share the same Supabase connection.
GET/cron/monthly-check

Not meant to be called directly — Vercel Cron hits this daily per vercel.json's crons entry, checks every tracked address whose next_check_due has passed, and rescoring each one. Documented here for completeness, not as a public integration point.

Protected in production: when the CRON_SECRET env var is set, this endpoint requires Authorization: Bearer <CRON_SECRET> — which Vercel automatically attaches to its own cron-triggered requests — and returns 401 otherwise, so it isn't triggerable by anyone who finds the URL.

Errors & troubleshooting

Errors come back as {"detail": "..."} (the FastAPI default) with a non-2xx status. Two exceptions: /api/health never errors, and an unhandled internal error returns a bare 500 with no JSON body — please report those, they're bugs.

StatusWhere it happensMeaning
400/score-batchMore than 25 addresses in one request.
401/cron/monthly-checkMissing or wrong Authorization header when CRON_SECRET is set.
404/score, /agent, /track (POST)The address has never been funded on XRPL mainnet.
502/score, /agent, /track (POST)The public XRPL node returned an error — safe to retry.
503Any /track endpointTracking isn't configured on this deployment (no Supabase connection), or a write to it failed.

Troubleshooting common surprises

"I called /score twice and got the same score even though I know the wallet changed." You're inside the 12-hour cache window. Call with ?refresh=true to force a live recompute.

"I called /score and /agent for the same address and got different scores." Expected — they're different models on different scales measuring different things (see Concepts), not two attempts at the same number.

"My score dropped after a batch call but not after a solo /score call." Layer 2 cluster detection only runs in /score-batch, because it needs multiple addresses analyzed together to see a shared-funding pattern at all. A solo /score call still runs the funding-source check, just not clustering — see Batch scoring.

"/consensus returned quorum_met: false." Either fewer than 3 verified submissions exist yet for that (address, round_id), or too many were dropped as outliers/unverified to leave 3 standing. Check reason and flagged_outliers in the response — a flagged node either has a bad signature or genuinely disagreed by more than 40 points.

"I signed a submission correctly but /consensus still excludes it." The three most common causes: the round_id you signed doesn't exactly match the one you submitted with (signatures aren't valid across rounds, by design — see Node identity & signing); you re-derived node_id from a different keypair than the one that signed; or the address/score in the signed message doesn't byte-for-byte match what you sent to /attest.

"Tracking says 503 even though the rest of the API works fine." Tracking and caching share one Supabase connection (SUPABASE_URL/SUPABASE_KEY) — if those aren't set on this deployment, or the wallet_tracking/score_history tables haven't been created yet (see supabase/schema.sql), every /track call returns 503 while scoring keeps working normally, since scoring doesn't depend on Supabase at all.

Rate limits & fair use

Nothing is enforced today — no API key, no per-IP throttle, no quota. That's a statement of current fact, not a promise it stays that way forever on a public deployment with finite XRPL-node and Supabase capacity behind it.

  • Prefer /score-batch over N sequential /score calls when you're scoring more than a couple of addresses at once — it's one round trip either way, and it's the only path that gets you cluster detection.
  • Don't poll /score in a tight loop expecting a changed answer inside the 12-hour cache window; use tracking instead if you need to know the moment a wallet's score changes — it's push-shaped (monthly + event-driven), not something you should reimplement by polling.
  • /verify-sources makes three real node round trips per call — reach for it before a high-stakes decision, not on every page load.

If a future rate limit is introduced, it'll show up as a standard 429 with a Retry-After header, not a silent behavior change.

Data & privacy

Every score is computed from data that's already public on the XRPL ledger — nothing here is a secret about you the network didn't already know. What VaultScore itself stores, beyond that public ledger data:

TableWhat's in itWritten byRetention
scored_walletsAddress, score, scale, reason codes, raw derived features, computed-at timestamp.Any /score or /agent call (cache write).Effectively until overwritten by the next cache write past the TTL — there's no separate deletion path for a cache row today, but it holds nothing beyond what a fresh /score call on that address would recompute anyway.
wallet_trackingAddress, first-tracked timestamp, next/last check timestamps, active flag.POST /track/{address}.Kept until you call DELETE /track/{address}, which flips active to false and stops future checks (history is kept — see next row).
score_historyEvery score snapshot for a tracked address, append-only.Each monthly/event-driven recheck.Not deleted by DELETE /track — that only stops future checks. There's no endpoint to purge history today; if you need that, it's a direct database operation, not an API call.
  • No accounts, no login, ever. An XRPL address is the entire identity for every feature, tracking included.
  • No wallet connection, no signature request, nothing submitted on-chain just to get scored. The only on-chain writes VaultScore ever makes are the ones you explicitly ask for elsewhere (e.g. issuing a Credential from a score) — scoring and tracking themselves are pure reads.
  • Row Level Security is on for both Supabase tables: public read (a score is meant to be shareable, same as the ledger data it's derived from), write only via the backend's service-role key, never exposed to any client.

Running it yourself

The whole stack is in the repo and runs locally with no cloud account required for the core scoring path — Supabase is only needed for caching and tracking, both of which degrade cleanly to "off" without it.

# Core scoring engine + full offline test suite
pip install -r requirements.txt
python3 -m pytest tests/ -v

# Backend API
pip install -r backend/requirements.txt
uvicorn backend.app:app --reload --port 8000
# curl http://localhost:8000/api/score/<address>

# Web frontend, pointed at the backend above
python3 -m http.server 8080 --directory web
# open http://localhost:8080 -- set window.VAULTSCORE_API_BASE if the
# API isn't on the same origin under /api

# MCP server
pip install -r mcp_server/requirements.txt
python3 mcp_server/server.py           # stdio, for a local MCP client
python3 mcp_server/server.py --http    # remote HTTP server

Environment variables (backend)

VariableRequired?Effect
SUPABASE_URL, SUPABASE_KEYNoEnables the score cache and every tracking endpoint. Omit both and the API still fully works — caching and tracking just report themselves disabled (see /health) instead of failing.
ORAI_CACHE_TTL_HOURSNo, default 12How long a cached score is served before a fresh ledger fetch happens automatically.
ORAI_ALLOWED_ORIGINSNo, default *Comma-separated CORS allowlist, e.g. https://yourdomain.com,https://www.yourdomain.com. Set this explicitly in production; the wildcard default is meant for local development.
CRON_SECRETNoIf set, required as a bearer token on /cron/monthly-check. Vercel Cron attaches this automatically; set it if you deploy the cron trigger anywhere else.

Deployment architecture (three Vercel Services sharing one domain — frontend, backend, MCP server — plus Supabase) is documented in full in ORAI_hosting_architecture.md at the repo root, including the fallback path (Railway/Render) if a dependency doesn't cooperate with Vercel's Python build image.

Frequently asked questions

Why does almost every address come back THIN_FILE?

Because it's true, not a bug — see THIN_FILE above. XRPL's native lending protocol (XLS-66) isn't live on mainnet, so there is no on-chain repayment history for any wallet to have yet. This is itself a real, load-bearing finding about the current state of the network, not a placeholder result — see scripts/mainnet_analysis.py's own output for more on what it implies.

Do I need an API key or to create an account?

No. Every scoring and tracking endpoint is public and address-keyed. There's genuinely nothing to sign up for.

Can I trust a single /score call the same way I'd trust the consensus network?

They're answering different questions. /score is one backend's own live computation — fast, always available, and exactly reproducible by anyone who runs the same Layer 1 math (which is the point of it being deterministic). The consensus network is what happens when several independently-operated nodes each do that same computation and their results are reconciled — meaningfully more resistant to any single operator (including this one) being wrong, compromised, or lying, but it's an early Phase-1 prototype today (in-memory attestation store, no node discovery, no bonded stake) — don't build something that assumes it has production-grade uptime or a large, independent node set yet.

What's the difference between /score, /score-batch, and /simulate?

/score is one real address, cached, with the funding-source Layer 2 check. /score-batch is up to 25 real addresses scored together specifically so cluster detection has something to compare — use it when you're evaluating a set of applicants, not a single lookup. /simulate takes no address at all — it's pure "what if these numbers were true," for exploring the model or building a UI like the what-if simulator.

Why is my score capped and I don't understand why?

Only /score-batch can cap a score (at 650, via layer2_cluster) — a solo /score call never will, since clustering needs multiple addresses to compare. If you see the cap, the response's reason_codes always includes the specific attribution (which pattern triggered it) — the model never applies a penalty it doesn't also explain in the same response. See Sybil resistance.

Is this investment or credit advice?

No. See the footer on every page, and the disclaimer at the bottom of Methodology: this is a concept prototype under active development, not a whitepaper, a security offering, or investment/credit advice. The exact weights and thresholds throughout are illustrative starting points pending real historical validation, stated as such rather than presented as finished.

Does scoring or tracking ever submit anything on-chain?

No — see Data & privacy. Both are pure reads. The only on-chain write anywhere in the project is the optional Credential-issuance flow (packaging a score into an XLS-70 Credential), which is a separate, explicit action, not something scoring or tracking triggers on your behalf.

Can I self-host the whole thing, including the frontend and MCP server?

Yes — see Running it yourself. Nothing about the API, the scoring core, or the MCP server is tied to this specific deployment.

What license is this under?

The repository doesn't currently include a license file. Treat that as "all rights reserved by default" until one is added, rather than assuming permissive terms — ask before redistributing.

How do I report a bug or an incorrect score?

Since every Layer 1 factor is deterministic and reproducible, the fastest way to report a suspected scoring bug is to include the exact address and the reason_codes you got back — that's already everything needed to recompute the factor by hand against orai/scoring.py and confirm whether it's a real discrepancy.

Why is the Agent Reliability Index's dispute_rate and responsiveness always neutral?

Because there's genuinely no on-chain primitive yet for either — no ledger object records a dispute or reversal on a settled obligation, and precisely matching an EscrowCreate's commitment window to its matching EscrowFinish (rather than just comparing counts) isn't implemented. Both are left at an honest neutral input rather than approximated from data that can't actually support the claim — see orai/agent_indexer.py. This is named explicitly rather than silently smoothed over, consistent with how the Wallet Score handles the same kind of gap for repayment history.

Versioning & stability

The backend is versioned internally as 0.1.0 (FastAPI's app.version). This is a prototype under active development, not a stable v1 with a breaking-change policy yet — field names and response shapes have been stable across every feature added so far (the ScoreResponse envelope hasn't changed shape since the Wallet Score shipped), but that's a track record, not a contractual guarantee. FastAPI's usual auto-generated /docs and /openapi.json are not exposed on this deployment today (every route is declared with its path already including the /api prefix rather than mounted under one, and Vercel's rewrites only forward /api/* to the backend service) — this page is the source of truth, not a generated schema. If you're building something that needs advance notice of breaking changes, say so — it's a reasonable thing to ask for as the project matures.