Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cfbf534
feat: add vault + lending market contract stack (8 examples)
claude Apr 4, 2026
21e20e5
fix: remove migrate path and upgradeRoot from VaultCovenant
claude Apr 4, 2026
e0e9c5c
refactor: move vault+lending contracts into examples/vault_lending/ s…
claude Apr 4, 2026
349f672
refactor: strip synthetic borrow path from lending_market
claude Apr 5, 2026
49f25dd
feat: add lending_pool.ark and address CodeRabbit review issues
claude Apr 5, 2026
8dc5be0
Merge remote-tracking branch 'origin/master' into claude/arkade-contr…
claude Apr 5, 2026
7bb93cd
feat: remove lending_pool.ark
claude Apr 5, 2026
c4d4650
chore: remove lending_pool from playground project
claude Apr 5, 2026
1fa982b
feat: creditHolder as bytes32 script; add UTXO flow diagrams
claude Apr 5, 2026
e0a2f4b
fix: simplify Mermaid diagrams for GitHub rendering
claude Apr 5, 2026
d267d05
feat: add reclaimExpired() to RepayFlow for LP liveness guarantee
claude Apr 5, 2026
ff7021e
fix: address Arkana review — exact liquidation amounts, value check, …
claude Apr 8, 2026
0091b0a
style: cargo fmt on vault_lending_test.rs
claude Apr 8, 2026
505a162
fix: add value preservation to report(); document CompositeRouter out…
claude Apr 8, 2026
0a9d361
fix: address CodeRabbit review — borrow guards, transferCredit, test …
claude Apr 8, 2026
d55d4d4
docs: Morpho-style lending pool PRD; remove strategy routing contracts
claude May 15, 2026
e97ee69
chore: merge master; resolve generate_contracts.sh conflict
claude May 15, 2026
2b19ef9
feat: LP share tokens as Arkade Assets; fix pool accounting model
claude May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/lending-pool-prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Morpho-Style Lending Pool on UTXO

## Problem

Bitcoin's UTXO model forces each borrower position into an isolated UTXO. Capital
deployed from the LP vault into a `LendingMarket` is physically locked in that UTXO
until the borrower repays or gets liquidated. An LP who wants out while capital is
deployed has no immediate path — the vault's idle balance may be zero.

On EVM, Morpho Blue solves this with a shared contract: all positions share one
accounting store, idle capital is always visible, and LP withdrawal is instant up
to the aggregate idle balance. The UTXO model cannot replicate shared mutable
state, but it can replicate the product behaviour through three mechanisms:

1. **Correct pool accounting** — vault `totalAssets` tracks all capital (idle + deployed),
not just idle. LP shares reflect the full economic position.
2. **Withdrawal bounded by idle** — LP can withdraw up to the vault's actual BTC balance.
Beyond that, they wait for repayment or use mechanism 3.
3. **LP shares as Arkade Assets** — shares are a transferable fungible token. An LP
who wants immediate liquidity sells shares on a secondary market without touching
the vault covenant.

---

## Architecture

The system uses four contracts. No yield-routing layer is needed at this stage.

```
┌──────────────────────────────────────────────────────┐
│ VaultCovenant (VTXO) │
│ totalAssets = idle + deployed (invariant) │
│ totalShares = LP share tokens outstanding │
│ vault UTXO value = idle portion only │
└───────┬──────────────────────────────────────────────┘
│ SupplyFlow (one-shot atomic)
┌──────────────────────────────────────────────────────┐
│ LendingMarket (VTXO, one per borrower) │
│ collateralAmount / debtAmount / lltv │
│ creditHolder = RepayFlow scriptPubKey │
└───────┬──────────────────────────────────────────────┘
│ repay() / liquidate()
┌──────────────────────────────────────────────────────┐
│ RepayFlow (VTXO) │
│ reclaim() → VaultCovenant (keeper) │
│ reclaimExpired() → VaultCovenant (LP, after 144 blk)│
└──────────────────────────────────────────────────────┘
```

### VaultCovenant

Recursive covenant tracking `(totalAssets, totalShares)`.

**Key invariant**: `totalAssets = vault.value + Σ(outstanding deployed capital)`.
Capital deployment via `SupplyFlow` does NOT decrement `totalAssets`. The vault's
UTXO value (physical BTC held) decreases; `totalAssets` stays flat. When
`RepayFlow.reclaim()` returns capital, `totalAssets` increases by the returned
amount (capturing interest/yield).

