Architecture Overview
System Stack
flowchart TD
app["Application<br/>(wiring + lifecycle)"]
http["HTTP Server<br/>(Servant REST API + Swagger UI)<br/>proof-bearing fact provider"]
idx["CageFollower<br/>(ChainSync block processing)<br/>unified transaction per block"]
mpf["MPF Trie<br/>(merkle-patricia-forestry)<br/>Proofs, insertion, deletion"]
verify["cardano-mpfs-verify<br/>CSMT/MPF/read/facts verifiers"]
cageTx["cardano-mpfs-cage-tx<br/>pure cage builders<br/>*WithEval API"]
browser["Downstream browser clients<br/>HTTP + CIP-30"]
submit["Submitter<br/>(LocalTxSubmission)"]
n2c["Node Client<br/>(node-to-client)<br/>ChainSync + eval context + LTxS"]
app --> http
http --> verify
http --> idx
app --> idx --> mpf
browser --> http
browser --> cageTx
cageTx --> verify
http --> submit
submit --> n2c
idx --> n2c
The service connects to a Cardano node via two N2C connections:
- ChainSync — blocks are processed by the
CageFollower, which applies UTxO, cage state, and trie mutations in a single atomic RocksDB transaction per block (see Block Processing). - LocalStateQuery + LocalTxSubmission — LocalTxSubmission sends
signed transactions. LocalStateQuery is used for protocol parameters,
script evaluation, slot conversion, and the trusted interim
GET /eval-contextmetadata. It is not used as a public UTxO fact source.
The public write path is facts-first. The server returns proof-bearing
facts anchored to one indexed UTxO-CSMT snapshot; clients verify them
with cardano-mpfs-verify, run cardano-mpfs-cage-tx locally, sign
with wallet keys, and submit signed CBOR through POST /submit. The
server must not return unsigned transactions for script-bearing cage
operations. The remaining server-built transaction route is the
owner-only /tx/sweep cleanup path.
Read endpoints ship only provable on-chain data plus proofs:
witnessed UTxOs (TxIn + full TxOut + CSMT inclusion proof), the
completeness / MPF-replay proofs binding them to the snapshot's
utxo_root, and genuinely server-only data the client cannot derive
(the snapshot, chain tip, and utxo_root). They must not ship
server-side projections - parsed convenience JSON re-rendering data
already present in a witnessed TxOut. Projections are unverified,
frequently lossy, and tempt verifying clients into trusting
non-provable data. A client reconstructs everything it needs (token
id, token state, request payload) by decoding the inline datum of the
witnessed TxOut; the server's job is to provide that provable
material, not to interpret it.
POST /submit accepts only MPFS operations. Before relaying, the
server runs a cheap scope gate on the decoded tx. A transaction is
admitted when it touches the cage contract surface in any of these ways:
- it mints or burns the cage state-token policy (boot, end);
- it locks an output at the cage state address (boot, update, reject);
- it produces a request output — a
RequestDatum-bearing output sitting at this cage's per-token request validator address (requestAddrFromCfgfor the token named in the datum), so a craftedRequestDatumat any other script address is rejected (request create); - it spends a cage-owned UTxO — the cage state UTxO or a request UTxO at a request validator address. This input-aware clause is what admits the spend-only operations retract and sweep, which refund or reclaim a request UTxO and so leave no cage mint and no cage output.
The mint and output checks are purely structural, but the spend check
needs the chain: the handler resolves the tx's spent inputs against the
indexer's UTxO set (one atomic read) and feeds the resolved TxOuts
into the pure predicate. The gate is conservative — an input the indexer
cannot resolve (its view may lag the chain) is treated as touching the
cage, so a valid operation is never false-rejected; only a transaction
that is definitely non-MPFS (no cage mint, no cage output, every spent
input resolved to a non-cage UTxO) — a plain ADA transfer, say — is
rejected, with a typed 400 "this service only submits MPFS
operations". This is abuse prevention at the gateway, not a new trust
boundary; the on-chain validators remain authoritative.
The 502 era-history failure was fixed by carrying live era history in
GET /eval-context and deriving EpochInfo from it in the reactor.
ScriptContext POSIXTimeRange costs now use the same era clock as the
node instead of a hardcoded clock.
Singleton Dependency Graph
Every major component is a record of functions (no typeclasses). Records are created bottom-up and torn down top-down using bracket patterns.
graph TD
APP["Application<br/>(withApplication)"]
CTX["Context<br/>(facade record)"]
PRV["Provider<br/>(N2C params/eval context)"]
TM["TrieManager<br/>(per-token MPF tries)"]
ST["State<br/>(tokens, requests, checkpoints)"]
READS["Indexer Reads<br/>(atomic proof queries)"]
IDX["Indexer<br/>(ChainSync)"]
SUB["Submitter<br/>(N2C LocalTxSubmission)"]
TXB["TxBuilder<br/>(internal proof envelopes<br/>/tx/sweep + legacy native paths)"]
NODE["Cardano Node<br/>(Unix socket)"]
APP --> CTX
CTX --> PRV
CTX --> TM
CTX --> ST
CTX --> READS
CTX --> IDX
CTX --> SUB
CTX --> TXB
PRV --> NODE
SUB --> NODE
Application Wiring
withApplication creates and wires all components:
graph LR
DB["RocksDB<br/>(14 CFs)"]
DB --> ST["State<br/>(persistent)"]
DB --> TM["TrieManager<br/>(persistent)"]
DB --> CF["CageFollower<br/>(unified txn)"]
DB --> READS["IndexerTx reads<br/>(proof snapshots)"]
N2C1["N2C #1<br/>(ChainSync)"] --> CF
N2C2["N2C #2<br/>(LSQ eval context + LTxS)"] --> PRV["Provider"]
N2C2 --> SUB["Submitter"]
PRV & SUB & ST & TM & READS --> TXB["TxBuilder"]
PRV & SUB & ST & TM & READS & TXB --> CTX["Context"]
All components use real implementations backed by RocksDB and N2C
connections. The CageFollower runs on connection 1 (ChainSync),
processing each block in a single atomic transaction. The proof-bearing
HTTP handlers read through Context.runIndexerTx, composing snapshot,
UTxO, request-set, and MPF reads into a single underlying transaction.
The Provider uses connection 2 only for ledger metadata/evaluation;
Submitter uses it for LocalTxSubmission.
External Dependencies
graph TD
OFFCHAIN["cardano-mpfs-offchain<br/>Service: indexer + fact API"]
API["cardano-mpfs-api<br/>Wire DTOs + Servant API"]
VERIFY["cardano-mpfs-verify<br/>Pure verifiers"]
CAGETX["cardano-mpfs-cage-tx<br/>Pure cage tx builders"]
CAGE["cardano-mpfs-cage<br/>On-chain types + scripts"]
CLIENTS["cardano-node-clients<br/>TxBuild DSL, N2C provider,<br/>fee balancing"]
MTS["haskell-mts<br/>CSMT + MPF libraries"]
ONCHAIN["cardano-mpfs-onchain<br/>Aiken validators + cage lib"]
LEDGER["cardano-ledger<br/>Conway era types"]
OFFCHAIN --> API
VERIFY --> API
CAGETX --> VERIFY
OFFCHAIN --> CAGE
CAGETX --> CAGE
OFFCHAIN --> CLIENTS
CAGETX --> CLIENTS
OFFCHAIN --> MTS
VERIFY --> MTS
OFFCHAIN --> LEDGER
CAGETX --> LEDGER
CAGE -.->|"lives in"| ONCHAIN
style OFFCHAIN fill:#e1f5fe
style CAGE fill:#fff3e0
style CLIENTS fill:#e8f5e9
style MTS fill:#e8f5e9
style ONCHAIN fill:#fff3e0
style LEDGER fill:#f3e5f5
| Color | Meaning |
|---|---|
| Blue | This project |
| Blue-linked packages | Split local packages in this repository |
| Orange | On-chain repo (validators + cage library) |
| Green | lambdasistemi libraries |
| Purple | Cardano ecosystem dependencies |
The current #62 bounded-refund validator cutover uses cage script hash
ad0a8eeeec8b0a5ee9930be5d6ea2e80b285fc2f3e9675a13a392dd5. The old
c0f05a30... exact-refund hash is legacy and should not be used for
new preprod/browser flows.
Module Hierarchy
The repository is split into several packages. The server lives in
cardano-mpfs-offchain; shared wire types live in cardano-mpfs-api;
pure verifiers live in cardano-mpfs-verify; pure cage builders live in
cardano-mpfs-cage-tx; and native clients can consume the re-exporting
cardano-mpfs-client.
The cardano-mpfs-offchain library is organized in layers. Server
modules live under Cardano.MPFS.
Core — domain types and pure logic
| Module | Purpose |
|---|---|
Core.Types |
TokenId, Root, Request, TokenState, CageConfig |
Core.OnChain |
Re-exports from cage + offchain-specific cagePolicyId, cageAddr |
Core.Blueprint |
Re-exports from cage: blueprint loading, validation |
Core.Proof |
Re-exports from cage: proof serialization |
Interfaces — record-of-functions singletons
| Module | Purpose |
|---|---|
Context |
Facade bundling all singletons |
Provider |
protocol params, evaluation, slot conversion, and trusted interim eval context support; LSQ UTxO queries are forbidden on proof/write hot paths |
State |
Tokens, Requests, Checkpoints sub-records |
Trie |
TrieManager — per-token MPF trie access |
TxBuilder |
internal proof-envelope builders and /tx/sweep; browser-facing script-bearing transactions are built client-side from facts |
Indexer |
Chain follower lifecycle (start, stop, getTip) |
Submitter |
submitTx :: Tx ConwayEra -> m SubmitResult |
Application |
withApplication — wiring and lifecycle |
HTTP — REST API
| Module | Purpose |
|---|---|
HTTP.API |
Server-local API wrapper around shared Servant API plus metrics |
HTTP.Types |
Server compatibility re-exports and ledger conversion helpers |
HTTP.Encoding |
Hex newtype for binary-as-hex transport |
HTTP.Server |
WAI application wiring, mkApp |
HTTP.Swagger |
OpenAPI spec generation, Swagger UI |
The shared API and DTO definitions live in cardano-mpfs-api:
| Package module | Purpose |
|---|---|
Cardano.MPFS.API |
Canonical Servant API: GET /eval-context, proof-bearing reads, facts endpoints, /tx/sweep, /submit |
Cardano.MPFS.API.Types |
Status, token, request, read-response, submit, and sweep JSON types |
Cardano.MPFS.API.Types.Facts |
Facts-only response types for boot/request/update/retract/reject/end |
Cardano.MPFS.API.Types.Common |
Shared snapshot, UTxO witness, token id, and eval-context primitives |
Indexer — chain sync and persistence
| Module | Purpose |
|---|---|
Indexer.CageFollower |
Unified rollForward/rollBackward — one block = one transaction |
Indexer.Event |
detectCageEvents — cage tx classification |
Indexer.Follower |
detectCageBlockEvents, applyCageBlockEvents — generic over Monad m |
Indexer.Persistent |
mkTransactionalState (composable) + mkPersistentState (IO) |
Indexer.Columns |
AllColumns + UnifiedColumns GADTs — full DB schema |
Indexer.Codecs |
CBOR serialization for column key-value types |
Indexer.Rollback |
storeRollbackT, rollbackToSlotT — transactional rollback |
Node integration — thin wrappers over cardano-node-clients
| Module | Purpose |
|---|---|
Provider.NodeClient |
N2C-backed Provider (PParams, script eval, slot conversion, queryEvalContext) |
Submitter.N2C |
N2C-backed Submitter (LocalTxSubmission) |
TxBuilder — internal server builders
| Module | Purpose |
|---|---|
TxBuilder.Config |
CageConfig loading |
TxBuilder.Real |
mkRealTxBuilder entry point |
TxBuilder.Real.Boot |
Mint cage token |
TxBuilder.Real.Request |
Submit insert/delete request |
TxBuilder.Real.Update |
Consume requests, update root |
TxBuilder.Real.Retract |
Cancel pending request |
TxBuilder.Real.End |
Burn cage token |
TxBuilder.Real.Internal |
Shared helpers, POSIX-to-slot conversion |
These server builders now return proof envelopes and consume
BundleSnapshot/indexed UTxO facts rather than querying the node for
UTxO state. The public browser path uses facts endpoints plus the
cardano-mpfs-cage-tx package, whose *WithEval builders run after
cardano-mpfs-verify verifies the supplied facts.
Client verification and cage reactors
| Package module | Purpose |
|---|---|
Cardano.MPFS.Client.Verify |
Re-exported verifier facade |
Cardano.MPFS.Client.Verify.Read |
verifyTokenState, verifyTokenFacts, verifyTokenRequests |
Cardano.MPFS.Client.Facts |
verifyBootFacts, verifyRequest*Facts, verifyUpdateFacts, verifyRetractFacts, verifyRejectFacts, verifyEndFacts |
Cardano.MPFS.Client.Verify.Completeness |
CSMT completeness witness checks |
Cardano.MPFS.Client.Verify.MPF |
MPF proof replay |
Cardano.MPFS.Client.Cage.Reactor |
JSON envelope dispatcher for native/wasm cage transactions |
Cardano.MPFS.Client.Cage.Eval |
Decodes GET /eval-context and derives EpochInfo from live era history |
Trie — MPF backends
| Module | Purpose |
|---|---|
Trie.Persistent |
mkUnifiedTrieManager (transactional) + mkPersistentTrieManager (IO with caches) |
Trie.PureManager |
mkPureTrieManager — in-memory TrieManager for tests |
Mock — test doubles
| Module | Purpose |
|---|---|
Mock.Context |
withMockContext — full mock wiring |
Mock.Provider |
In-memory UTxO store |
Mock.State |
mkMockState — IORef-backed state |
Mock.Submitter |
Always-succeeds submitter |
Mock.TxBuilder |
mkMockTxBuilder — placeholder builder |
Utilities
| Module | Purpose |
|---|---|
Trace |
AppTrace structured tracing type, jsonLinesTracer for stderr JSON-lines logging |
Design Principles
- No typeclasses — closed world with explicit records of functions.
- All types from cardano-ledger —
Tx ConwayEra,PParams ConwayEra,Addr,TxIn, etc. - Visible dependency graph — no implicit resolution surprises.
- Trivial testing — swap the record for a mock backend.
- Fact-provider boundary — public script-bearing writes return proof-bearing material, not unsigned transactions.
- No LSQ UTxO hot path — UTxO and request facts come from the indexed CSMT snapshot. LSQ UTxO scans bypass the proof system and are forbidden on production write/read proof paths.
- One verifier, many targets — the same pure verifier and cage builder code runs native, wasm32-wasi, and GHC-JS.
- No orphan instances.
Implementation Phases
graph LR
P0["Phase 0<br/>MPF Library ✓"]
P1["Phase 1<br/>Service Interfaces ✓"]
P2["Phase 2<br/>N2C Client +<br/>Provider ✓"]
P3["Phase 3<br/>Transaction<br/>Builders"]
P4["Phase 4<br/>ChainSync Indexer +<br/>Persistent State"]
P5["Phase 5<br/>HTTP API +<br/>Deployment"]
P6["Phase 6<br/>Fact Provider +<br/>Browser Reactor"]
P0 --> P1 --> P2 --> P3 --> P4 --> P5 --> P6
style P0 fill:#2d6,color:#fff
style P1 fill:#2d6,color:#fff
style P2 fill:#2d6,color:#fff
style P3 fill:#2d6,color:#fff
style P4 fill:#2d6,color:#fff
style P5 fill:#2d6,color:#fff
style P6 fill:#2d6,color:#fff
| Phase | Description | Status |
|---|---|---|
| 0 | MPF library — 16-ary Merkle Patricia Forestry, Blake2b-256 hashing, insertion/deletion/proofs, pure and RocksDB backends | Done |
| 1 | Service interfaces — Provider, Submitter, TxBuilder, State, TrieManager, Context records; mock implementations; on-chain type encodings; CIP-57 blueprint validation; Aiken-compatible proof serialization |
Done |
| 2 | N2C client + Provider — ouroboros-network LocalStateQuery and LocalTxSubmission clients; mkNodeClientProvider for PParams/evaluation/slot conversion; mkN2CSubmitter for transaction submission; E2E tests with cardano-node subprocess |
Done |
| 3 | Transaction builders — real TxBuilder implementations for boot, update, reject, retract, end operations with Plutus script witnesses, proof envelopes, and on-chain datum construction |
Done |
| 4 | ChainSync indexer + persistent state — real ChainSync follower; RocksDB-backed UTxO CSMT, State, and TrieManager; block processing with rollback support | Done |
| 5 | HTTP API + deployment — Servant HTTP layer with Swagger UI, proof-bearing token/trie/request reads, facts endpoints, signed submission, WAI application wiring | Done |
| 6 | Trust-minimized client flow — facts-only script-bearing writes, cardano-mpfs-verify, cardano-mpfs-cage-tx, wasm cage reactor, and trusted interim GET /eval-context for ex-unit evaluation |
Done |