Overview and testing guide for the Proof of Liabilities (PoL) implementation in Nutshell.
This implementation allows wallets to independently verify a Mint's solvency without compromising user privacy. It is based on three main pillars:
- Fixed-Size Merkle Sum Trees: Commits to the token set and total amount simultaneously.
- Epoch Chaining: Daily epochs linked by
previous_epoch_hashto ensure historical immutability. - OpenTimestamps (OTS): Anchoring the final daily hash to the Bitcoin blockchain for tamper-evident timestamps.
During development, a few architectural choices were made to keep the system robust and private:
| Decision | Rationale |
|---|---|
| Dense Tree over Sparse Tree | Keeps the implementation database-agnostic (works with SQL or flat files without heavy KV-store requirements). |
| Fixed Padding (2²⁴ leaves) | Maintains k-anonymity and prevents traffic analysis. Observers can't guess mint size or transaction volume by looking at tree depth. |
| Epoch-based disclosure | Open epochs (today) return PENDING_EPOCH status. Only closed epochs (past days) provide Merkle proofs—protecting real-time transaction patterns. |
- 1 - Open Epoch: During the day, mint/burn events are logged. Verification returns
PENDING_EPOCH. - 2 - Epoch Closed: At midnight UTC, the background task fills up to 2²⁴ sheets, calculates the Merkle root, links to the previous epoch, and sends the hash to OpenTimestamps. After that, it is closed and becomes historical data.
In these examples, I will use Docker to demonstrate
docker compose up -dFirst, get the keyset ID and check the current epoch:
# Get mint info (shows keyset_id)
docker compose exec -T wallet /bin/sh -c "cashu info"
# Version: 0.19.2
# Wallet: test_wallet
# Mints:
# - URL: http://mint:3338
# - Keysets:
# - ID: 009a1f293253e41e unit: sat active: True fee (ppk): 100
# Check PoL roots for current epoch
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/roots/009a1f293253e41e" | python3 -m json.tooldocker compose exec -T wallet /bin/sh -c "cashu invoice 100"
# Balance: 0 sat
# Requesting invoice for 100 sat.
# Pay invoice to mint 100 sat:
# Invoice: lnbc1u1p5ew...
# Checking invoice ... Invoice paid.
# Balance: 100 sat
docker compose exec -T wallet /bin/sh -c "cashu balance"
# Balance: 100 satCheck PoL report (mint tree updated):
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/roots/009a1f293253e41e" | python3 -m json.tool
# {
# "keyset_id": "009a1f293253e41e",
# "epoch_date": "2026-02-19",
# "is_closed": false,
# "mint_root_amount": 100,
# "burn_root_amount": 0,
# ...
# }# Send 30 sats (creates a Cashu token)
docker compose exec -T wallet /bin/sh -c "cashu -y send 30"
# cashuBo2F0gaJhaUg...token...YXVjc2F0
# Balance: 70 sat
# Receive the token (burns old proofs, mints new)
docker compose exec -T wallet /bin/sh -c 'cashu receive "cashuBo2F0ga...token..."'
# Received 29 sat
# Balance: 29 sat (30 - 1 sat fee)Check PoL (both trees updated):
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/roots/009a1f293253e41e" | python3 -m json.tool
# {
# "keyset_id": "009a1f293253e41e",
# "epoch_date": "2026-02-19",
# "is_closed": false,
# "mint_root_amount": 129, # 100 initial + 29 received
# "burn_root_amount": 30, # original 30 sats burned
# "outstanding_balance": 2224,
# ...
# }Get a recent B_ from mint database and verify:
# Get recent B_ (blinded message)
docker compose exec -T mint python -c "
import sqlite3
conn = sqlite3.connect('/app/data/mint/mint.sqlite3')
cur = conn.cursor()
cur.execute('SELECT b_ FROM promises ORDER BY created DESC LIMIT 1')
print(cur.fetchone()[0])
"
# 02d3bb44b07a9411c951bff58cf02fc58b3e2bf0fe37e5c08172ff341c86bc151c
# Verify mint inclusion
docker compose exec -T wallet /bin/sh -c \
"curl -s 'http://mint:3338/v1/pol/verify/mint/009a1f293253e41e/02d3bb44b07a9411c951bff58cf02fc58b3e2bf0fe37e5c08172ff341c86bc151c'" \
| python3 -m json.tool
# {
# "keyset_id": "009a1f293253e41e",
# "B_": "02d3bb44b07a9411c951bff58cf02fc58b3e2bf0fe37e5c08172ff341c86bc151c",
# "status": "PENDING_EPOCH", # <-- Token is in today's open epoch
# "proof": null # <-- It is not available because the season is not closed, so the Merkle Tree is not available.
# }Note: Tokens in the current (open) epoch return PENDING_EPOCH.
Only after midnight UTC close will they have INCLUDED status with Merkle proofs.
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/history/009a1f293253e41e" | python3 -m json.toolResponse shows epoch chain:
{
"keyset_id": "009a1f293253e41e",
"epochs": [
{
"epoch_date": "2026-02-17",
"previous_epoch_hash": null,
"report_hash": "a1b2c3...",
"report_signature": "e4f5a6...",
"cumulative_minted": 5000,
"cumulative_burned": 2000,
"outstanding_balance": 3000,
"ots_confirmed": true
},
{
"epoch_date": "2026-02-18",
"previous_epoch_hash": "a1b2c3...",
"report_hash": "d4e5f6...",
"report_signature": "b7c8d9...",
"cumulative_minted": 8000,
"cumulative_burned": 3500,
"outstanding_balance": 4500,
"ots_confirmed": false
}
],
"chain_valid": true
}# Download .ots file for a closed epoch
curl -o proof.ots http://localhost:3338/v1/pol/ots/009a1f293253e41e/2026-02-18
# Verify with OpenTimestamps CLI
ots verify proof.ots| Endpoint | Description | Returns |
|---|---|---|
GET /v1/pol/roots/{keyset_id} |
Current epoch Merkle roots | PoLRootsResponse |
GET /v1/pol/history/{keyset_id} |
Closed epochs chain | PoLHistoryResponse |
GET /v1/pol/verify/mint/{keyset_id}/{B_} |
Check if B_ was minted |
status, proof |
GET /v1/pol/verify/burn/{keyset_id}/{Y} |
Check if Y was burned |
status, proof |
GET /v1/pol/commitment/{keyset_id}/{B_} |
Get signed commitment for pending token | MintCommitment |
GET /v1/pol/ots/{keyset_id}/{epoch_date} |
Download .ots file |
Binary |
| Status | Meaning |
|---|---|
INCLUDED |
Token is in a closed epoch with verifiable Merkle proof |
PENDING_EPOCH |
Token is in today's open epoch (wait for close) |
NOT_FOUND |
Token not found in the specified epoch |
A wallet can verify the mint hasn't tampered with history:
# Check if chain is valid
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/history/009a1f293253e41e" | python3 -c "
import sys, json
data = json.load(sys.stdin)
print('Chain valid:', data.get('chain_valid'))
"
# Chain valid: True
# Verification rules:
# 1. First epoch must have previous_epoch_hash = null
# 2. Each epoch[n].previous_epoch_hash == epoch[n-1].report_hash
# 3. cumulative_minted[n] >= cumulative_minted[n-1]
# 4. cumulative_burned[n] >= cumulative_burned[n-1]Every EpochReport is signed by the mint. Wallets can verify authenticity:
# Using the wallet CLI
docker compose exec -T wallet cashu pol --verify-signature --epoch 2026-01-15
# Signature: VALID ✓# Using Python
from cashu.core.proof_of_liabilities import EpochReport
report = EpochReport(**api_response)
is_valid = report.verify_report_signature(mint_pubkey)
if not is_valid:
print("ALERT: Report signature verification failed!")Why it matters:
- Prevents man-in-the-middle attacks
- Detects compromised servers serving altered data
- Ensures historical reports are authentic
OTS anchors epoch hashes to the Bitcoin blockchain, providing tamper-evident timestamps.
# Download .ots file for a closed epoch
docker compose exec -T wallet /bin/sh -c \
"curl -s http://mint:3338/v1/pol/ots/009a1f293253e41e/2026-01-13 -o proof.ots"
# Verify with OpenTimestamps CLI
ots verify proof.ots
# Success! Bitcoin block 876543 attests data existed as of 2026-01-14docker compose exec -T wallet /bin/sh -c \
"curl -s 'http://mint:3338/v1/pol/roots/009a1f293253e41e?epoch_date=2026-01-13'" \
| python3 -m json.tool
# {
# "epoch_date": "2026-01-13",
# "is_closed": true,
# "ots_proof": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/...",
# "ots_confirmed": true
# }| Stage | Time | ots_confirmed |
|---|---|---|
| Epoch closes | T+0 | false |
| Calendar aggregates | T+1-3h | false |
| Bitcoin block mined | T+3-6h | true |
The wallet includes built-in commands to verify Merkle proofs locally - without trusting the mint's response blindly. This is the key security feature of PoL.
# Check PoL status
cashu pol --status
# Verify all wallet tokens against mint's PoL
cashu pol --verify
# Check a specific B_ or Y value with local proof verification
cashu pol --check <B_or_Y> --epoch <YYYY-MM-DD>
# Show step-by-step Merkle proof walkthrough
cashu pol --check <B_or_Y> --epoch <YYYY-MM-DD> --step-by-stepdocker compose exec -T wallet cashu pol --statusOutput:
=== Proof of Liabilities Status ===
Mint URL: http://mint:3338
Keyset ID: 009a1f293253e41e
Current Epoch: 2026-02-20
--- Epoch Info ---
Mint Root: N/A
Burn Root: N/A
Outstanding Balance: 2125
Total Minted: N/A
Total Burned: N/A
# This N/A means I haven't made any transactions today.
The key feature is local verification - the wallet recalculates the Merkle path from the leaf to the root and confirms it matches what the mint claims.
# Verify a minted token (B_) with local proof verification
docker compose exec -T wallet cashu pol \
--check 02284f10bd95ac2c96a6cab3b38e0bf8ea8cdc895b1caaefc75a29598a5dce3f4f \
--epoch 2026-01-13Output:
=== Checking PoL Value ===
Value: 02284f10bd95ac2c96a6cab3b38e0bf8...
Epoch: 2026-01-13
--- MINT Tree ---
Status: INCLUDED
Leaf Amount: 1
Root Amount: 2370
Siblings Count: 6
Local Verification: PASSED
Details: Proof verified: leaf=02284f10bd95ac2c..., amount=1, root_amount=2370
What happens during local verification:
- Wallet receives proof with
leaf_value,leaf_amount, andsiblingsfrom API verify_merkle_proof_locally()reconstructs theMerkleSumProofobject- Calls
proof.verify()which recalculates the path hash-by-hash - Compares computed root with the claimed
root_hash - Returns
PASSEDonly if they match exactly
To see exactly how the Merkle path is computed:
docker compose exec -T wallet cashu pol \
--check 02284f10bd95ac2c96a6cab3b38e0bf8ea8cdc895b1caaefc75a29598a5dce3f4f \
--epoch 2026-01-13 \
--step-by-stepOutput:
=== Checking PoL Value ===
Value: 02284f10bd95ac2c96a6cab3b38e0bf8...
Epoch: 2026-01-13
--- MINT Tree ---
Status: INCLUDED
Leaf Amount: 1
Root Amount: 2370
Siblings Count: 6
Local Verification: PASSED
Details: Proof verified: leaf=02284f10bd95ac2c..., amount=1, root_amount=2370
--- Step-by-Step Verification ---
Step 0: hash=60586560a5cb36f2... amount=1 Leaf hash
Step 1: hash=0fffdc4789796657... amount=17 Combined with sibling
Step 2: hash=67023486695c95e2... amount=49
Step 3: hash=3ff068b58a81a5d1... amount=69
Step 4: hash=6fd641a79f803cab... amount=471
Step 5: hash=88a82ffa494298e7... amount=1289
Step 6: hash=a63712a23f82742f... amount=2370 Root
Final Hash: a63712a23f82742f409582d0c8f98f81f4f816cec4adb0a80ff66064e59d2b4c
Expected Root: a63712a23f82742f409582d0c8f98f81f4f816cec4adb0a80ff66064e59d2b4c
Match: True
This shows:
- Step 0: Initial leaf hash (
sha256(B_:amount)) - Steps 1-6: Each step combines current hash with a sibling using
sha256(left:right:combined_amount) - Final Hash: The computed root must match the claimed
Expected Root
Verify all tokens in your wallet against the mint's PoL trees:
docker compose exec -T wallet cashu pol --verifyOutput:
=== Full PoL Verification ===
Keyset: 009a1f293253e41e
Verifying wallet tokens against mint's PoL report...
Epoch: 2026-02-20
Checked Epoch: 2026-02-20
--- Mint Verification ---
Verified: 5
Pending: 2
Missing: 0
Invalid Proofs: 0
--- Burn Verification ---
Verified: 3
Pending: 0
Missing: 0
--- Balance Check ---
Wallet Balance: 70
Reported Outstanding: 2125
--- Overall ---
Is Valid: YES
Local Verification: ENABLED
--- Verified Mints (B_) ---
02284f10bd95ac2c96a6... | epoch=2026-01-13 | local=YES
0323721d4a00927721be... | epoch=2026-01-13 | local=YES
... and 3 more
Flags explained:
Verified: Tokens with valid Merkle proofs (locally verified)Pending: Tokens in today's open epoch (not yet provable)Missing: Tokens that should be in the tree but aren't (ALERT!)Invalid Proofs: Tokens where local verification failed (CRITICAL ALERT!)local=YES: The proof was verified by recalculating the Merkle path locally
| Scenario | Meaning | Action |
|---|---|---|
Verified + local=YES |
Token provably in tree | Safe |
Pending |
Token in open epoch | Wait for epoch close |
Missing |
Token not found in claimed epoch | ALERT: Possible fraud |
Invalid Proofs |
Merkle proof doesn't verify | CRITICAL: Mint lying |
If you see Missing or Invalid Proofs, the mint may be:
- Omitting tokens from the tree (hidden liabilities)
- Providing fake Merkle proofs (falsified audit)
For PoL to work correctly, wallets must reject tokens from inactive keysets. This ensures that all tokens in circulation can be verified against the current PoL reports.
When you receive a token from an inactive keyset, the wallet will raise an exception:
PoL: Rejecting token from inactive keyset 00deadbeef123456.
Consider using only active keysets for PoL guarantees.
This enforcement is critical because:
- Inactive keysets may not have valid PoL reports
- Old keysets could be used for "hidden liability" attacks
- It ensures the mint can only issue tokens on auditable keysets
The implementation is optimized for production use:
- Tree caching: Built trees are cached per epoch
- Async building: Large trees (16M nodes) don't block the event loop
- Fast token lookup: O(1) via direct DB query on creation timestamp
# .env
POL_ENABLED=true
POL_EPOCH_CLOSE_INTERVAL=3600 # Check for epochs to close (hourly)
POL_EPOCH_LOOKBACK_DAYS=30 # Auto-close epochs up to 30 days back
POL_OTS_UPGRADE_INTERVAL=3600 # Try OTS upgrade (hourly)
POL_RETENTION_MONTHS=24 # Months to retain epochs (0 = disable pruning)
POL_TREE_SIZE_EXPONENT=24 # Tree size = 2^n leaves (24 = ~16M)
POL_PRUNE_INTERVAL=86400 # Check for expired epochs (daily)| Variable | Default | Description |
|---|---|---|
POL_ENABLED |
true |
Enable PoL features |
POL_EPOCH_CLOSE_INTERVAL |
3600 |
Seconds between epoch close checks |
POL_EPOCH_LOOKBACK_DAYS |
30 |
Days to look back for unclosed epochs |
POL_OTS_UPGRADE_INTERVAL |
3600 |
Seconds between OTS upgrade attempts |
POL_RETENTION_MONTHS |
24 |
Months before epochs expire (0 = never) |
POL_TREE_SIZE_EXPONENT |
24 |
Tree size exponent (2^n leaves) |
POL_PRUNE_INTERVAL |
86400 |
Seconds between prune checks |