**LP share accounting** (ERC-4626 style):
```
sharesIssued = depositAmount * totalShares / totalAssets (deposit)
assetsOut = sharesIn * totalAssets / totalShares (withdraw)
```

`totalShares` is also the total outstanding supply of the LP share Arkade Asset.

### LP Shares as Arkade Assets

LP shares are a fungible Arkade Asset issued at deposit and burned at withdrawal.
The asset ID is globally unique (committed at vault genesis). Shares are
transferable: an LP can sell their position on any Arkade-compatible secondary
market without coordinating with the vault or the keeper.

This is the primary liquidity mechanism when the vault is fully deployed. The
secondary market prices LP shares at a discount that reflects credit risk and
expected repayment timing.

### LendingMarket

One VTXO per borrower. Unchanged from the current implementation:
- `borrow()` anti-reborrow guards (`debtAmount == 0`, `collateralAmount == 0`)
- Value conservation: `tx.input.current.value == borrowAmount`
- Oracle-attested LLTV check via `checkSigFromStack`
- Liquidation waterfall: fee → keeper, face value → `creditHolder`, residual → borrower
- `transferCredit` locked on open positions

### SupplyFlow

One-shot atomic: moves capital from `VaultCovenant` → fresh `LendingMarket`.

**Change from current design**: does not decrement `vault.totalAssets`. The vault
output receives `inputVal - supplyAmount` in BTC value, but `totalAssets` stays
at its pre-supply value. The keeper is responsible for tracking deployed capital
off-chain (sum of outstanding `LendingMarket` debt values) to verify the invariant
at withdrawal time.

### RepayFlow

Unchanged. When `reclaim()` routes repayment back to `VaultCovenant`, the returned
amount is added to `totalAssets` — capturing any interest earned above principal.

---

## LP Liquidity Model

| Scenario | LP exit path |
|---|---|
| Idle capital available (utilization < 100%) | `VaultCovenant.withdraw()` — instant |
| Fully deployed (utilization = 100%) | Sell LP share tokens on secondary market |
| Keeper unresponsive, capital in RepayFlow | `reclaimExpired()` after 144 blocks |
| Keeper unresponsive, position still open | Wait for Ark exit timelock; borrower can repay trustlessly |

---

## Accounting Model: Current vs. Target

| | Current (in PR) | Target (Morpho-style) |
|---|---|---|
| `totalAssets` on supply | Decrements by `supplyAmount` | Stays flat |
| LP share value on supply | Decreases (shares track idle only) | Unchanged (shares track all capital) |
| LP withdrawal | Uncapped (accounting bug) | Capped at `vault.value` (idle) |
| LP liquidity at 100% util. | None | Secondary market for LP share tokens |
| Bad debt on liquidation shortfall | Not modelled | `totalAssets` decreases → share price falls |

---

## Why Not Loan-Receipt Basket

The alternative approach issues a per-loan Arkade Asset receipt at borrow time.
The vault holds a basket of receipts; LPs redeem shares for a proportional slice
of the basket.

**Rejected because:**

- Receipts are heterogeneous (different rates, collateral, oracle, maturity). Each
receipt requires independent pricing. The secondary market fragments into N illiquid
receipt markets rather than one liquid LP share market.
- LP withdrawal at full utilization delivers a basket of partially-matched receipts,
not cash. The LP must then liquidate those receipts separately — worse UX than
selling a fungible LP share.
- Bad debt requires per-receipt impairment decisions. LP share approach automatically
socialises losses via `totalAssets` reduction.
- Implementation complexity: unique Arkade Asset ID per loan at issuance is
significantly harder to coordinate than a single vault share asset.

---

## What Was Removed

`StrategyFragment` and `CompositeRouter` were removed from the vault+lending suite.
They implement a yield-routing layer (keeper-attested strategy weights aggregated
into a vault update) that is orthogonal to the LP liquidity problem. The yield
routing concern belongs in a separate product layer, not in the base lending pool.
The `CompositeRouter` also explicitly relies on keeper-supplied `currentTotalAssets`
without on-chain verification — an unnecessary trust assumption in a design that
already has the vault covenant enforce accounting.

---

## Implementation Checklist

