diff --git a/docs/lending-pool-prd.md b/docs/lending-pool-prd.md new file mode 100644 index 0000000..51e789f --- /dev/null +++ b/docs/lending-pool-prd.md @@ -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 diff --git a/examples/vault_lending/FLOWS.md b/examples/vault_lending/FLOWS.md new file mode 100644 index 0000000..54323c0 --- /dev/null +++ b/examples/vault_lending/FLOWS.md @@ -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 | diff --git a/examples/vault_lending/lending_market.ark b/examples/vault_lending/lending_market.ark new file mode 100644 index 0000000..d88afbc --- /dev/null +++ b/examples/vault_lending/lending_market.ark @@ -0,0 +1,198 @@ +// lending_market.ark +// Lending market covenant. Manages collateral and debt for one borrower position. +// +// Debt is supplied externally via SupplyFlow from VaultCovenant (exogenous path only). +// Collateral locks in this covenant; borrowed funds flow to the borrower at borrow time. +// +// Repayment and liquidation: +// repay — partial or full. Credit holder receives proceeds; full repay releases collateral. +// liquidate — keeper-only. Waterfall: fee to keeper | face value to creditHolder | residual to borrower. +// transferCredit — updates creditHolder for secondary market settlement (e.g. NonInteractiveSwap). +// +// Division note: all ratio checks use integer arithmetic scaled to basis points. +// OP_DIV64 floors toward zero. Both sides of the LLTV check are consistently floored, +// so no systematic bias exists. Ratio boundaries are very slightly borrower-favourable. +// Liquidation fee truncates down (keeper receives marginally less at fee boundary values). +// +// Oracle trust assumption: checkSigFromStack(oracleSig, oraclePk, priceHash) verifies +// the oracle signed priceHash, but the contract cannot verify that priceHash == hash(price) +// because the Arkade language has no on-chain hash opcode for this binding. Security relies +// on the oracle signing a message that commits to a specific price value (e.g. the oracle +// message format includes the price as a fixed-width field that the verifier checks off-chain). +// A compromised or colluding caller could replay a stale oracle signature with a different +// price value. Deployments must ensure the oracle message format is unambiguous and that +// priceHash commits to a canonical encoding of price. + +import "lending_market.ark"; +import "single_sig.ark"; + +options { + server = server; + exit = 144; +} + +contract LendingMarket( + pubkey borrowerPk, + pubkey oraclePk, + pubkey vaultKeeperPk, + bytes32 creditHolder, // repayment script (RepayFlow scriptPubKey); updated by transferCredit + int collateralAmount, + int debtAmount, + int lltv, // basis points, e.g. 8500 = 85% + bytes32 collateralAssetId, + bytes32 loanAssetId, + bytes32 oracleHash +) { + // ─── Borrow ─────────────────────────────────────────────────────────────────── + // Debt was supplied by VaultCovenant via SupplyFlow. + // Borrower provides collateral; borrowed funds flow out to borrower on output 1. + // Anti-reborrow: borrow() is only valid on a fresh position (debtAmount == 0). + // Value conservation: input value (available liquidity) must equal borrowAmount. + function borrow( + signature borrowerSig, + bytes32 priceHash, + signature oracleSig, + int price, + int borrowAmount, + int collateral + ) { + require(checkSig(borrowerSig, borrowerPk), "invalid borrower"); + require(checkSigFromStack(oracleSig, oraclePk, priceHash), "invalid oracle"); + + // Guard against re-borrowing over an existing position + require(debtAmount == 0, "position already open"); + require(collateralAmount == 0, "position already has collateral"); + + // Value conservation: the market input holds exactly the available liquidity; + // borrower must draw the full amount (partial draws leave unaccounted value). + require(tx.input.current.value == borrowAmount, "borrow amount must equal available liquidity"); + + // Collateral ratio check: (collateral * price / 10000) >= (borrowAmount * 10000 / lltv) + // Division floors toward zero — see contract header note. + int lhs = collateral * price / 10000; + int rhs = borrowAmount * 10000 / lltv; + require(lhs >= rhs, "insufficient collateral ratio"); + + // Output 0: updated LendingMarket holding the collateral + require(tx.outputs[0].value == collateral, "collateral must be locked in output 0"); + require( + tx.outputs[0].scriptPubKey == new LendingMarket( + borrowerPk, oraclePk, vaultKeeperPk, creditHolder, + collateral, borrowAmount, lltv, + collateralAssetId, loanAssetId, oracleHash + ), + "successor mismatch" + ); + // Output 1: borrowed funds flow to borrower + require(tx.outputs[1].value == borrowAmount, "borrower must receive borrow amount"); + require( + tx.outputs[1].scriptPubKey == new SingleSig(borrowerPk), + "borrower output mismatch" + ); + } + + // ─── Repay ──────────────────────────────────────────────────────────────────── + // Borrower repays partial or full debt. + // Credit holder receives repayment proceeds on output 1. + // Full repay: collateral released to borrower on output 0. + // Partial repay: recursive covenant on output 0 with updated debtAmount. + function repay( + signature borrowerSig, + int repayAmount, + int newDebtAmount + ) { + require(checkSig(borrowerSig, borrowerPk), "invalid borrower"); + require(repayAmount > 0, "repayAmount must be positive"); + + // Verify accounting: newDebtAmount + repayAmount == debtAmount + int verifySum = newDebtAmount + repayAmount; + require(verifySum == debtAmount, "invalid repay amounts"); + + // Repayment flows directly to the pre-committed RepayFlow covenant + require(tx.outputs[1].value == repayAmount, "repayment value must match repay amount"); + require(tx.outputs[1].scriptPubKey == creditHolder, "repayment must go to credit script"); + + if (newDebtAmount == 0) { + // Full repay: release collateral to borrower + require(tx.outputs[0].value == collateralAmount, "full collateral must be released"); + require( + tx.outputs[0].scriptPubKey == new SingleSig(borrowerPk), + "collateral must be returned to borrower" + ); + } else { + // Partial repay: recursive covenant with reduced debt; collateral stays locked + require(tx.outputs[0].value == collateralAmount, "partial repay must preserve collateral value"); + require( + tx.outputs[0].scriptPubKey == new LendingMarket( + borrowerPk, oraclePk, vaultKeeperPk, creditHolder, + collateralAmount, newDebtAmount, lltv, + collateralAssetId, loanAssetId, oracleHash + ), + "successor mismatch" + ); + } + } + + // ─── Liquidate ──────────────────────────────────────────────────────────────── + // Keeper-only. Position must be underwater (threshold < debtAmount). + // Liquidation waterfall: + // output 0: liquidation fee (5% of collateral) to keeper + // output 1: face value (debtAmount) to credit holder + // output 2: residual (collateral - fee - debtAmount) to borrower + // + // Division note: fee = collateralAmount * 500 / 10000 floors toward zero. + // residual = collateralAmount - fee - debtAmount (chained subtraction). + // Caller is responsible for ensuring collateralAmount >= fee + debtAmount. + function liquidate( + signature vaultKeeperSig, + bytes32 priceHash, + signature oracleSig, + int price + ) { + require(checkSig(vaultKeeperSig, vaultKeeperPk), "invalid keeper"); + require(checkSigFromStack(oracleSig, oraclePk, priceHash), "invalid oracle"); + + // Position is underwater: collateral value * lltv < debt * 10000 + int ratio = collateralAmount * price / 10000; + int threshold = ratio * lltv / 10000; + require(threshold < debtAmount, "position is not underwater"); + + // Liquidation waterfall + int fee = collateralAmount * 500 / 10000; + int residual = collateralAmount - fee - debtAmount; + require(residual >= 0, "residual must be non-negative"); + + require(tx.outputs[0].value == fee, "liquidation fee must be exact"); + require( + tx.outputs[0].scriptPubKey == new SingleSig(vaultKeeperPk), + "fee must go to keeper" + ); + require(tx.outputs[1].value == debtAmount, "credit holder payout must be exact"); + require(tx.outputs[1].scriptPubKey == creditHolder, "face value must go to credit script"); + require(tx.outputs[2].value >= residual, "residual to borrower too low"); + require( + tx.outputs[2].scriptPubKey == new SingleSig(borrowerPk), + "residual must go to borrower" + ); + } + + // ─── Transfer credit ────────────────────────────────────────────────────────── + // Keeper rotates creditHolder to a new RepayFlow script (e.g. to update vault + // snapshot after other vault activity before the position is opened). + // Only callable on a fresh position (debtAmount == 0) — once a borrower has + // drawn against the market the creditHolder is immutable, preventing the keeper + // from redirecting repay()/liquidate() proceeds after the fact. + function transferCredit(signature keeperSig, bytes32 newHolder) { + require(checkSig(keeperSig, vaultKeeperPk), "invalid keeper"); + require(debtAmount == 0, "cannot rotate credit on open position"); + require( + tx.outputs[0].scriptPubKey == new LendingMarket( + borrowerPk, oraclePk, vaultKeeperPk, newHolder, + collateralAmount, debtAmount, lltv, + collateralAssetId, loanAssetId, oracleHash + ), + "successor mismatch" + ); + require(tx.outputs[0].value == tx.input.current.value, "value must be preserved"); + } +} diff --git a/examples/vault_lending/repay_flow.ark b/examples/vault_lending/repay_flow.ark new file mode 100644 index 0000000..0b60942 --- /dev/null +++ b/examples/vault_lending/repay_flow.ark @@ -0,0 +1,78 @@ +// repay_flow.ark +// Inverse of SupplyFlow. Returns repaid assets from a closed LendingMarket back +// to VaultCovenant, accreting only the interest (yield) earned above principal. +// +// Pool accounting: vault.totalAssets stayed FLAT when capital was deployed +// (SupplyFlow did not decrement it). On return, only interest is added: +// interest = returnAmount - supplyAmount +// newVaultAssets = totalAssets + interest +// +// This preserves the invariant: totalAssets = vault.value + deployedCapital. +// After repayment, deployedCapital for this loan = 0, vault.value increases by +// returnAmount, and totalAssets increases by interest only (no double-counting). +// +// If returnAmount < supplyAmount (bad debt / partial liquidation), interest < 0 +// and totalAssets decreases — the loss is automatically socialized across all LP +// shares via share price dilution. +// +// creditHolder in LendingMarket is set to the scriptPubKey of this covenant at +// supply time. Repayment lands here automatically; keeper cannot redirect it. +// +// Liveness: reclaim() requires keeper. After the exit timelock (144 blocks), +// the LP (ownerPk) can call reclaimExpired() unilaterally, supplying current +// vault state (observable from vault VTXO scriptPubKey on-chain). + +import "vault_covenant.ark"; + +options { + server = server; + exit = 144; +} + +contract RepayFlow( + pubkey vaultKeeperPk, + pubkey ownerPk, + bytes32 lpAssetId, + int totalAssets, + int totalShares, + int supplyAmount // principal deployed; used to isolate interest on return +) { + // ─── Reclaim (cooperative) ──────────────────────────────────────────────────── + // Keeper routes repaid assets back into VaultCovenant. + // Only interest (returnAmount - supplyAmount) is added to totalAssets. + function reclaim(signature vaultKeeperSig) { + require(checkSig(vaultKeeperSig, vaultKeeperPk), "invalid vault keeper"); + + int returnAmount = tx.input.current.value; + int interest = returnAmount - supplyAmount; + int newVaultAssets = totalAssets + interest; + + require(tx.outputs[0].value == returnAmount, "full value must flow to vault"); + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + vaultKeeperPk, ownerPk, lpAssetId, newVaultAssets, totalShares + ), + "vault successor mismatch" + ); + } + + // ─── Reclaim expired (unilateral after exit timelock) ───────────────────────── + // Available after 144 blocks without keeper action (Ark exit path). + // LP supplies current vault totalAssets and totalShares (observable from vault + // VTXO scriptPubKey on-chain). Interest is computed the same way as reclaim(). + function reclaimExpired(signature ownerSig, int currentTotalAssets, int currentTotalShares) { + require(checkSig(ownerSig, ownerPk), "invalid owner"); + + int returnAmount = tx.input.current.value; + int interest = returnAmount - supplyAmount; + int newVaultAssets = currentTotalAssets + interest; + + require(tx.outputs[0].value == returnAmount, "full value must flow to vault"); + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + vaultKeeperPk, ownerPk, lpAssetId, newVaultAssets, currentTotalShares + ), + "vault successor mismatch" + ); + } +} diff --git a/examples/vault_lending/supply_flow.ark b/examples/vault_lending/supply_flow.ark new file mode 100644 index 0000000..ecb3bbf --- /dev/null +++ b/examples/vault_lending/supply_flow.ark @@ -0,0 +1,66 @@ +// supply_flow.ark +// One-shot atomic template. Moves BTC from VaultCovenant into a fresh LendingMarket. +// +// Pool accounting: vault.totalAssets stays FLAT on capital deployment. +// Only the vault's physical BTC value (idle capital) decreases; totalAssets continues +// to count deployed capital. This mirrors Morpho's accounting model: LP share price +// does not decrease when capital is deployed to a healthy loan. +// +// creditHolder must be supplied by the caller as the precomputed scriptPubKey of: +// RepayFlow(vaultKeeperPk, ownerPk, lpAssetId, totalAssets, totalShares, supplyAmount) +// supplyAmount is baked into RepayFlow so interest accounting is correct on return: +// interest = returnAmount - supplyAmount; vault.totalAssets += interest only. +// +// Value split: vault output receives inputVal - supplyAmount; market output receives supplyAmount. + +import "vault_covenant.ark"; +import "lending_market.ark"; + +options { + server = server; + exit = 144; +} + +contract SupplyFlow( + pubkey vaultKeeperPk, + pubkey ownerPk, + pubkey borrowerPk, + pubkey oraclePk, + bytes32 lpAssetId, + bytes32 creditHolder, // scriptPubKey of RepayFlow(vaultKeeperPk, ownerPk, lpAssetId, totalAssets, totalShares, supplyAmount) + int supplyAmount, + int lltv, + int totalAssets, + int totalShares, + bytes32 collateralAssetId, + bytes32 loanAssetId, + bytes32 oracleHash +) { + function supply(signature vaultKeeperSig) { + require(checkSig(vaultKeeperSig, vaultKeeperPk), "invalid vault keeper"); + require(supplyAmount <= totalAssets, "supply amount exceeds vault assets"); + + int inputVal = tx.input.current.value; + int vaultOut = inputVal - supplyAmount; + + // Output 0: VaultCovenant with same totalAssets (deployed capital still counted) + require(tx.outputs[0].value == vaultOut, "vault output value mismatch"); + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + vaultKeeperPk, ownerPk, lpAssetId, totalAssets, totalShares + ), + "vault successor mismatch" + ); + + // Output 1: fresh LendingMarket seeded with supplyAmount, ready for borrower + require(tx.outputs[1].value == supplyAmount, "market output value mismatch"); + require( + tx.outputs[1].scriptPubKey == new LendingMarket( + borrowerPk, oraclePk, vaultKeeperPk, creditHolder, + 0, 0, lltv, + collateralAssetId, loanAssetId, oracleHash + ), + "market successor mismatch" + ); + } +} diff --git a/examples/vault_lending/vault_covenant.ark b/examples/vault_lending/vault_covenant.ark new file mode 100644 index 0000000..1634349 --- /dev/null +++ b/examples/vault_lending/vault_covenant.ark @@ -0,0 +1,95 @@ +// vault_covenant.ark +// Recursive ERC-4626-style vault. Tracks share accounting and price-per-share (PPS). +// Only reportYield() can increase totalAssets relative to totalShares. +// PPS is monotonically non-decreasing: reportYield enforces newTotalAssets >= totalAssets. +// +// LP shares are issued as Arkade Assets (lpAssetId) on deposit and burned on withdrawal. +// This makes LP positions transferable: an LP can sell their shares on a secondary market +// without touching the vault covenant, providing exit liquidity even at full utilization. +// +// Deposit: LP tokens minted to caller (output[1]) proportional to sharesIssued. +// Withdraw: LP tokens burned from caller (input[1]) proportional to sharesBurned. +// Authentication on withdraw is the LP token burn — no ownerSig required. +// +// totalAssets invariant: totalAssets = vault.value + deployedCapital. +// SupplyFlow does NOT decrement totalAssets on capital deployment; RepayFlow +// accretes only interest (returnAmount - supplyAmount) when capital returns. +// +// Division note: all arithmetic uses OP_DIV64 (floor toward zero). +// PPS ratios are computed off-chain; the covenant enforces state transitions only. + +import "vault_covenant.ark"; + +options { + server = server; + exit = 144; +} + +contract VaultCovenant( + pubkey keeperPk, + pubkey ownerPk, + bytes32 lpAssetId, // Arkade Asset ID for LP share tokens + int totalAssets, + int totalShares +) { + // Deposit: LP provides BTC; LP tokens minted to output[1]. + // ownerSig gates deposits — keeper controls who can enter the pool. + function deposit(signature ownerSig, int newTotalAssets, int newTotalShares) { + require(checkSig(ownerSig, ownerPk), "invalid owner"); + require(newTotalShares > totalShares, "shares must increase"); + require(newTotalAssets > totalAssets, "assets must increase"); + + // Mint LP tokens to the depositor (output[1]) + int sharesIssued = newTotalShares - totalShares; + require( + tx.outputs[1].assets.lookup(lpAssetId) == sharesIssued, + "LP tokens must be minted to depositor" + ); + + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + keeperPk, ownerPk, lpAssetId, newTotalAssets, newTotalShares + ), + "successor mismatch" + ); + } + + // Withdraw: LP burns their LP tokens (input[1]); vault releases proportional BTC. + // LP token burn is the authentication — no separate signature required. + // Withdrawal is bounded by the vault's idle BTC (vault UTXO value). + function withdraw(int newTotalAssets, int newTotalShares) { + require(newTotalShares < totalShares, "shares must decrease"); + require(newTotalAssets < totalAssets, "assets must decrease"); + + // Burn LP tokens from the LP's input (input[1]) + int sharesBurned = totalShares - newTotalShares; + require( + tx.inputs[1].assets.lookup(lpAssetId) == sharesBurned, + "LP tokens must be burned" + ); + + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + keeperPk, ownerPk, lpAssetId, newTotalAssets, newTotalShares + ), + "successor mismatch" + ); + } + + // Only path that can increase PPS. + // keeperSig over reportHash prevents intra-block replay. + function reportYield( + signature keeperSig, + bytes32 reportHash, + int newTotalAssets + ) { + require(newTotalAssets >= totalAssets, "PPS decrease forbidden"); + require(checkSigFromStack(keeperSig, keeperPk, reportHash), "invalid keeper"); + require( + tx.outputs[0].scriptPubKey == new VaultCovenant( + keeperPk, ownerPk, lpAssetId, newTotalAssets, totalShares + ), + "successor mismatch" + ); + } +} diff --git a/playground/generate_contracts.sh b/playground/generate_contracts.sh index f9c1d25..fe7dae1 100755 --- a/playground/generate_contracts.sh +++ b/playground/generate_contracts.sh @@ -9,30 +9,38 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" EXAMPLES_DIR="$PROJECT_DIR/examples" OUTPUT="$SCRIPT_DIR/contracts.js" -echo "Generating contracts.js from examples/*.ark..." +echo "Generating contracts.js from examples/**/*.ark..." node -e " const fs = require('fs'); const path = require('path'); -const root = '$EXAMPLES_DIR'; +const dir = '$EXAMPLES_DIR'; +const entries = []; -function walk(dir) { - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...walk(full)); - else if (entry.isFile() && entry.name.endsWith('.ark')) out.push(full); +// Root-level .ark files +for (const f of fs.readdirSync(dir).sort()) { + if (f.endsWith('.ark')) { + entries.push({ name: f.replace('.ark', ''), file: path.join(dir, f) }); + } +} + +// One level of subdirectories — each subdir becomes a namespace prefix +for (const d of fs.readdirSync(dir).sort()) { + const subdir = path.join(dir, d); + if (fs.statSync(subdir).isDirectory()) { + for (const f of fs.readdirSync(subdir).sort()) { + if (f.endsWith('.ark')) { + entries.push({ name: d + '_' + f.replace('.ark', ''), file: path.join(subdir, f) }); + } + } } - return out; } -const files = walk(root).sort(); let out = '// Auto-generated from examples/**/*.ark — do not edit\n// Regenerate: ./playground/generate_contracts.sh\n\n'; -for (const full of files) { - const name = path.basename(full, '.ark'); - const code = fs.readFileSync(full, 'utf-8'); +for (const { name, file } of entries) { + const code = fs.readFileSync(file, 'utf-8'); out += 'export const ' + name + ' = ' + JSON.stringify(code) + ';\n\n'; } fs.writeFileSync('$OUTPUT', out); -console.log(' Written ' + files.length + ' contracts to contracts.js'); +console.log(' Written ' + entries.length + ' contracts to contracts.js'); " diff --git a/playground/main.js b/playground/main.js index 216d1d4..5bd1e01 100644 --- a/playground/main.js +++ b/playground/main.js @@ -14,7 +14,17 @@ const projects = { 'stability_vault.ark': contracts.stability_vault, 'stability_offer.ark': contracts.stability_offer, } - } + }, + vault_lending: { + name: 'Vault + Lending', + description: 'Morpho-style lending pool: ERC-4626 vault with isolated per-borrower lending markets', + files: { + 'vault_covenant.ark': contracts.vault_lending_vault_covenant, + 'lending_market.ark': contracts.vault_lending_lending_market, + 'supply_flow.ark': contracts.vault_lending_supply_flow, + 'repay_flow.ark': contracts.vault_lending_repay_flow, + } + }, }; // Single file examples diff --git a/tests/vault_lending_test.rs b/tests/vault_lending_test.rs new file mode 100644 index 0000000..aa7a3f5 --- /dev/null +++ b/tests/vault_lending_test.rs @@ -0,0 +1,527 @@ +use arkade_compiler::compile; + +// ─── Source fixtures via include_str! ──────────────────────────────────────── +// Tests compile the real example files rather than hand-maintained snapshots. + +const VAULT_COVENANT_SRC: &str = include_str!("../examples/vault_lending/vault_covenant.ark"); +const REPAY_FLOW_SRC: &str = include_str!("../examples/vault_lending/repay_flow.ark"); +const LENDING_MARKET_SRC: &str = include_str!("../examples/vault_lending/lending_market.ark"); +const SUPPLY_FLOW_SRC: &str = include_str!("../examples/vault_lending/supply_flow.ark"); + +/// Returns true if `needle` appears as a contiguous subsequence in `haystack`. +fn asm_contains_sequence(haystack: &[String], needle: &[&str]) -> bool { + haystack + .windows(needle.len()) + .any(|w| w.iter().zip(needle).all(|(a, b)| a == b)) +} + +/// Count occurrences of `op` in `asm`. +fn asm_count(asm: &[String], op: &str) -> usize { + asm.iter().filter(|s| *s == op).count() +} + +// ─── VaultCovenant ──────────────────────────────────────────────────────────── + +#[test] +fn test_vault_covenant_compiles() { + let result = compile(VAULT_COVENANT_SRC); + assert!( + result.is_ok(), + "VaultCovenant compile failed: {:?}", + result.err() + ); + let abi = result.unwrap(); + assert_eq!(abi.name, "VaultCovenant"); + // lpAssetId is used in assets.lookup() → compiler splits into _txid (bytes32) + _gidx (int) + assert_eq!(abi.parameters.len(), 6); + assert_eq!(abi.parameters[0].name, "keeperPk"); + assert_eq!(abi.parameters[0].param_type, "pubkey"); + assert_eq!(abi.parameters[2].name, "lpAssetId_txid"); + assert_eq!(abi.parameters[2].param_type, "bytes32"); + assert_eq!(abi.parameters[4].name, "totalAssets"); + assert_eq!(abi.parameters[4].param_type, "int"); +} + +#[test] +fn test_vault_covenant_functions() { + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + // 3 functions × 2 variants = 6 + assert_eq!(abi.functions.len(), 6); + for name in &["deposit", "withdraw", "reportYield"] { + assert!( + abi.functions + .iter() + .any(|f| &f.name == name && f.server_variant), + "Missing cooperative variant of {}", + name + ); + } +} + +#[test] +fn test_vault_covenant_deposit_enforces_pps_monotonicity() { + // deposit() must enforce both newTotalAssets > totalAssets and newTotalShares > totalShares + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + let deposit = abi + .functions + .iter() + .find(|f| f.name == "deposit" && f.server_variant) + .unwrap(); + assert!( + deposit.asm.iter().any(|op| op == "OP_GREATERTHAN"), + "deposit() must check asset/share increases via OP_GREATERTHAN, got {:?}", + deposit.asm + ); +} + +#[test] +fn test_vault_covenant_report_yield_uses_checksig_from_stack() { + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + let report = abi + .functions + .iter() + .find(|f| f.name == "reportYield" && f.server_variant) + .unwrap(); + assert!( + report.asm.iter().any(|op| op == "OP_CHECKSIGFROMSTACK"), + "reportYield() must verify keeper via OP_CHECKSIGFROMSTACK, got {:?}", + report.asm + ); +} + +#[test] +fn test_vault_covenant_deposit_mints_lp_tokens() { + // deposit() must verify LP tokens minted to output[1] via OP_INSPECTOUTASSETLOOKUP. + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + let deposit = abi + .functions + .iter() + .find(|f| f.name == "deposit" && f.server_variant) + .unwrap(); + assert!( + deposit + .asm + .iter() + .any(|op| op == "OP_INSPECTOUTASSETLOOKUP"), + "deposit() must check LP token output via OP_INSPECTOUTASSETLOOKUP, got {:?}", + deposit.asm + ); +} + +#[test] +fn test_vault_covenant_withdraw_burns_lp_tokens() { + // withdraw() must verify LP tokens burned from input[1] via OP_INSPECTINASSETLOOKUP. + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + let withdraw = abi + .functions + .iter() + .find(|f| f.name == "withdraw" && f.server_variant) + .unwrap(); + assert!( + withdraw + .asm + .iter() + .any(|op| op == "OP_INSPECTINASSETLOOKUP"), + "withdraw() must check LP token burn via OP_INSPECTINASSETLOOKUP, got {:?}", + withdraw.asm + ); +} + +#[test] +fn test_vault_covenant_withdraw_has_no_owner_sig() { + // withdraw() is authenticated by LP token burn, not ownerSig. + let abi = compile(VAULT_COVENANT_SRC).unwrap(); + let withdraw = abi + .functions + .iter() + .find(|f| f.name == "withdraw" && f.server_variant) + .unwrap(); + assert_eq!( + withdraw.function_inputs.len(), + 2, + "withdraw() must have exactly 2 inputs (newTotalAssets, newTotalShares), got {:?}", + withdraw.function_inputs + ); + assert!( + withdraw + .function_inputs + .iter() + .all(|i| i.param_type != "signature"), + "withdraw() must not require a signature — LP token burn is the auth" + ); +} + +// ─── RepayFlow ──────────────────────────────────────────────────────────────── + +#[test] +fn test_repay_flow_compiles() { + let result = compile(REPAY_FLOW_SRC); + assert!( + result.is_ok(), + "RepayFlow compile failed: {:?}", + result.err() + ); + let abi = result.unwrap(); + assert_eq!(abi.name, "RepayFlow"); + // lpAssetId is passed through (not used in assets.lookup) → stays as single bytes32 + assert_eq!(abi.parameters.len(), 6); + assert_eq!(abi.parameters[2].name, "lpAssetId"); + assert_eq!(abi.parameters[2].param_type, "bytes32"); + assert_eq!(abi.parameters[5].name, "supplyAmount"); + assert_eq!(abi.parameters[5].param_type, "int"); +} + +#[test] +fn test_repay_flow_has_both_reclaim_functions() { + let abi = compile(REPAY_FLOW_SRC).unwrap(); + // 2 functions × 2 variants = 4 + assert_eq!(abi.functions.len(), 4); + assert!( + abi.functions + .iter() + .any(|f| f.name == "reclaim" && f.server_variant), + "Missing cooperative reclaim" + ); + assert!( + abi.functions + .iter() + .any(|f| f.name == "reclaimExpired" && f.server_variant), + "Missing cooperative reclaimExpired" + ); +} + +#[test] +fn test_repay_flow_reclaim_requires_keeper_sig() { + let abi = compile(REPAY_FLOW_SRC).unwrap(); + let reclaim = abi + .functions + .iter() + .find(|f| f.name == "reclaim" && f.server_variant) + .unwrap(); + assert_eq!(reclaim.function_inputs.len(), 1); + assert_eq!(reclaim.function_inputs[0].name, "vaultKeeperSig"); + assert_eq!(reclaim.function_inputs[0].param_type, "signature"); +} + +#[test] +fn test_repay_flow_reclaim_expired_requires_owner_sig() { + // reclaimExpired is the LP's self-sovereign exit — must use ownerSig, not keeperSig + let abi = compile(REPAY_FLOW_SRC).unwrap(); + let expired = abi + .functions + .iter() + .find(|f| f.name == "reclaimExpired" && f.server_variant) + .unwrap(); + assert_eq!(expired.function_inputs.len(), 3); + assert_eq!(expired.function_inputs[0].name, "ownerSig"); + assert_eq!(expired.function_inputs[0].param_type, "signature"); + assert_eq!(expired.function_inputs[1].name, "currentTotalAssets"); + assert_eq!(expired.function_inputs[1].param_type, "int"); + assert_eq!(expired.function_inputs[2].name, "currentTotalShares"); + assert_eq!(expired.function_inputs[2].param_type, "int"); +} + +#[test] +fn test_repay_flow_produces_vault_covenant_successor() { + let abi = compile(REPAY_FLOW_SRC).unwrap(); + for fn_name in &["reclaim", "reclaimExpired"] { + let f = abi + .functions + .iter() + .find(|f| f.name == *fn_name && f.server_variant) + .unwrap(); + assert!( + f.asm.iter().any(|op| op.contains("VTXO:VaultCovenant")), + "{} must produce VaultCovenant successor, got {:?}", + fn_name, + f.asm + ); + } +} + +#[test] +fn test_repay_flow_interest_accounting() { + // reclaim() and reclaimExpired() must compute interest = returnAmount - supplyAmount + // and add only interest to totalAssets (not the full returnAmount). + // ASM pattern: OP_SUB (interest = returnAmount - supplyAmount) + // OP_ADD (newVaultAssets = totalAssets + interest) + let abi = compile(REPAY_FLOW_SRC).unwrap(); + for fn_name in &["reclaim", "reclaimExpired"] { + let f = abi + .functions + .iter() + .find(|f| f.name == *fn_name && f.server_variant) + .unwrap(); + assert!( + f.asm.iter().any(|op| op == "OP_SUB64"), + "{} must subtract supplyAmount from returnAmount (OP_SUB64) to isolate interest, got {:?}", + fn_name, + f.asm + ); + assert!( + f.asm.iter().any(|op| op == "OP_ADD64"), + "{} must add interest to totalAssets (OP_ADD64), got {:?}", + fn_name, + f.asm + ); + } +} + +// ─── LendingMarket ──────────────────────────────────────────────────────────── + +#[test] +fn test_lending_market_compiles() { + let result = compile(LENDING_MARKET_SRC); + assert!( + result.is_ok(), + "LendingMarket compile failed: {:?}", + result.err() + ); + let abi = result.unwrap(); + assert_eq!(abi.name, "LendingMarket"); + assert_eq!(abi.parameters.len(), 10); +} + +#[test] +fn test_lending_market_credit_holder_is_bytes32() { + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let credit_holder = abi + .parameters + .iter() + .find(|p| p.name == "creditHolder") + .unwrap(); + assert_eq!( + credit_holder.param_type, "bytes32", + "creditHolder must be bytes32 script hash, not pubkey" + ); +} + +#[test] +fn test_lending_market_functions() { + let abi = compile(LENDING_MARKET_SRC).unwrap(); + // 4 functions × 2 variants = 8 + assert_eq!(abi.functions.len(), 8); + for name in &["borrow", "repay", "liquidate", "transferCredit"] { + assert!( + abi.functions + .iter() + .any(|f| &f.name == name && f.server_variant), + "Missing cooperative variant of {}", + name + ); + } +} + +#[test] +fn test_lending_market_borrow_guards_against_reborrow() { + // borrow() must reject positions that already have debt or collateral. + // ASM: 0 OP_EQUAL and 0 OP_EQUAL + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let borrow = abi + .functions + .iter() + .find(|f| f.name == "borrow" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence(&borrow.asm, &["", "0", "OP_EQUAL"]), + "borrow() must check debtAmount == 0, got {:?}", + borrow.asm + ); + assert!( + asm_contains_sequence(&borrow.asm, &["", "0", "OP_EQUAL"]), + "borrow() must check collateralAmount == 0, got {:?}", + borrow.asm + ); +} + +#[test] +fn test_lending_market_borrow_enforces_value_conservation() { + // borrow() must verify tx.input.current.value == borrowAmount. + // ASM: OP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE OP_EQUAL + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let borrow = abi + .functions + .iter() + .find(|f| f.name == "borrow" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence( + &borrow.asm, + &[ + "OP_PUSHCURRENTINPUTINDEX", + "OP_INSPECTINPUTVALUE", + "", + "OP_EQUAL" + ] + ), + "borrow() must verify input value == borrowAmount, got {:?}", + borrow.asm + ); +} + +#[test] +fn test_lending_market_borrow_enforces_collateral_ratio() { + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let borrow = abi + .functions + .iter() + .find(|f| f.name == "borrow" && f.server_variant) + .unwrap(); + assert!( + borrow.asm.iter().any(|op| op == "OP_DIV64"), + "borrow() collateral ratio check must use OP_DIV64, got {:?}", + borrow.asm + ); + assert!( + borrow.asm.iter().any(|op| op == "OP_GREATERTHANOREQUAL"), + "borrow() must use OP_GREATERTHANOREQUAL for ratio check, got {:?}", + borrow.asm + ); +} + +#[test] +fn test_lending_market_borrow_checks_borrower_output_value() { + // borrow() must verify outputs[1].value == borrowAmount. + // ASM: 1 OP_INSPECTOUTPUTVALUE OP_EQUAL + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let borrow = abi + .functions + .iter() + .find(|f| f.name == "borrow" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence( + &borrow.asm, + &["1", "OP_INSPECTOUTPUTVALUE", "", "OP_EQUAL"] + ), + "borrow() must check outputs[1].value == borrowAmount, got {:?}", + borrow.asm + ); +} + +#[test] +fn test_lending_market_repay_checks_repay_output_value() { + // repay() must verify outputs[1].value == repayAmount. + // ASM: 1 OP_INSPECTOUTPUTVALUE OP_EQUAL + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let repay = abi + .functions + .iter() + .find(|f| f.name == "repay" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence( + &repay.asm, + &["1", "OP_INSPECTOUTPUTVALUE", "", "OP_EQUAL"] + ), + "repay() must check outputs[1].value == repayAmount, got {:?}", + repay.asm + ); +} + +#[test] +fn test_lending_market_liquidate_uses_exact_fee_and_debt_amounts() { + // Fee output (index 0) and debt output (index 1) must use exact OP_EQUAL. + // ASM: 0 OP_INSPECTOUTPUTVALUE OP_EQUAL + // 1 OP_INSPECTOUTPUTVALUE OP_EQUAL + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let liquidate = abi + .functions + .iter() + .find(|f| f.name == "liquidate" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence( + &liquidate.asm, + &["0", "OP_INSPECTOUTPUTVALUE", "", "OP_EQUAL"] + ), + "liquidate() must check outputs[0].value == fee exactly, got {:?}", + liquidate.asm + ); + assert!( + asm_contains_sequence( + &liquidate.asm, + &["1", "OP_INSPECTOUTPUTVALUE", "", "OP_EQUAL"] + ), + "liquidate() must check outputs[1].value == debtAmount exactly, got {:?}", + liquidate.asm + ); + // Total OP_EQUAL count: at least 2 for the exact checks above + let equal_count = asm_count(&liquidate.asm, "OP_EQUAL"); + assert!( + equal_count >= 2, + "liquidate() must have >= 2 OP_EQUAL (got {})", + equal_count + ); +} + +#[test] +fn test_lending_market_liquidate_guards_residual() { + // residual >= 0 must appear before the output waterfall. + // ASM: OP_GREATERTHANOREQUAL 0 + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let liquidate = abi + .functions + .iter() + .find(|f| f.name == "liquidate" && f.server_variant) + .unwrap(); + assert!( + asm_contains_sequence( + &liquidate.asm, + &["", "OP_GREATERTHANOREQUAL", "0"] + ), + "liquidate() must check residual >= 0, got {:?}", + liquidate.asm + ); +} + +#[test] +fn test_lending_market_transfer_credit_is_keeper_only() { + // transferCredit must check keeper sig and debtAmount == 0 (blocks rotation on open positions) + let abi = compile(LENDING_MARKET_SRC).unwrap(); + let tc = abi + .functions + .iter() + .find(|f| f.name == "transferCredit" && f.server_variant) + .unwrap(); + assert_eq!(tc.function_inputs.len(), 2); + assert_eq!(tc.function_inputs[0].name, "keeperSig"); + assert_eq!( + tc.function_inputs[1].param_type, "bytes32", + "newHolder must be bytes32 script hash" + ); + // Must block rotation on open positions: 0 OP_EQUAL + assert!( + asm_contains_sequence(&tc.asm, &["", "0", "OP_EQUAL"]), + "transferCredit() must check debtAmount == 0, got {:?}", + tc.asm + ); +} + +// ─── Lifecycle compilation smoke test ───────────────────────────────────────── + +#[test] +fn test_all_vault_lending_contracts_compile() { + // Smoke test: every contract in the vault+lending system compiles without error. + let contracts = [ + ("VaultCovenant", VAULT_COVENANT_SRC), + ("RepayFlow", REPAY_FLOW_SRC), + ("LendingMarket", LENDING_MARKET_SRC), + ("SupplyFlow", SUPPLY_FLOW_SRC), + ]; + for (name, src) in &contracts { + let result = compile(src); + assert!( + result.is_ok(), + "{} failed to compile: {:?}", + name, + result.err() + ); + assert_eq!( + result.unwrap().name, + *name, + "Contract name mismatch for {}", + name + ); + } +}