Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
204 changes: 204 additions & 0 deletions examples/vault_lending/FLOWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# 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` |
| Strategy weights sum to 10000 | `weightSum == 10000` on-chain in CompositeRouter |
| Liquidation waterfall is solvent | `residual >= 0` guard before distributing outputs |
78 changes: 78 additions & 0 deletions examples/vault_lending/composite_router.ark
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// composite_router.ark
// Atomically updates VaultCovenant + 2 StrategyFragments in one transaction.
// Keeper attestations verified via checkSigFromStack.
// PPS monotonicity enforced: newTotalAssets >= currentTotalAssets.
//
// weight0 + weight1 must equal 10000 (basis points); enforced on-chain in route().
//
// Trust assumption: currentTotalAssets and currentTotalShares are caller-supplied and not
// verified against the actual vault VTXO state. The PPS monotonicity check
// (newTotalAssets >= currentTotalAssets) can be circumvented by a keeper supplying a
// deflated currentTotalAssets. Security relies on keeper honesty for these params;
// they are not constrainable without spending the vault VTXO in the same transaction.
//
// Output value note: route() verifies scriptPubKey for all 3 outputs but not their values.
// Value conservation across the 3 outputs is enforced by the Ark round mechanism rather
// than by this covenant. A keeper constructing the transaction is responsible for correct
// value splits; no additional value can be extracted beyond what is in the input set.
//
// Division note: (reported0 * weight0 + reported1 * weight1) / 10000 floors toward zero.
// This may cause newTotalAssets to be 1 sat below the true weighted average at boundary values.

import "vault_covenant.ark";
import "strategy_fragment.ark";

options {
server = server;
exit = 144;
}

contract CompositeRouter(
pubkey keeper0,
pubkey keeper1,
pubkey vaultKeeperPk,
pubkey ownerPk,
int weight0, // basis points; weight0 + weight1 == 10000
int weight1,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
bytes32 strategyId0,
bytes32 strategyId1
) {
function route(
signature keeperSig0,
signature keeperSig1,
bytes32 reportHash0,
bytes32 reportHash1,
int reported0,
int reported1,
int currentTotalAssets,
int currentTotalShares
) {
require(checkSigFromStack(keeperSig0, keeper0, reportHash0), "invalid keeper0");
require(checkSigFromStack(keeperSig1, keeper1, reportHash1), "invalid keeper1");

int weightSum = weight0 + weight1;
require(weightSum == 10000, "weights must sum to 10000");

int newTotalAssets = (reported0 * weight0 + reported1 * weight1) / 10000;
require(newTotalAssets >= currentTotalAssets, "PPS decrease forbidden");
Comment on lines +40 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

The keeper attestations are replayable as written.

Lines 40-41 verify signatures over caller-supplied hashes, but the script never proves those hashes commit to strategyId0/1, reported0/1, or the currentTotal* values that feed output 0. Any stale keeper signature can therefore be reused to authorize a different routing result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/vault_lending/composite_router.ark` around lines 30 - 44, The keeper
signatures are replayable because route() only checks that keeperSig0/1 sign the
caller-supplied reportHash0/1 without proving those hashes commit to
strategyId0/1, reported0/1 or currentTotalAssets/currentTotalShares; fix by
recomputing and verifying the commitment that was actually signed: compute a
canonical hash (include strategyId0/strategyId1, reported0/reported1,
currentTotalAssets, currentTotalShares, and a domain/tag) and require
reportHash0 == computedHash0 and reportHash1 == computedHash1 before calling
checkSigFromStack; use the same canonical format for both keepers to prevent
reuse across different inputs.


require(
tx.outputs[0].scriptPubKey == new VaultCovenant(
vaultKeeperPk, ownerPk, newTotalAssets, currentTotalShares
),
"vault successor mismatch"
);
require(
tx.outputs[1].scriptPubKey == new StrategyFragment(
keeper0, reported0, strategyId0
),
"fragment0 successor mismatch"
);
require(
tx.outputs[2].scriptPubKey == new StrategyFragment(
keeper1, reported1, strategyId1
),
"fragment1 successor mismatch"
);
}
}
Loading
Loading