Reading a lending protocol's whole loan book straight from Cardano's ledger

# cardano# blockchain# postgres# python
Reading a lending protocol's whole loan book straight from Cardano's ledgerElliot

We wanted one number: how far does ADA have to fall before a real share of Indigo's loans gets...

We wanted one number: how far does ADA have to fall before a real share of Indigo's loans gets liquidated? Indigo is a Cardano protocol where you lock ADA and mint synthetic assets (iUSD, iBTC, iETH…) against it. Each position is a CDP, a collateralized debt position.

The protocol's own API was the obvious place to start. When we started (September 9) it listed only the v3 positions: 50 CDPs holding about 6,300 ADA. The real book was about 3,000 times bigger, and it was sitting in plain sight on chain.

This post shows how we read it from a local cardano-db-sync Postgres database, and how we check that the decode is complete. That check matters more than the decode itself.

1. Find where the positions live

On Cardano, a smart contract's funds are UTxOs locked at a script address. Every CDP is one UTxO at Indigo's v2 CDP validator, and that address's payment credential is ff0b10bff20e4b68b491492e5ba6c8048a704763b0a45ce2995da0be.

We found it by working backwards: take recent transactions that mint iUSD and look at which script output each one creates. Every one of them lands on that credential.

2. Pull the unspent outputs and their inline datums

db-sync ships a utxo_view of unspent outputs. Every CDP carries its state as an inline datum, and db-sync stores those already decoded to JSON in the datum table:

SELECT u.value::numeric          AS lovelace,
       d.value::text             AS datum_json
FROM   utxo_view u
LEFT   JOIN datum d ON d.id = u.inline_datum_id
WHERE  u.payment_cred = decode('ff0b10bff20e4b68b491492e5ba6c8048a704763b0a45ce2995da0be', 'hex');
Enter fullscreen mode Exit fullscreen mode

On 2026-10-06 that returned 314 UTxOs. 311 decode as CDPs; the 3 that don't are reported as undecoded.

3. Decode the datum

The v2 CDP datum is nested Plutus constructors. Stripped down:

con0[ con0[ con0[ bytes owner_pkh ],
            bytes  iasset_name_hex,          -- "iUSD", "iBTC", ...
            con0[ bytes policy, bytes name ], -- collateral; ("", "") = ADA
            int    minted_raw,                -- the debt, 6 decimals
            con0[ int updated_ms, int interest_acc ] ] ]
Enter fullscreen mode Exit fullscreen mode

In Python:

import json

def decode_cdp(lovelace, datum_json):
    f = json.loads(datum_json)["fields"][0]["fields"]
    asset = bytes.fromhex(f[1]["bytes"]).decode()
    if not asset.startswith("i"):
        raise ValueError("not a CDP")
    return {
        "asset": asset,
        "owner": f[0]["fields"][0]["bytes"],
        "collateral_ada": int(lovelace) / 1e6,
        "collateral_is_ada": f[2]["fields"][0]["bytes"] == "",
        "debt": int(f[3]["int"]) / 1e6,
    }
Enter fullscreen mode Exit fullscreen mode

Anything that doesn't fit the shape gets counted, not dropped. A decoder that quietly skips rows it doesn't understand will tell you the book is smaller than it is, and it will look fine while doing it.

4. Prove it's complete

Every iAsset is minted under one policy (f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b69880). The total minted minus the total burned is the amount of each iAsset that exists. If our decode has found every position, the decoded debt per asset should equal that number:

SELECT encode(ma.name, 'escape') AS iasset, SUM(mtm.quantity)::numeric AS net_minted
FROM   ma_tx_mint mtm JOIN multi_asset ma ON ma.id = mtm.ident
WHERE  encode(ma.policy, 'hex') = 'f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b69880'
GROUP  BY 1 ORDER BY 1;
Enter fullscreen mode Exit fullscreen mode

Result on 2026-10-06: iADA, iBTC, iETH, iEUR, iJPY and iSOL reconcile to 100.00%. iUSD comes to 95.2%. We have ruled out the redemption script, which holds reference datums rather than CDPs. We still don't know where the rest is, so we publish the gap rather than round it away, and our pipeline refuses to publish if any asset falls below 85%.

5. The traps

  • Liquidation leftovers. 14 UTxOs (on 2026-10-06) hold only the minimum ADA (exactly 1.8619 ADA each) plus a stale debt field, so their ratio looks absurd (one came out at 0.00014). They are positions that were already liquidated. We filter out anything with ≤ 5 ADA and a ratio ≤ 0.5, and report how many we removed.
  • No price, no stress test. 6 iSOL positions are left out of the dollar figures because we had no cross-checked USD price for iSOL that run. They still count toward the coverage check above.
  • iADA doesn't belong in an ADA-price stress test. Its debt is in ADA, so its collateral ratio doesn't move when the ADA price does. Including it overstates the risk.
  • There is no single minimum ratio to hardcode. The indexer returns mcr = null for every asset, so we show the curve across 110%, 120%, 130% and 150% instead of pretending to know one.

6. What the book looks like

As of 2026-10-06 13:18 UTC, with ADA at $0.276:

Positions in the stress test 287 (of 311 decoded)
iAsset debt $1.89M
Collateral 17.4M ADA (~$4.81M)
Whole-book collateral ratio 2.54
ADA price where 10% of the debt is under 120% $0.175 (−36%)
ADA price where 50% of the debt is under 120% $0.143 (−48%)

It's a snapshot, not a forecast. The live version, with the full curve and CSVs, is at cardanorecord.com/risk.html, and it's refreshed several times a day from the same node.


We run The Cardano Record, a daily show built entirely from our own node's data, with every prediction it makes scored in public, the misses included. Not financial advice.