- [ ] Update `VaultCovenant.withdraw()` to cap output value at `vault.value` via
`tx.input.current.value` introspection
- [ ] Update `SupplyFlow.supply()`: keep `totalAssets` flat; only decrement vault BTC value
- [ ] Add LP share Arkade Asset issuance in `VaultCovenant.deposit()`
- [ ] Add LP share burn check in `VaultCovenant.withdraw()`
- [ ] Update `RepayFlow.reclaim()` accounting if interest accrual changes `totalAssets`
beyond original supply amount
- [ ] Update tests: remove StrategyFragment / CompositeRouter; add pool accounting tests
- [ ] Update FLOWS.md to reflect corrected accounting model
203 changes: 203 additions & 0 deletions examples/vault_lending/FLOWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Vault + Lending — UTXO Spending Flows

Each diagram shows one transaction. Inputs are on the left, outputs on the right.
`fn(...)` labels on edges name the covenant function being executed.

---

## 1. Deposit (LP → Vault)

```mermaid
graph LR
I0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets, totalShares"]
I1["SingleSig(ownerPk)\ndeposit value"]
O0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets + deposit\ntotalShares + newShares"]

I0 -->|"deposit(ownerSig, ...)"| O0
I1 --> O0
```

---

## 2. Withdraw (Vault → LP)

```mermaid
graph LR
I0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets, totalShares"]
O0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets - withdraw\ntotalShares - burned"]
O1["SingleSig(ownerPk)\nwithdraw value"]

I0 -->|"withdraw(ownerSig, ...)"| O0
I0 --> O1
```

---

## 3. Supply (Vault → LendingMarket)

`creditHolder` = precomputed `scriptPubKey` of `RepayFlow(keeperPk, ownerPk, totalAssets − supplyAmount, totalShares)`

```mermaid
graph LR
I0["SupplyFlow\nkeeperPk, ownerPk, borrowerPk\ncreditHolder, supplyAmount, lltv\ntotalAssets, totalShares"]
O0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets - supplyAmount\ntotalShares"]
O1["LendingMarket\nborrowerPk, oraclePk, keeperPk\ncreditHolder = RepayFlow script\ncollateral=0, debt=0, lltv"]

I0 -->|"supply(keeperSig)"| O0
I0 --> O1
```

---

## 4. Borrow (LendingMarket → Borrower)

```mermaid
graph LR
I0["LendingMarket\ncollateral=0, debt=0\ncreditHolder = RepayFlow script"]
I1["SingleSig(borrowerPk)\ncollateral"]
O0["LendingMarket\ncollateral, debt=borrowAmount\ncreditHolder = RepayFlow script"]
O1["SingleSig(borrowerPk)\nborrowAmount"]

I0 -->|"borrow(borrowerSig, oracleSig, ...)"| O0
I1 --> O0
I0 --> O1
```

---

## 5a. Full Repay (Borrower closes position)

```mermaid
graph LR
I0["LendingMarket\ncollateral, debt\ncreditHolder = RepayFlow script"]
I1["SingleSig(borrowerPk)\nrepayAmount"]
O0["SingleSig(borrowerPk)\ncollateral released"]
O1["RepayFlow\nkeeperPk, ownerPk\ntotalAssets, totalShares\nrepayAmount value"]

I0 -->|"repay(borrowerSig, repayAmount, newDebt=0)"| O0
I1 --> O0
I0 --> O1
```

---

## 5b. Partial Repay (Borrower reduces debt)

```mermaid
graph LR
I0["LendingMarket\ncollateral, debt\ncreditHolder = RepayFlow script"]
I1["SingleSig(borrowerPk)\nrepayAmount"]
O0["LendingMarket\ncollateral, debt - repayAmount\ncreditHolder unchanged"]
O1["RepayFlow\nkeeperPk, ownerPk\ntotalAssets, totalShares\nrepayAmount value"]

I0 -->|"repay(borrowerSig, repayAmount, newDebt)"| O0
I1 --> O0
I0 --> O1
```

---

## 6. Reclaim (RepayFlow → Vault)

`returnAmount` is derived from `tx.input.current.value` — no keeper input.

```mermaid
graph LR
I0["RepayFlow\nkeeperPk, ownerPk\ntotalAssets, totalShares\nreturnAmount value"]
O0["VaultCovenant\nkeeperPk, ownerPk\ntotalAssets + returnAmount\ntotalShares"]

I0 -->|"reclaim(keeperSig)"| O0
```

---

## 6b. Reclaim Expired (LP unilateral — after 144-block exit timelock)

Keeper is unresponsive. LP calls `reclaimExpired()` without keeper co-sign.
LP supplies current vault `totalAssets`/`totalShares` (observable from vault VTXO on-chain).

```mermaid
graph LR
I0["RepayFlow\nkeeperPk, ownerPk\ntotalAssets, totalShares\nreturnAmount value"]
O0["VaultCovenant\nkeeperPk, ownerPk\ncurrentTotalAssets + returnAmount\ncurrentTotalShares"]

I0 -->|"reclaimExpired(ownerSig, currentTotalAssets, currentTotalShares)"| O0
```

---

## 7. Liquidation (Keeper closes underwater position)

```mermaid
graph LR
I0["LendingMarket\ncollateral, debt\nposition underwater"]
O0["SingleSig(keeperPk)\nfee = collateral × 5%"]
O1["RepayFlow\nkeeperPk, ownerPk\ntotalAssets, totalShares\ndebt value"]
O2["SingleSig(borrowerPk)\ncollateral - fee - debt"]

I0 -->|"liquidate(keeperSig, oracleSig, ...)"| O0
I0 --> O1
I0 --> O2
```

---

## 8. End-to-end lifecycle

```mermaid
sequenceDiagram
participant LP
participant Vault as VaultCovenant
participant SF as SupplyFlow
participant LM as LendingMarket
participant RF as RepayFlow
participant B as Borrower

LP->>Vault: deposit()
Note over Vault: totalAssets increases

Note over SF: keeper creates SupplyFlow VTXO
SF->>Vault: supply() → VaultCovenant(totalAssets − X)
SF->>LM: supply() → LendingMarket(debt=0, creditHolder=RepayFlow script)

B->>LM: borrow(collateral)
LM-->>B: SingleSig(borrowerPk) borrowAmount
Note over LM: collateral locked, debt recorded

B->>LM: repay(repayAmount)
LM-->>B: SingleSig(borrowerPk) collateral (full repay)
LM-->>RF: RepayFlow VTXO created automatically

RF->>Vault: reclaim()
Note over Vault: totalAssets + returnAmount

LP->>Vault: withdraw()
Vault-->>LP: assets + accrued yield
```

---

## Liveness tradeoffs

| Actor | Keeper required? | Self-sovereign exit? | Notes |
|---|---|---|---|
| **Borrower** | Cooperative path only | Yes — after 144 blocks | `exit = 144` guarantees collateral recovery |
| **LP (idle vault assets)** | Cooperative path only | Yes — after 144 blocks | `VaultCovenant.withdraw()` unilateral after exit |
| **LP (deployed assets)** | `reclaim()` needs keeper | Yes — `reclaimExpired()` after 144 blocks | LP supplies current vault state; no keeper needed |
| **Liquidation** | Always keeper-gated | No | Underwater positions cannot be liquidated without keeper |
| **Yield reporting** | Always keeper-gated | No | `reportYield()` requires keeperSig; PPS freezes if keeper down (no loss) |
| **Credit transfer** | Always keeper-gated | No | `transferCredit()` rotates RepayFlow target; keeper-only |

**Key asymmetry**: borrowers always have a self-sovereign exit. LPs in deployed positions now also have one via `reclaimExpired()`. Liquidations remain fully keeper-dependent — if the keeper is offline while positions are underwater, the vault absorbs the loss.

---

## Key invariants

| Invariant | Enforced by |
|---|---|
| Repayment always lands in RepayFlow, never a bare pubkey | `creditHolder` is `bytes32` in LendingMarket; `repay` checks `outputs[1].scriptPubKey == creditHolder` |
| RepayFlow script committed at supply time | Off-chain: `creditHolder = scriptPubKey(RepayFlow(keeperPk, ownerPk, totalAssets − supplyAmount, totalShares))` |
| Vault accounting bound to actual settled value | `returnAmount = tx.input.current.value` in RepayFlow — no caller input |
| Collateral ratio enforced on every borrow | `collateral × price / 10000 >= borrowAmount × 10000 / lltv` |
| Liquidation waterfall is solvent | `residual >= 0` guard before distributing outputs |
Loading
Loading