DEVNETYou are on Solana devnet. Funds are not real. Behavior matches mainnet.

The Merkle allowlist

Pricing alone isn't enough to safely open a loan. The protocol also needs to know that the NFT the borrower is locking actually corresponds to the specific grading certificate that the oracle just priced — i.e. that the issuer's claim about "this NFT represents this card" matches what the oracle was asked about. That binding lives in a Merkle allowlist that the protocol indexes off-chain and proves on-chain.

The binding leaf

For each (NFT, certificate, asset type) triple eligible for collateral, Milky builds a binding leaf:

leaf = H(domain ‖ nft_mint ‖ cert_hash ‖ asset_type_id)

Where:

  • H is SHA-256.
  • domain is the constant BINDING_LEAF_V1\0.
  • nft_mint is the Solana mint address of the NFT.
  • cert_hash is the 32-byte digest of the grading-cert identity.
  • asset_type_id is the 32-byte canonical asset identity (cert + grader
    • grade).

The protocol's indexer maintains the full set of these leaves. Any new mint or new cert that becomes eligible adds a new leaf; any cert that should no longer be borrowed against can be omitted from the next root.

The Merkle tree and root

All current leaves are aggregated into a Merkle tree, and the root hash is published on-chain. The on-chain Config account holds a small ring buffer of recent roots (currently K=3), each tagged with a root version number.

Why a ring buffer? Because quotes signed against the previous root should still be usable for a short window after a root rotation — otherwise a borrower who got a quote 5 minutes ago might find it suddenly invalid because the indexer just published an updated root. The K=3 buffer gives the system roughly two full rotations of grace.

When a quote arrives at loan_create, the protocol checks that the quote's claimed root_version matches one of the K versions currently in the buffer.

When and why roots rotate

The off-chain RealRootRotationService decides when to publish a new root based on two thresholds:

  • MAX_PENDING_LEAVES (default 1,000 new leaves) — enough new cards have been added that a rotation is worthwhile.
  • MAX_ROOT_AGE_HOURS (default 12 hours) — even without enough new leaves, the root rotates at least every 12 hours so that removed leaves stop being borrowable promptly.

The rotation transaction (rotate_root) is signed by an admin key (distinct from the oracle key) and appends the new (version, hash) to the ring buffer, evicting the oldest slot.

The proof receipt

Before opening a loan, the borrower submits a Merkle proof showing that their (NFT, cert, asset type) leaf is in the current tree. The on-chain verify_merkle_proof instruction:

  • Reconstructs the leaf from the supplied fields.
  • Walks the proof up to a root.
  • Compares the computed root to one of the K active roots in Config.
  • If matched, writes a ProofReceipt PDA keyed by the cert hash.

The receipt records (nft_mint, cert_hash, asset_type_id, root_version, expiry). The next instruction in the borrower's flow, loan_create, consumes the receipt — checking that all four locked fields match what the oracle's signed quote also claims.

This two-step pattern (verify-proof, then create-loan) keeps each on-chain instruction small enough to fit comfortably within Solana's compute budget and improves the auditability of each step.

What this prevents

The Merkle allowlist + proof receipt mechanism prevents two specific attack classes:

  • Mint-swap. An attacker can't open a loan against a different NFT than the one the oracle priced; the binding leaf ties the mint into the proof.
  • Cert-swap. They can't open a loan against a different physical certificate, because the cert hash is also in the leaf — even if they can produce a Metaplex NFT pointing at the right card metadata, they can't synthesize a leaf that's in a Merkle root the protocol recognizes.

What this does not solve

  • The off-chain indexer must not include incorrect leaves in the tree. If the indexer ever signs off on a leaf binding the wrong cert to the wrong NFT, the protocol will accept it. The oracle's signed quote must point at the same fields, so an isolated indexer error can't drain the protocol — but a coordinated oracle + indexer error can.
  • The Merkle allowlist is a snapshot per root version, not a live feed. A card removed from eligibility will continue to be borrowable against until the next root rotation evicts it from all K active versions.

Operational discipline

The rotation service watches both the leaf-count and time thresholds. In practice this means:

  • A burst of newly-eligible cards triggers a rotation within minutes rather than waiting up to 12 hours.
  • A quiet period still rotates the root at least every 12 hours, so any leaf that should be removed is removed within roughly that window (plus the K=3 ring buffer's grace).

For most users the entire mechanism is invisible: the app fetches the proof, attaches it to the transaction, and the proof receipt is created and consumed in the next instruction.