Skip to content

Instantly share code, notes, and snippets.

@victorandre957
Last active March 11, 2026 17:03
Show Gist options
  • Select an option

  • Save victorandre957/4f497d385e1fd9a47898480903f56b3e to your computer and use it in GitHub Desktop.

Select an option

Save victorandre957/4f497d385e1fd9a47898480903f56b3e to your computer and use it in GitHub Desktop.
Cashu Proof of Liabilities specification

NUT-X: Proof of Liabilities - Implementation & Usage Guide

Overview and testing guide for the Proof of Liabilities (PoL) implementation in Nutshell.


1. The Concept

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_hash to ensure historical immutability.
  • OpenTimestamps (OTS): Anchoring the final daily hash to the Bitcoin blockchain for tamper-evident timestamps.

2. Key Design Decisions

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.

3. The Epoch Lifecycle

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

4. Testing the Implementation

In these examples, I will use Docker to demonstrate

Prerequisites

docker compose up -d

Step 1: Check Initial PoL State

First, 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.tool

Step 2: Mint Tokens

docker 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 sat

Check 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,
#     ...
# }

Step 3: Send and Receive (Burn + Mint)

# 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,
#     ...
# }

Step 4: Verify a Specific Token (B_)

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.

Step 5: View Epoch History

docker compose exec -T wallet /bin/sh -c \
  "curl -s http://mint:3338/v1/pol/history/009a1f293253e41e" | python3 -m json.tool

Response 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
}

Step 6: Download OTS Proof (Closed Epochs Only)

# 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

5. API Reference

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

Verification Status Values

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

6. Validating Chain Integrity

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]

6.1 Verifying Report Signatures

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

7. OpenTimestamps (OTS)

OTS anchors epoch hashes to the Bitcoin blockchain, providing tamper-evident timestamps.

7.1 How to Download and Verify OTS Proofs

# 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-14

7.2 Checking OTS Status via API

docker 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
# }

7.3 Timeline

Stage Time ots_confirmed
Epoch closes T+0 false
Calendar aggregates T+1-3h false
Bitcoin block mined T+3-6h true

8. Wallet-Side Local Verification (CLI)

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.

8.1 CLI Commands Overview

# 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-step

8.2 Status Check

docker compose exec -T wallet cashu pol --status

Output:

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

8.3 Local Merkle Proof Verification

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-13

Output:

=== 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:

  1. Wallet receives proof with leaf_value, leaf_amount, and siblings from API
  2. verify_merkle_proof_locally() reconstructs the MerkleSumProof object
  3. Calls proof.verify() which recalculates the path hash-by-hash
  4. Compares computed root with the claimed root_hash
  5. Returns PASSED only if they match exactly

8.4 Step-by-Step Verification (Educational/Debug)

To see exactly how the Merkle path is computed:

docker compose exec -T wallet cashu pol \
  --check 02284f10bd95ac2c96a6cab3b38e0bf8ea8cdc895b1caaefc75a29598a5dce3f4f \
  --epoch 2026-01-13 \
  --step-by-step

Output:

=== 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

8.5 Full Wallet Verification

Verify all tokens in your wallet against the mint's PoL trees:

docker compose exec -T wallet cashu pol --verify

Output:

=== 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

8.6 Security Implications

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:

  1. Omitting tokens from the tree (hidden liabilities)
  2. Providing fake Merkle proofs (falsified audit)

8.7 Inactive Keyset Rejection

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

8.8 Performance Notes

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

9. Configuration

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

Configuration Options

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment