Skip to content

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:

  1. Verify — check proofs for every piece of data received from the untrusted service
  2. Interpret — decode raw bytes into domain meaning using a verified schema
  3. 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:

  1. The browser app — the SPA, a consumer of the library
  2. 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:

  1. Fact Explorer — given an MPFS token, query its facts and render them using a verified schema, with full proof verification at every step
  2. 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:

  1. Token ID — published by the oracle (token owner) alongside the schema. The oracle is responsible for making this public.
  2. MPFS API URL — the address of any MPFS off-chain service. This is untrusted — it is just a data pipe.
  3. 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

  1. CBOR decoding in PureScript — which library? FFI to a JS CBOR library? How much of the Cardano tx structure do we need to parse?
  2. Institutional root protocol — is there a standard for publishing UTXO Merkle roots, or do we define one?
  3. Multi-token view — should the explorer support comparing facts across tokens, or is it strictly per-token?