Design
Overview
Cardano MPFS Browser is the trusted interface between the user and the MPFS protocol. It is the boundary where the digital world (cryptographic proofs, on-chain state, Merkle trees) meets the non-digital world (a human making decisions).
Everything below this layer is verifiable: proofs are mathematical, chain state is consensus, the MPFS service is just a data pipe. But none of that matters if the interface that translates digital facts into human-readable information is wrong. If this code misrepresents a transaction, the user signs something they didn't intend. If it misrenders a fact, the user acts on false data.
This is the trust boundary. This is Web3: not "trustless" in the sense that trust disappears, but that trust is relocated — from opaque servers to auditable client code that verifies proofs before presenting anything to the user.
The MPFS Application Pattern
Any application built on MPFS — whether a browser, a CLI, or an automated agent — must follow the same pattern:
- Verify — check proofs for every piece of data received from the untrusted service
- Interpret — decode raw bytes into domain meaning using a verified schema
- Decide — make a trust decision (sign or reject a transaction)
The difference between applications is only in step 3:
- A browser presents verified facts to a human, who decides
- An agent applies programmatic logic to verified facts and decides autonomously
- A CLI does the same as the browser, but in a terminal
The trust architecture is identical. The browser is the reference implementation of this pattern — and the first real MPFS client. Any domain-specific application built on MPFS (a credential verifier, a supply chain tracker, a registry) replicates this same structure, adding domain logic on top of verified facts.
MPFS Client Libraries
The verify–interpret–decide machinery is not application-specific. It belongs in a library that any MPFS application can consume. This repository produces two artifacts:
- The browser app — the SPA, a consumer of the library
- The JS client library — published to npm, usable by any JavaScript/TypeScript application (browser or Node.js)
The library takes the three user inputs (token ID, schema, API URL + institutional root source) and exposes verified facts, decoded transactions, and proof verification. The consumer supplies the "decide" step — a human in the browser, business logic in an agent, a prompt in a CLI.
graph TB
subgraph "MPFS Client Library"
IN["Inputs<br/>token ID, schema,<br/>API URL, root source"]
V["Verify<br/>check proofs"]
I["Interpret<br/>apply schema"]
E["Expose<br/>verified data +<br/>decoded transactions"]
IN --> V --> I --> E
end
subgraph "Consumer (the decide step)"
B["Browser<br/>show to human"]
A["Agent<br/>apply logic"]
C["CLI<br/>print to terminal"]
end
E --> B
E --> A
E --> C
Beyond JavaScript, we are committed to providing the same trust machinery as native libraries for backend and systems integration:
| Library | Language | Target |
|---|---|---|
| JS/npm | PureScript → JS | Browser, Node.js |
| Native (C ABI) | Haskell or Rust | C, C++, Python, Go, any FFI |
The native library exposes the same verify–interpret–decide interface via C-compatible FFI, enabling MPFS applications in any language that can call C functions.
This Application
The browser serves two purposes:
- Fact Explorer — given an MPFS token, query its facts and render them using a verified schema, with full proof verification at every step
- MPFS Client — interact with the cage protocol (insert, delete, update) and sign transactions via a CIP-30 wallet, with every unsigned transaction decoded and displayed in human-readable MPFS semantics before signing
Trust Model
System Context
graph TB
subgraph "Trusted"
User["Human User"]
Browser["MPFS Browser<br/>(this application)"]
Wallet["CIP-30 Wallet<br/>(Nami, Eternl, Lace)"]
end
subgraph "Untrusted"
API["MPFS Off-Chain API<br/>(data pipe)"]
Node["Cardano Node<br/>(behind API)"]
end
subgraph "Trust Anchors"
Oracle["Oracle<br/>(publishes token ID +<br/>schema)"]
Inst["Institutional Party<br/>(publishes UTXO<br/>Merkle root)"]
end
Oracle -->|"token ID + schema"| User
Inst -->|"UTXO root"| Browser
User <-->|"view facts,<br/>approve txs"| Browser
Browser <-->|"query facts,<br/>get proofs,<br/>build txs"| API
Browser <-->|"sign txs"| Wallet
API <--> Node
What the User Needs
To use the application, a user provides exactly three inputs:
- Token ID — published by the oracle (token owner) alongside the schema. The oracle is responsible for making this public.
- MPFS API URL — the address of any MPFS off-chain service. This is untrusted — it is just a data pipe.
- Institutional UTXO Merkle root source — a trusted party (e.g. Cardano Foundation) that publishes the current UTXO Merkle tree root.
Everything else is provable. The MPFS service is obligated to provide proofs for anything it claims — if it lies or withholds data, the proofs won't verify and the user knows immediately.
The Oracle's Responsibility
The oracle (token owner) publishes a configuration document:
{
"tokenId": "21c5...",
"schemas": [
{ "format": "cddl", "document": "..." },
{ "format": "json-schema", "document": "..." }
]
}
- The token ID — identifies the cage on-chain
- The schemas — one or more schema documents with their format, used to decode facts
By publishing the token ID and schemas, the oracle gives users the entry point to independently verify and interpret everything.
The Verification Chain
The application verifies facts through a four-layer chain, where each layer is independently provable:
graph LR
IR["Institutional Root<br/>(trusted anchor)"]
CP["CSMT Proof<br/>(UTxO existence)"]
CU["Cage UTxO<br/>(on-chain state)"]
MP["MPF Proof<br/>(trie inclusion)"]
F["Fact<br/>(key-value)"]
IR -- "root matches?" --> CP
CP -- "UTxO exists?" --> CU
CU -- "trie root" --> MP
MP -- "fact exists?" --> F
| Layer | What it proves | Trust source |
|---|---|---|
| Institutional Root | The UTXO Merkle root is authentic | Published by a known party (e.g. Cardano Foundation) |
| CSMT Proof | The cage UTxO exists in the UTXO set | Verified against institutional root |
| Cage UTxO | The cage's current trie root | Proved to exist on-chain |
| MPF Proof | A fact exists in the cage's trie | Verified against cage's trie root |
No Cardano node is required. The entire chain is verified client-side using cryptographic proofs and a single trusted root.
What the User Trusts
- The oracle's published token ID (explicit, public)
- The institutional root publisher (explicit, auditable)
- The browser (runs the verification code)
- Nothing else — not the MPFS off-chain service, not the API
The MPFS Service Obligation
The off-chain service is untrusted but has a clear contract: for any data it holds that is committed to the Merkle tree, it must provide the corresponding proof. The user can always verify:
- Is this fact actually in the trie? (MPF proof)
- Does this trie root match what's on-chain? (cage UTxO)
- Does this cage UTxO actually exist? (CSMT proof)
- Is the UTXO set root authentic? (institutional root)
If any link breaks, the user sees it. The service cannot selectively lie — it either provides valid proofs or the verification fails visibly.
Schema-Driven Fact Rendering
Self-Describing Facts
MPFS stores facts as raw bytestrings in the trie. The trie is format-agnostic — it stores whatever bytes the oracle inserts. To enable generic rendering, facts follow a convention: the first 32 bytes of the value are the blake2b-256 hash of the schema document used to encode the rest.
value = schema_hash (32 bytes) ++ payload
The schema hash links the fact to a specific schema document published by the oracle. The browser computes the hash of each known schema and matches it against the prefix. Facts with unknown schema hashes are displayed as raw hex.
This is a convention, not enforced on-chain. The schema hash prefix is what makes facts interpretable by generic clients.
Schema Matching
sequenceDiagram
participant O as Oracle
participant B as Browser
participant API as MPFS API
Note over O: Publishes: oracle config<br/>(token ID + schemas)
B->>B: Receive oracle config
B->>B: Hash each schema document
B->>API: GET /tokens/:id/facts/:key
API-->>B: value bytes + MPF proof
B->>B: Verify MPF proof
B->>B: Extract first 32 bytes of value
B->>B: Match against known schema hashes
alt Known schema
B->>B: Decode payload with schema
B->>B: Render structured data
else Unknown schema
B->>B: Render as hex
end
Schema Formats
The schema format is extensible. The format field in the
oracle config determines how the browser decodes the payload
after the 32-byte hash prefix:
| Format | Schema language | Payload encoding |
|---|---|---|
cddl |
CDDL (RFC 8610) | CBOR |
json-schema |
JSON Schema | JSON (UTF-8) |
CDDL schemas are embedded as strings in the oracle config
JSON. The document field is the hash source —
blake2b(document_string) must match the 32-byte prefix in
fact values.
New formats can be added without protocol changes — just a new decoder and a schema document from the oracle.
Key and Value Structure
For a given schema, both the key and the value payload (bytes after the 32-byte hash prefix) must conform to the schema. The schema describes the structure of both.
For example, a CDDL schema for the moog test-run application:
fact-key = {
type: "test-run",
platform: tstr,
repository: {
organization: tstr,
repo: tstr
},
commitId: tstr,
try: uint,
requester: tstr
}
fact-value = {
phase: "pending" / "accepted"
/ "finished" / "rejected",
duration: uint,
? outcome: "success" / "failure" / "unknown",
? url: tstr,
? from: fact-value
}
Multiple Schemas
A token can accumulate multiple schemas over time. The oracle may update the encoding format, add new fact types, or evolve the data structure. Each schema version is a separate document with a distinct hash.
Old facts remain readable because old schemas remain in the oracle config. The browser matches each fact's schema hash against all known schemas. Schemas should never be removed from the oracle config — removing a schema makes older facts unreadable by generic clients.
MPFS Client
Transaction Flow
The client interacts with the cage protocol through the MPFS API. The API builds unsigned transactions; the client decodes them, displays their MPFS semantics in human-readable form, and delegates signing to the user's CIP-30 wallet.
Because the API is untrusted, the client always decodes the unsigned CBOR before requesting a signature — the user sees exactly what they are signing.
sequenceDiagram
participant U as User
participant FE as Frontend
participant API as MPFS API
participant W as CIP-30 Wallet
U->>FE: "Insert fact X=Y into token T"
FE->>API: POST /tx/request/insert {token, key, value, address}
API-->>FE: unsigned tx (CBOR hex)
FE->>FE: Decode CBOR, extract TxIns
loop For each TxIn
FE->>API: GET /utxo/:txin
API-->>FE: TxOut (datum, value, address)
FE->>API: GET /utxo/:txin/proof
API-->>FE: CSMT inclusion proof
FE->>FE: Verify TxIn exists in UTXO set
end
FE->>FE: Parse MPFS semantics from tx body
FE->>FE: Render resolved inputs with proof status
FE->>U: Display verified transaction
Note over FE,U: Inputs (verified on-chain):<br/>• Cage UTxO root=abc... ✓<br/>Operation:<br/>• Insert key "X" = value "Y"<br/>• Fee: 1.5 ADA
U->>FE: "Approve"
FE->>W: api.signTx(unsignedTx)
W-->>FE: signed tx
FE->>API: POST /tx/submit {signed tx}
API-->>FE: TxId
FE->>U: "Submitted: tx abc123..."
Input Resolution and Verification
The unsigned transaction contains TxIns — references to UTxOs being spent. Raw TxIns are opaque hashes. The client resolves each TxIn to its full TxOut content and verifies it exists in the UTXO set via a CSMT proof.
graph LR
subgraph "Unsigned Tx"
TI1["TxIn #1<br/>(hash)"]
TI2["TxIn #2<br/>(hash)"]
end
subgraph "Resolved + Verified"
TO1["TxOut #1<br/>cage UTxO<br/>root: abc...<br/>value: 5 ADA<br/>✓ CSMT proof"]
TO2["TxOut #2<br/>user UTxO<br/>addr: addr1...<br/>value: 10 ADA<br/>✓ CSMT proof"]
end
TI1 -->|"GET /utxo/:txin<br/>+ proof"| TO1
TI2 -->|"GET /utxo/:txin<br/>+ proof"| TO2
This is critical: without resolving and verifying inputs, the user cannot know what the transaction actually spends. The API could claim a TxIn points to one UTxO while the transaction actually consumes another. The CSMT proof makes this impossible — each input is independently proven to exist in the UTXO set.
What the Frontend Displays
From the decoded transaction and resolved inputs, the frontend presents:
| Field | Source | Display |
|---|---|---|
| Inputs | TxIns, resolved via API | Full TxOut content with CSMT proof status |
| Cage UTxO | Input datum | Trie root, owner, config — verified on-chain |
| Operation | Redeemer (Contribute/Modify/Mint) | "Insert", "Delete", "Update", "Boot", "Retract", "End" |
| Token | Asset name in tx outputs | Token identifier |
| Key | Request datum field | Decoded via verified schema |
| Value | Request datum field | Decoded via verified schema |
| Fee | Tx fee field | ADA amount |
| Address | Tx output addresses | Bech32, highlighted if user's |
If the schema is verified, the key and value are rendered in structured form. Otherwise they are shown as hex with a warning that no verified schema is available.
Every input carries a proof indicator. If any input cannot be verified, the UI warns prominently — the user should not sign a transaction with unverified inputs.
Transaction Signing State Machine
stateDiagram-v2
[*] --> SelectOperation: user chooses action
SelectOperation --> BuildingTx: submit parameters
BuildingTx --> TxReceived: API returns unsigned CBOR
TxReceived --> Decoding: parse CBOR
Decoding --> Decoded: extract MPFS semantics
Decoding --> DecodeFailed: malformed or unexpected
Decoded --> ReviewPending: display to user
ReviewPending --> Approved: user approves
ReviewPending --> Rejected: user rejects
Approved --> Signing: CIP-30 signTx
Signing --> Signed: wallet returns signature
Signing --> SignFailed: wallet refused
Signed --> Submitting: POST /tx/submit
Submitting --> Submitted: TxId received
Submitting --> SubmitFailed: submission error
Rejected --> [*]
DecodeFailed --> [*]
SignFailed --> [*]
SubmitFailed --> [*]
Submitted --> [*]
Why the Server Doesn't Matter
The MPFS off-chain service is a convenience layer. The client independently verifies everything:
- Facts — verified via the full proof chain
- Transactions — decoded and displayed before signing
- State — anchored on-chain via cage UTxOs
The server could lie, omit data, or be compromised. The client catches it because every claim requires a cryptographic proof. This is the key value proposition: a trusted client that works with any untrusted server.
CIP-30 Wallet Integration
Connection Flow
sequenceDiagram
participant U as User
participant FE as Frontend
participant W as CIP-30 Wallet
FE->>FE: Detect window.cardano.*
FE->>U: Show available wallets (Nami, Eternl, Lace...)
U->>FE: Select wallet
FE->>W: cardano.nami.enable()
W-->>FE: API handle
FE->>W: api.getUsedAddresses()
W-->>FE: [addr1, addr2, ...]
FE->>FE: Store primary address (hex)
API Surface Used
| CIP-30 Method | Purpose |
|---|---|
cardano.<wallet>.enable() |
Connect to wallet |
api.getUsedAddresses() |
Get user's address for tx building |
api.signTx(tx, partialSign) |
Sign unsigned transaction |
api.getNetworkId() |
Verify correct network (mainnet/testnet) |
The frontend does not use api.submitTx() — submission goes
through the MPFS API which handles chain submission via its node
connection.
State Persistence
The entire application state is serialized to localStorage. If the browser tab closes — accidentally or intentionally — reopening restores the exact same view: same token, same fact, same pending transaction, same wallet connection.
What Lives Where
| Storage | Content | Purpose |
|---|---|---|
| URL hash | Navigation: token, fact key, current page | Bookmarkable, shareable |
| localStorage | Session config: API URL, root source, connected wallet, verified schemas, view state | Survives tab close |
URL Structure
#/token/abc123 → token detail
#/token/abc123/facts → fact list
#/token/abc123/facts/mykey → fact detail + proof
#/token/abc123/tx/insert → build insert tx
Recovery Guarantee
The app serializes its full state to localStorage on every state change. On load, it reads localStorage first, then the URL hash. The result: Ctrl+W → reopen → identical view. No re-entry of API URL, no wallet reconnection prompt, no lost context.
This is critical for the trust boundary role: the user must always be able to see where they are in a verification or signing flow, even after an interruption.
Application Structure
Pages
graph TB
subgraph "Fact Explorer"
TL["Token List<br/>All known MPFS tokens"]
TD["Token Detail<br/>State, root, config"]
FL["Fact List<br/>All facts in token"]
FD["Fact Detail<br/>Decoded value + proof status"]
end
subgraph "MPFS Client"
TX["Build Transaction<br/>Select operation"]
TV["Review Transaction<br/>Decoded MPFS semantics"]
TS["Sign & Submit<br/>CIP-30 wallet"]
end
subgraph "Proof Inspector"
PI["Proof Chain<br/>Full verification status"]
end
TL --> TD --> FL --> FD
TD --> TX --> TV --> TS
FD --> PI
Token List View
Shows all MPFS tokens the API tracks:
- Token ID (asset name, hex + decoded if UTF-8)
- Owner (payment key hash → bech32 if possible)
- Current root (truncated hash)
- Pending requests count
- Phase indicator (process/retract window)
Fact Detail View
For a single fact:
- Key — raw hex + schema-decoded rendering
- Value — raw hex + schema-decoded rendering
- Proof status:
- ✓ MPF proof valid against cage root
- ✓ Cage UTxO exists (CSMT proof valid)
- ✓ CSMT root matches institutional publisher
- Or: ⚠ partial verification (e.g. no institutional root configured)
Proof Inspector
Expandable panel showing the full verification chain:
- Institutional root source and value
- CSMT proof steps (Merkle path)
- Cage UTxO details (TxIn, datum, value)
- MPF proof steps (trie path)
- Final verdict: fully verified / partially verified / unverified
Fact Verification State Machine
Each fact goes through a verification pipeline. The UI reflects the current state with visual indicators:
stateDiagram-v2
[*] --> Fetching: query fact
Fetching --> ProofReceived: API returns bytes + proof
ProofReceived --> MPFValid: MPF proof valid
ProofReceived --> MPFFailed: MPF proof invalid
MPFValid --> CageVerified: cage UTxO exists (CSMT proof)
MPFValid --> CageUnverified: no CSMT proof available
CageVerified --> FullyVerified: root matches institutional
CageVerified --> PartiallyVerified: no institutional root
CageUnverified --> PartiallyVerified
FullyVerified --> [*]
PartiallyVerified --> [*]
MPFFailed --> [*]
| State | UI Indicator | Meaning |
|---|---|---|
| Fetching | Spinner | Awaiting API response |
| MPF Failed | Red | Fact proof invalid — data cannot be trusted |
| Partially Verified | Yellow | Fact is in trie, but chain anchor incomplete |
| Fully Verified | Green | Complete proof chain from fact to institutional root |
Institutional Root Sources
The application needs at least one trusted source for the UTXO Merkle root. This is configurable:
- URL endpoint — the institutional party publishes the current root at a known URL (simplest)
- On-chain reference — the root is published in a datum on-chain (self-referential but removes the URL dependency)
- Multiple sources — cross-reference roots from multiple publishers for higher confidence
The UI shows which root source is active and when it was last updated.
API Dependency
The frontend consumes the MPFS off-chain HTTP API. Current endpoint status:
Available (PR #108)
| Endpoint | Purpose |
|---|---|
GET /status |
Service health and sync status |
GET /tokens |
List all tracked tokens |
GET /tokens/:id |
Token state (owner, root, config) |
GET /tokens/:id/root |
Current trie root |
GET /tokens/:id/facts/:key |
Fact value + MPF proof |
GET /tokens/:id/proofs/:key |
MPF proof only |
GET /tokens/:id/requests |
Pending requests |
POST /tx/boot |
Build boot transaction |
POST /tx/request/insert |
Build insert request tx |
POST /tx/request/delete |
Build delete request tx |
POST /tx/update |
Build update tx |
POST /tx/retract |
Build retract tx |
POST /tx/end |
Build end tx |
POST /tx/submit |
Submit signed tx |
Needed (issue #117)
| Endpoint | Purpose |
|---|---|
GET /utxo/:txin |
Resolve TxIn to full UTxO |
GET /utxo/:txin/proof |
CSMT inclusion proof |
GET /csmt/root |
Current UTXO Merkle root |
Technology Stack
- PureScript with Halogen (component framework)
- Nix flake for reproducible dev environment
- esbuild for bundling (via spago)
- MkDocs with Material theme for documentation
- No backend — static SPA served from GitHub Pages or alongside the MPFS API
Open Questions
- CBOR decoding in PureScript — which library? FFI to a JS CBOR library? How much of the Cardano tx structure do we need to parse?
- Institutional root protocol — is there a standard for publishing UTXO Merkle roots, or do we define one?
- Multi-token view — should the explorer support comparing facts across tokens, or is it strictly per-token?