SolidPay Protocol 0.0.1

Routable trust money: bilateral credit, payments over a web of trust, and a self-proving ledger.

Status
Editor's Draft
This version
https://jss.live/solidpay/docs/spec/
Issue tracking
GitHub Issues
Editor
Melvin Carvalho
Reference code
JavaScriptSolidServer/solidpay (MIT)
Modified
29 July 2026

Abstract§

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.

Status of This Document§

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.

1.Introduction§

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.

1.1Design goals§

2.Conformance§

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:

ClassDescriptionRequirements
NodeSoftware that holds ledger state and accepts transitions.All of § 4–8; § 10 if it exposes the HTTP API; § 9 at level 1.
ClientSoftware that submits transitions and reads state on behalf of an agent.§ 4 (identity), § 5.1–5.2 (amounts and currencies), § 10 (API usage).
AuditorSoftware that verifies a log without holding authority over it.§ 6.1 (canonicalization), § 8 (chain verification); § 9.4 at level 1.

3.Terminology§

agent
A participant in the network, identified by a URI (§ 4). Agents may be people, organisations, or software.
trustline
A unilateral grant of credit: the tuple (creditor, debtor, currency, limit) meaning "the creditor permits the debtor to owe them up to limit of currency."
balance
The single signed IOU amount between an unordered pair of agents in one currency (§ 5.4).
capacity
The amount that can still flow across a directed hop: capacity(x→y) = limit(y→x) − debt(x→y).
transition
A validated state change: one of create-trustline, update-trustline, remove-trustline, send-payment, settle.
transition log
The append-only, hash-chained sequence of every transition a node has applied (§ 8). Its newest hash is the tip.
protocol level
Level 0: transitions attributed by the node. Level 1: transitions signed by their actor (§ 9).

4.Identity§

4.1Agent URIs§

Every agent is identified by a URI. This specification defines behaviour for three families:

FamilyFormNotes
Node-localhttps://<node>/u/<name>#meMinted by a node at registration. The URI with its fragment removed must dereference to a profile document (§ 4.3).
Solid WebIDhttps://<host>/<pod>/profile/card#me [WEBID]. Subject to the canonical-spelling rule below.
DIDdid: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.

4.2Canonical spelling§

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:

  1. If the URI contains the substring /profile/card.jsonld#, replace it with /profile/card# (the WebID document form normalizes to the fragment form).
  2. Otherwise the URI is unchanged. In particular, DIDs pass through byte-identically.
Note

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

4.3Profile documents§

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

5.Data model§

5.1Amounts§

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.

5.2Currencies§

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:

KindSettled byExamples
fiat-iouout-of-band fiat paymentUSD, EUR
chainan on-chain transaction on the named network — publicly verifiableSATS, TBTC4, TBTC3
servicedelivery of the named service — serving IS the repaymentLLM (inference tokens); provider/model-specific codes minting their own URIs
mutualagreement between the partiesHRS (time credit)
Note — the machine economy

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.

5.3Trustlines§

A trustline is keyed by (creditor, debtor, currency) and carries a single limit in micro-units. Rules:

5.4Balances§

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.

Note

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.

6.Algorithms§

6.1Canonical JSON§

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.

Example — key ordering and NFC
canonicalize({ b: 1, a: "x", "é": true })
→ {"a":"x","b":1,"é":true}

6.2Debt and capacity§

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

6.3Route discovery§

Given (from, to, currency, amount), a conforming node must select a route satisfying all of:

  1. The route is a simple path from = p₀ → p₁ → … → pₙ = to with n ≤ 8 hops.
  2. Every hop satisfies capacity(pᵢ→pᵢ₊₁) ≥ amount — the full amount; this level of the protocol has no partial fills and no amount splitting.
  3. 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.

7.Transitions§

7.1Common structure§

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.

7.2Types, parameters, and errors§

TypeparamsPreconditions (else status)Effect
create-trustline
update-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.

8.The transition log§

8.1Entry hash§

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.

8.2Chain verification§

An auditor verifies a log as follows; a log is valid iff every step holds for every entry, in order:

  1. seq values are dense from 1 (each entry's seq = its predecessor's + 1).
  2. prev equals the previous entry's hash (null for the first).
  3. Recomputing § 8.1 over the entry reproduces its stored hash.
  4. (Level 1) the signature verifies per § 9.4.

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.

9.Signed transitions — protocol level 1§

9.1Model§

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.

9.2Wire format — the transition event§

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:

KindTransitionIntent (content)
8801set-trustline
(create vs update derived by the node)
{peer, currency, limit}
8802remove-trustline{peer, currency}
8803send-payment{to, currency, amount}
8804settle{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):

  1. 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.
  2. Intent values arrive pre-normalized (canonical agent spelling per § 4.2, uppercase currency): signed bytes are validated, never rewritten (400 otherwise).
  3. created_at within ±120 s of node time (freshness).
  4. An event id already applied is refused with 409 (replay). The freshness window bounds the id set a node must remember.
  5. 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.

9.3Key binding & custody§

9.4Verification§

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.

Open issues (level 1)

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.

10.HTTP API§

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:

All responses are JSON; errors are {"error": string} with the status codes of § 7.2. CORS should be permissive on reads.

Method & pathBody / querySuccess
POST /api/register{username, password} 201 {agent, token}; 409 if taken
POST /api/login{username, password} 200 {agent, token}; 401 on mismatch
GET /api/whoami200 {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&currency&amount 200 {path[], hops}; 404 no route
POST /api/txa 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}
Example — a routed payment
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", … }
}

11.Security considerations§

12.Privacy considerations§

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.

13.Federation Draft§

13.1Model — sovereign nodes, global agents§

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

13.2The gateway pattern (implemented)§

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:

  1. G holds (or creates, with one signed transition to each node) trust relationships: G→alice on A, carol→G on B.
  2. alice pays G on node A — consuming credit G already granted.
  3. G pays carol on node B — consuming credit carol already granted.

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

13.3Toward atomic routes (level 2, unimplemented)§

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.

13.4Settlement assurance — anchoring (implemented)§

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.

Note

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.

A.Test vectors§

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.

A.1 Canonicalization

input:  { b: 1, a: "x", "é": true }
JCS:    {"a":"x","b":1,"é":true}

A.2 Balance key and micro-units

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)

A.3 A three-entry chain

// 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.

A.4 A signed transition event

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.

B.References§

B.1 Normative

B.2 Informative