Routable trust money: bilateral credit, payments over a web of trust, and a self-proving ledger.
SolidPay is a protocol for trust money: value represented as bilateral IOUs between identified agents, and payments routed through chains of pre-granted credit. It revives the design of the original Ripple (Fugger, 2004) — no blockchain, no native token, no global consensus — and adds three properties the 2004 design lacked: every agent is a dereferenceable URI; every state change is an entry in a hash-chained transition log that anyone can re-derive; and (from protocol level 1) every transition carries a digital signature by its actor, making the ledger verifiable without trusting the node that holds it.
This is an Editor's Draft, published for implementation and review. It specifies protocol level 0 (single-node operation) and protocol level 1 (signed transitions, § 9) normatively; the reference implementation implements both. Federation (level 2) is out of scope for this document and tracked in the roadmap. The reference implementation passes the test vectors in Appendix A.
Ryan Fugger's insight was that trust relationships between people are a payment network. If Bob is willing to let Alice owe him up to 1,000 units, and Carol is willing to let Bob owe her up to 500, then Alice can pay Carol 300 through Bob: after the payment, Alice owes Bob 300 and Bob owes Carol 300. No money moved; obligations did. Crucially, nobody along the path had to approve the payment — every hop consumed credit its recipient had already granted. That pre-authorization is the routing permission.
SolidPay specifies that model precisely enough to implement interoperably, then hardens it: identities are URIs rather than rows in one operator's database; the ledger is a hash chain rather than a mutable table; and signatures (level 1) shift the node's role from authority to coordinator — verify, don't trust.
The key words must, must not, should, may, and required in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here — in this document they are rendered in small capitals.
This specification defines three conformance classes:
| Class | Description | Requirements |
|---|---|---|
| Node | Software that holds ledger state and accepts transitions. | All of § 4–8; § 10 if it exposes the HTTP API; § 9 at level 1. |
| Client | Software that submits transitions and reads state on behalf of an agent. | § 4 (identity), § 5.1–5.2 (amounts and currencies), § 10 (API usage). |
| Auditor | Software that verifies a log without holding authority over it. | § 6.1 (canonicalization), § 8 (chain verification); § 9.4 at level 1. |
(creditor, debtor, currency, limit) meaning "the creditor permits the debtor to owe
them up to limit of currency."capacity(x→y) = limit(y→x) − debt(x→y).create-trustline, update-trustline, remove-trustline,
send-payment, settle.Every agent is identified by a URI. This specification defines behaviour for three families:
| Family | Form | Notes |
|---|---|---|
| Node-local | https://<node>/u/<name>#me | Minted by a node at registration. The URI with its fragment removed must dereference to a profile document (§ 4.3). |
| Solid WebID | https://<host>/<pod>/profile/card#me |
[WEBID]. Subject to the canonical-spelling rule below. |
| DID | did:nostr:<hex> and other DID methods |
[DID-CORE]. For did:nostr the 64-char lower-case hex public
key is canonical; the bech32 npub form is display-only and
must not appear in ledger state. |
One agent must have exactly one spelling everywhere in ledger state. An
implementation must apply the following normalization to every agent URI at
every entry point (authentication results and every peer, to,
from, or agent parameter) before comparison or storage:
/profile/card.jsonld#, replace it with
/profile/card# (the WebID document form normalizes to the fragment
form).This rule exists because the failure it prevents was observed in practice: an authentication layer returned the document form while stored trustlines named the fragment form, and the graph silently split — trustlines existed, yet no payment could route. An identity-keyed system fails quietly on spelling drift; normalization is therefore normative, not advisory.
Agent URIs are compared by exact byte equality after normalization. The character
| (U+007C) must not appear in an agent URI
(§ 5.4).
For a node-local agent https://<node>/u/<name>#me, the node
must serve GET /u/<name> as a JSON document whose
@id member equals the agent URI. It should include
name and the node origin. At level 1 it must also carry
the agent's verification key (§ 9.2).
Amounts cross APIs as JSON numbers with at most 6 decimal places, in the open
interval 0 < amount ≤ 10¹². Internally, all arithmetic
must be performed on integer micro-units
(amount × 10⁶) using arbitrary-precision or 64-bit-safe integers. A value that is not a
finite number, is non-positive, exceeds the ceiling, or has more than 6 decimal places
must be rejected with status 400. Implementations
must not perform ledger arithmetic in binary floating point.
A currency code matches [A-Z0-9]{1,12} after upper-casing on input. Currencies are
fully isolated: a trustline or balance in one currency confers no capacity in any other.
Every currency is a URI; the short code is a display/wire alias. Each node
serves a registry at GET /api/currencies mapping codes to
{uri, name, kind, decimals, settlement}; nodes seed it from the repository registry
and operators may extend it. The registry is discovery, not
permission: unknown codes transact freely — but two parties who mean different things by a
code have a dispute the registry exists to prevent. Registered kinds:
| Kind | Settled by | Examples |
|---|---|---|
fiat-iou | out-of-band fiat payment | USD, EUR |
chain | an on-chain transaction on the named network — publicly verifiable | SATS, TBTC4, TBTC3 |
service | delivery of the named service — serving IS the repayment | LLM (inference tokens); provider/model-specific codes minting their
own URIs |
mutual | agreement between the parties | HRS (time
credit) |
Service-settled currencies make agents natural participants: an inference provider extends a consumer credit denominated in tokens; consumption draws it down; serving the tokens is the settlement. An LLM agent needs only a did:nostr key and § 9's signed transitions — no payment rails at all for machine-to-machine credit.
A trustline is keyed by (creditor, debtor, currency) and carries a single
limit in micro-units. Rules:
limit = 0 is valid and freezes new credit without erasing the line.creditor = debtor) is invalid (400).limit below the current debt is permitted; it blocks new credit and leaves
the existing balance intact.debt(debtor→creditor) > 0. The IOU record lives in the balance
(§ 5.4), not the line — removal must not be able to vaporize a debt.For each unordered pair of agents and currency there is exactly one signed
balance. Let [lo, hi] be the two agent URIs sorted by byte order; the balance
records how much lo owes hi (negative when hi owes
lo), keyed by the string lo|hi|currency. A balance of exactly zero
must be deleted rather than stored.
Storing one signed number rather than two mirrored entries makes double-entry desynchronization unrepresentable: the two directions of a pair are the same number, negated, by construction.
Wherever this specification hashes or signs JSON, the bytes are the UTF-8 encoding of the
value's canonical serialization per JCS [RFC8785]: object members sorted
by code-unit order of their (NFC-normalized) names, no insignificant whitespace, canonical number
form, members whose value is undefined omitted. The value space of ledger entries —
objects, arrays, strings, integers within 2⁵³, booleans, null — avoids the
non-integer-number edge cases of full JCS.
canonicalize({ b: 1, a: "x", "é": true })
→ {"a":"x","b":1,"é":true}debt(x→y) = balance(lo,hi,cur) · (x = lo ? +1 : −1) // what x owes y; may be negative
capacity(x→y) = limit(y→x) − debt(x→y) // limit(y→x) = 0 if no line
Negative debt makes owed-to-you credit spendable with no trustline at all: if y owes
x 30, then capacity(x→y) = 30 even when y extends
x nothing. A payment in the reverse direction therefore clears existing debt first
(clearing).
Given (from, to, currency, amount), a conforming node must
select a route satisfying all of:
from = p₀ → p₁ → … → pₙ = to with
n ≤ 8 hops.capacity(pᵢ→pᵢ₊₁) ≥ amount — the full amount; this
level of the protocol has no partial fills and no amount splitting.n is minimal: no shorter path satisfying (2) exists.Among equally short routes the choice is implementation-defined. If no route satisfies the constraints the payment must fail with 404 and must not modify any balance.
Every applied transition appends exactly one log entry (§ 8):
{
"seq": 7, // 1-based, dense, strictly increasing
"prev": "sha256:…", // hash of entry seq−1; null for seq 1
"ts": "2026-07-29T12:02:00.000Z", // RFC 3339 UTC, node clock
"actor": "<agent URI>", // the authenticated agent
"type": "send-payment",
"params": { … }, // per-type, see below; amounts as decimals
"event": { … }, // level 1: the actor's signed intent (§ 9)
"hash": "sha256:…" // § 8.1
}
Validation and state mutation must be atomic with the log append: either the transition applies fully and its entry is appended, or nothing changes. A rejected transition appends nothing.
| Type | params | Preconditions (else status) | Effect |
|---|---|---|---|
create-trustlineupdate-trustline |
{peer, currency, limit} |
valid peer ≠ actor (400); valid currency (400); limit ≥ 0 valid amount or 0
(400) |
Line (actor, peer, currency) set to limit. create when
the key was absent, else update. |
remove-trustline |
{peer, currency} |
line exists (404); debt(peer→actor) ≤ 0 (409) |
Line deleted. The pair balance is untouched. |
send-payment |
{from, to, currency, amount, path}from = actor;
path is the route taken |
valid to ≠ actor (400); valid currency and amount (400); route exists per § 6.3 (404) | For each hop i: debt(pᵢ→pᵢ₊₁) += amount. Atomic across all
hops. |
settle |
{peer, currency, amount} |
valid inputs (400); debt(peer→actor) ≥ amount (409) |
debt(peer→actor) −= amount. Only the creditor — the party whose claim shrinks —
records repayment. |
Error responses are JSON {"error": "<human-readable reason>"} with the status
codes above, plus 401 for unauthenticated writes and 400 for unparseable bodies.
hash = "sha256:" ‖ lowerhex( SHA-256( UTF8( JCS( entry \ {hash, sig} ) ) ) )
The entry's own hash member is excluded from the hashed bytes; every other member —
including the level-1 event — is included. prev is the previous entry's
hash (null for seq 1), so each hash transitively commits
to the entire prior history. The newest hash is the log's tip.
An auditor verifies a log as follows; a log is valid iff every step holds for every entry, in order:
seq values are dense from 1 (each entry's seq = its predecessor's + 1).prev equals the previous entry's hash (null for the
first).hash.Because balances and trustlines are pure functions of the transition sequence, an auditor may additionally replay the log through § 7 and compare the resulting state to the node's reported state; any divergence proves misbehaviour.
Level 0 trusts the node to attribute actor honestly. Level 1 removes that
trust: every applied transition embeds an event — a signed statement of the actor's
intent, in the shape of a nostr event [NIP-01]. The actor signs
intent, not chain position: what they authorize
({to, currency, amount}), never seq/prev/path.
The split of proofs is deliberate:
This is also what makes signing race-free for client-held keys: an intent is self-contained, so there is nothing to re-sign when the chain moves between signing and applying.
A signed transition is a nostr event: standard NIP-01 id
(SHA-256 of the serialization array) and BIP-340 sig
[BIP340] by the actor's x-only secp256k1 key; tags is empty
or exactly one ["nonce", <1–32 hex>] entry — signers should
include a random nonce so that byte-identical intents yield distinct events
(without one, two legitimate identical payments in the same second share an id: the replay guard
refuses the second and an auditor reads the pair as a double-apply);
content = the RFC 8785-canonical intent params; kind per this table:
| Kind | Transition | Intent (content) |
|---|---|---|
8801 | set-trustline (create vs update derived by the node) |
{peer, currency, limit} |
8802 | remove-trustline | {peer, currency} |
8803 | send-payment | {to, currency, amount} |
8804 | settle | {peer, currency, amount} |
Because it is a plain nostr event, any NIP-07 signer signs transitions natively, and a
transition can be carried by nostr relays as-is — the transport for federation (level 2).
Rules a node must enforce on receipt (POST /api/tx):
content parses as a JSON object AND equals its own canonical serialization —
byte-different spellings of one meaning are refused, or replay ids could be sidestepped.created_at within ±120 s of node time (freshness).id already applied is refused with 409 (replay). The freshness window
bounds the id set a node must remember.id recomputes and sig verifies; the actor is
did:nostr:<pubkey>, or the account whose published key matches (§ 9.3).The verified event is embedded in the resulting log entry as event (covered by the
entry hash), and the entry's response is identical to the legacy lanes'. The signature is the
authentication — /api/tx takes no Authorization header.
did:nostr:<hex> agents: the DID is the x-only public key. The agent
holds it; the node never sees it. These agents write only through
/api/tx — a level-1 node must refuse to fabricate entries for
keys it does not hold (the legacy lanes answer 400).pubkey,
alsoKnownAs: ["did:nostr:<pubkey>"], and
"solidpay:keyCustody": "node".An auditor verifying a level-1 entry must check, in addition to the
chain (§ 8.2): the embedded event's kind matches the entry type;
content equals the canonical intent derived from the entry's params (so a
node that alters an entry's params breaks the match); the event pubkey equals the
actor's key (from the DID, or the profile document for custodial accounts); the NIP-01
id recomputes; and the BIP-340 signature verifies. Freshness is not
rechecked — it was enforced at apply time, and history ages. GET /api/log/verify
performs this audit and reports signatures: {signed, unsigned, invalid}; a level-1 log
is valid only when invalid = 0.
Key rotation/revocation and custody export (a password agent claiming their custodial key as
their own did:nostr); multi-key agents; whether settle should carry the
debtor's counter-signature as a receipt. Tracked on GitHub.
A node exposing HTTP must implement the following resources. Writes
require an Authorization header in one of two schemes, and a node
must accept both:
Bearer <token> — a node-issued token for a node-local account
(/api/register, /api/login).Nostr <base64(event)> — a NIP-98 [NIP-98] signed
kind-27235 event authenticating the actor as did:nostr:<pubkey> with no prior
registration. The node must verify: the BIP-340 signature over the NIP-01
event id; u equals the request's absolute URL and method its verb;
created_at within ±60 s; and the payload tag equals
SHA-256 of the exact body bytes whenever a body is present (a body-carrying request
must not verify without a payload tag, or a signed bodyless header could
be replayed onto a write). This is signed authentication; signed transitions
remain level 1 (§ 9).All responses are JSON; errors are {"error": string} with the status codes of
§ 7.2. CORS should be permissive on reads.
| Method & path | Body / query | Success |
|---|---|---|
POST /api/register | {username, password} |
201 {agent, token}; 409 if taken |
POST /api/login | {username, password} |
200 {agent, token}; 401 on mismatch |
GET /api/whoami | — | 200 {agent | null} |
GET /u/<name> | — | 200 profile document (§ 4.3) |
GET /api/graph | — | 200 {trustlines[], balances[], seq, tip} |
GET /api/balances | ?agent=<uri> |
200 {agent, positions[], net{}} |
GET /api/path | ?from&to¤cy&amount |
200 {path[], hops}; 404 no route |
POST /api/tx | a signed transition event (§ 9.2) | as the matching lane below; 401 bad event, 409 replay, 400 non-canonical intent |
POST /api/trustlines | {peer, currency, limit} |
201 created / 200 updated |
POST /api/trustlines/remove | {peer, currency} |
200; 409 outstanding debt |
POST /api/payments | {to, currency, amount} |
200 {payment{…, path[]}, entry}; 404 no route |
POST /api/settle | {peer, currency, amount} |
200 {settled{…, remaining}, entry}; 409 over-settle |
GET /api/log | ?limit=N (≤ 500) |
200 {seq, tip, entries[]} |
GET /api/log/verify | — | 200 {valid, seq, tip} or {valid: false, brokenAt} |
POST /api/payments
Authorization: Bearer v1.eyJhIjoi…
Content-Type: application/json
{"to": "https://n.example/u/carol#me", "currency": "USD", "amount": 300}
HTTP/1.1 200 OK
{
"payment": { "from": "https://n.example/u/alice#me",
"to": "https://n.example/u/carol#me",
"currency": "USD", "amount": 300,
"path": ["…/u/alice#me", "…/u/bob#me", "…/u/carol#me"] },
"entry": { "seq": 3, "prev": "sha256:151bb2…", "type": "send-payment", … }
}limit per line, and nothing else. There is no systemic
leverage to contain — the security model of the network is the sum of individually chosen,
individually capped risks.At level 0 the graph — who trusts whom, for how much, and who owes whom — is publicly readable, which route discovery requires of the node and a testnet tolerates. This is a meaningful disclosure: credit relationships are social relationships. The original RipplePay showed each user only their own lines. A per-agent visibility model (own lines, own balances, routes that touch you) is a level-1 design goal; deployments handling real obligations should not expose the full graph publicly. Usernames are public by construction (they are URIs); agents who need unlinkability should use pseudonymous names or DIDs.
Nodes are sovereign: each holds its own ledger and answers for its own history. Agents are global: an identity is a URI or DID, so the same agent can hold trustlines on many nodes at once. A trustline (and its balance) lives on exactly one node — the node both parties accepted when the line was created. There is no global state and no consensus between nodes; there are only per-node ledgers whose entries are signed by their actors (§ 9) and anchored histories that make lying provable (§ 13.3).
Cross-node value transfer composes from per-node payments through a gateway — an agent with credit relationships on both nodes. For alice@A → carol@B via gateway G:
Net: value crossed nodes; G's books balance across them (+x owed to it on A, −x owed by it on
B). The gateway's signatures are the only coordination — no node-to-node protocol is
required, which is why this level ships already (the repository's
tools/xnode-demo.js runs it against two live nodes). The honest caveat: steps 2 and 3
are not atomic; between them the gateway bears the risk, which is precisely a gateway's
business (and Fugger's original inter-server design assumption).
Removing the gateway's window of risk needs conditional transitions: a
hold reserving capacity on each leg with a TTL, released by a commit
carrying a hashlock preimage (HTLC-shaped), else unwound at expiry. Known dragon, named here so it
is not rediscovered: holds lock capacity, so hold-griefing and liquidity-lockup are real costs —
the reason Interledger moved to packetized amounts. Level 2 will specify hold/commit transitions
as two further signed event kinds; nothing in the level-1 data model changes.
Because state is a pure function of the log (§ 8.2), the only lie available to a node is presenting different histories to different parties. Anchoring makes that refutable. The interface is deliberately pluggable:
anchor(state) → proof // commit to a state externally
verify(proof) → attested time // check a commitment
resolve(histA, histB) → … // which history was committed to, when
The first backend is Blocktrails [BLOCKTRAILS]
(implemented; tools/anchor.js): the anchored state is the string
{"node":"<origin>","seq":n,"tip":"sha256:…"} (literal key order); each anchor
tweaks the trail key by the state hash (chained BIP-341 TapTweak) yielding a fresh P2TR address,
and the mark transaction pays the trail's balance forward to it. The chain of spends is
the anchor history — ordered and timestamped by Bitcoin. One trail may
carry many nodes' tips (a federation trail); the state names the node. A node
must not anchor a tip whose own audit fails. Anchors are served read-only
at GET /api/anchors. Dispute rule: a history whose (seq, tip) was anchored at
Bitcoin-attested time T refutes any competing history presented later that diverges before T —
the node is caught by its own commitment.
This is not consensus, on purpose. The network keeps no global state to agree on; anchoring makes each node's own statements progressively harder to retract. Trust stays bilateral; evidence becomes public.
An implementation is expected to reproduce these values exactly. Agents:
A = https://n.example/u/alice#me, B = …/u/bob#me,
C = …/u/carol#me.
input: { b: 1, a: "x", "é": true }
JCS: {"a":"x","b":1,"é":true}
pairKey(B, A, "USD") = "https://n.example/u/alice#me|https://n.example/u/bob#me|USD"
// lo = alice (byte order), regardless of argument order
toMicro(300) = 300000000
toMicro(0.000001) = 1
toMicro(0.0000001) → invalid (> 6 dp)
// entry 1 — bob extends alice 1000 USD
{ "seq":1, "prev":null, "ts":"2026-07-29T12:00:00.000Z", "actor":B,
"type":"create-trustline", "params":{"peer":A,"currency":"USD","limit":1000} }
hash₁ = sha256:27ae26a0a5abe78b3dca9cba1fdb73bd5df6652f32e5d7dc8396e06a0e84ec86
// entry 2 — carol extends bob 500 USD
{ "seq":2, "prev":hash₁, "ts":"2026-07-29T12:01:00.000Z", "actor":C,
"type":"create-trustline", "params":{"peer":B,"currency":"USD","limit":500} }
hash₂ = sha256:151bb238b8aa204bda446bc6574fd237bbac398c82a4335683a8749bdb408ef5
// entry 3 — alice pays carol 300 USD via bob
{ "seq":3, "prev":hash₂, "ts":"2026-07-29T12:02:00.000Z", "actor":A,
"type":"send-payment",
"params":{"from":A,"to":C,"currency":"USD","amount":300,"path":[A,B,C]} }
hash₃ = sha256:cf5289f9be4010c23ce11838819dab7fa287259d94530183aa77bd1de23786c4
The canonical (hashed) bytes of entry 3, for byte-exact comparison:
{"actor":"https://n.example/u/alice#me","params":{"amount":300,"currency":"USD","from":"https://n.example/u/alice#me","path":["https://n.example/u/alice#me","https://n.example/u/bob#me","https://n.example/u/carol#me"],"to":"https://n.example/u/carol#me"},"prev":"sha256:151bb238b8aa204bda446bc6574fd237bbac398c82a4335683a8749bdb408ef5","seq":3,"ts":"2026-07-29T12:02:00.000Z","type":"send-payment"}
Resulting state: debt(A→B) = debt(B→C) = 300 USD;
capacity(A→B) = 700; capacity(B→C) = 200; a further payment
A→C of 900 finds no route and changes nothing.
Key 0x…03 (the integer 3 as a 32-byte scalar), created_at 1785312000,
BIP-340 auxiliary randomness all-zero (deterministic form — production signers use random aux;
verification is identical):
{ "pubkey": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
"created_at": 1785312000, "kind": 8803, "tags": [],
"content": "{\"amount\":300,\"currency\":\"USD\",\"to\":\"https://n.example/u/carol#me\"}",
"id": "0501ad5807e019a8609dbf18120956e4af64f2b5e9186c7033eb7cda7b49e88a",
"sig": "b0766e770dd4627c00342255a809aba6f33a8ebc8e8b434fab2305b8dd229ab25e52063cccf1274ce9b99c6e52d6bf4c543ce69cb55d13dc7ba93ac55060c12f" }
An implementation must reproduce id exactly, and with the
all-zero aux the signature as well.
blocktrails