Skip to content

feat: vault + lending market covenant system (Morpho-style)#26

Open
tiero wants to merge 15 commits intomasterfrom
claude/arkade-contracts-ZAi2R
Open

feat: vault + lending market covenant system (Morpho-style)#26
tiero wants to merge 15 commits intomasterfrom
claude/arkade-contracts-ZAi2R

Conversation

@tiero
Copy link
Copy Markdown
Member

@tiero tiero commented Apr 4, 2026

Summary

Morpho-style vault + isolated lending market system for Ark. Vault LPs deposit into an ERC-4626-style VaultCovenant; the keeper allocates to isolated LendingMarket positions; repayments flow back through a pre-committed RepayFlow covenant. No shared risk between positions.

Full UTXO spending diagrams + liveness table: examples/vault_lending/FLOWS.md


Contracts

File Role
vault_covenant.ark ERC-4626-style vault. Tracks totalAssets / totalShares. Keeper-attested yield via reportYield.
strategy_fragment.ark Per-strategy VTXO with keeper attestation and reportedAmount binding.
composite_router.ark Atomically updates vault + 2 strategy fragments. Weight sum enforced on-chain (weight0 + weight1 == 10000).
supply_flow.ark One-shot: moves assets from VaultCovenant → fresh LendingMarket. Sets creditHolder to precomputed RepayFlow script.
lending_market.ark Isolated per-position market. Collateral ratio, partial/full repay, keeper liquidation with waterfall.
repay_flow.ark Repayment destination covenant. Two paths: reclaim() (keeper) and reclaimExpired() (LP unilateral after 144-block timelock).

Key Design Decisions

Repayment routing — creditHolder as bytes32 script

LendingMarket.creditHolder is a bytes32 script hash (not a pubkey). At supply time, SupplyFlow sets it to the precomputed scriptPubKey of:

RepayFlow(keeperPk, ownerPk, totalAssets − supplyAmount, totalShares)

Repayments and liquidation proceeds therefore land directly in the correct RepayFlow covenant. The keeper cannot redirect them to any other script. RepayFlow.reclaim() derives the return amount from tx.input.current.value — vault accounting is bound to the actual settled value, not a caller-supplied number.

Liveness — LP self-sovereign exit for deployed capital

Actor Keeper required? Self-sovereign exit?
Borrower Cooperative path only Yes — after 144 blocks
LP (idle vault assets) Cooperative path only Yes — after 144 blocks
LP (deployed assets) reclaim() needs keeper Yes — reclaimExpired() after 144 blocks
Liquidation Always No
Yield reporting Always No

RepayFlow.reclaimExpired(ownerSig, currentTotalAssets, currentTotalShares) is callable without keeper co-sign after the 144-block exit timelock. The LP supplies the current vault state (observable from the vault VTXO scriptPubKey on-chain) and recovers all deployed capital. Liquidations remain keeper-gated — if the keeper is offline while positions are underwater, the vault absorbs the loss.

Supplier / Borrower exit

  • LP withdraw: VaultCovenant.withdraw() for idle assets any time; reclaimExpired() for deployed assets after 144 blocks.
  • Borrower full repay: collateral released to SingleSig(borrowerPk), repayment to RepayFlow.
  • Borrower partial repay: recursive LendingMarket with reduced debt; collateral stays locked.
  • Liquidation: fee (5%) → keeper, face value → RepayFlow, residual → borrower.

UTXO Flow (abbreviated)

deposit()         SingleSig(ownerPk) + VaultCovenant  →  VaultCovenant(totalAssets + X)

supply()          SupplyFlow                            →  VaultCovenant(totalAssets − X)
                                                        →  LendingMarket(debt=0, creditHolder=RepayFlow script)

borrow()          LendingMarket(debt=0) + collateral   →  LendingMarket(collateral, debt=X)
                                                        →  SingleSig(borrowerPk) borrowAmount

repay()           LendingMarket + repayAmount           →  SingleSig(borrowerPk) collateral  [full]
                                                        →  RepayFlow  repayAmount

reclaim()         RepayFlow                             →  VaultCovenant(totalAssets + repayAmount)

reclaimExpired()  RepayFlow  [after 144 blocks]         →  VaultCovenant(currentTotalAssets + repayAmount)

liquidate()       LendingMarket                         →  SingleSig(keeperPk) fee
                                                        →  RepayFlow  debtAmount
                                                        →  SingleSig(borrowerPk) residual

Security fixes (addressed during review)

  • lending_market borrow: outputs[1].value == borrowAmount
  • lending_market repay: outputs[1].value == repayAmount; outputs[0].value == collateralAmount in partial branch
  • lending_market liquidate: residual >= 0 guard before waterfall
  • supply_flow: supplyAmount <= totalAssets bound; exact per-output value splits proved
  • repay_flow: returnAmount derived from tx.input.current.value (not constructor param)
  • composite_router: weight0 + weight1 == 10000 enforced on-chain

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2

Summary by CodeRabbit

Release Notes

New Features

  • Added vault system with deposit, withdrawal, and yield reporting capabilities
  • Added lending market functionality including borrowing, repayment, liquidation, and credit management
  • Enhanced playground with new contract examples and improved file navigation for subdirectories

Documentation

  • Added comprehensive flow documentation with lifecycle diagrams and system invariants

Tests

  • Added test suite validating new contract functionality and opcode enforcement

claude added 3 commits April 4, 2026 23:45
Three vault contracts:
- vault_covenant.ark: ERC-4626-style recursive vault with PPS monotonicity
- strategy_fragment.ark: independent per-strategy VTXO with keeper attestation
- composite_router.ark: atomic weighted yield settlement (vault + 2 fragments)

Five lending contracts:
- debt_note.ark: transferable borrower debt receipt (DebtToken VTXO)
- credit_note.ark: transferable lender credit receipt (CreditToken VTXO)
- lending_market.ark: full lending market with borrowExogenous/borrowSynthetic,
  repay (partial/full with if/else), keeper-only liquidate (5% fee waterfall),
  transferCredit for secondary market settlement. Synthetic path mints both
  DebtToken and CreditToken as fresh asset group issuances gated by control assets.
- supply_flow.ark: one-shot VaultCovenant → LendingMarket atomic transfer
- repay_flow.ark: one-shot LendingMarket → VaultCovenant yield accretion

All contracts verified with `cargo run -- <file>` and `cargo test`.
Integer division floors noted throughout; systematic LLTV bias analysis documented.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
migrate() placed no constraint on tx.outputs[0].scriptPubKey, making
the covenant custodial to ownerPk — any holder could redirect the VTXO
to an arbitrary script and break the covenant. Removed the function and
the now-dead upgradeRoot constructor parameter from VaultCovenant and
all referencing contracts (composite_router, supply_flow, repay_flow).

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
…ubfolder

- Move all 8 .ark files into examples/vault_lending/
- Update generate_contracts.sh to scan one level of subdirs,
  exporting as <folder>_<name> (e.g. vault_lending_vault_covenant)
- Add vault_lending project to playground/main.js so the stack
  appears as a grouped folder in the playground explorer sidebar

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 4, 2026

Warning

Rate limit exceeded

@tiero has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 42 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 8 minutes and 42 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7fd79eb3-a3cb-4b9d-aab2-221c34a1f416

📥 Commits

Reviewing files that changed from the base of the PR and between 0091b0a and 0a9d361.

📒 Files selected for processing (4)
  • examples/vault_lending/composite_router.ark
  • examples/vault_lending/lending_market.ark
  • examples/vault_lending/strategy_fragment.ark
  • tests/vault_lending_test.rs

Walkthrough

This PR introduces a complete vault lending system comprising six new Ark smart contracts implementing a DeFi infrastructure: VaultCovenant for ERC-4626-style share accounting, StrategyFragment for asset allocation tracking, CompositeRouter for atomic multi-strategy updates, LendingMarket for borrowing and liquidation, and SupplyFlow and RepayFlow for capital movement. Supporting changes include build script enhancements, playground integration, comprehensive test coverage, and flow documentation.

Changes

Cohort / File(s) Summary
Core Vault Lending Contracts
examples/vault_lending/vault_covenant.ark, examples/vault_lending/strategy_fragment.ark, examples/vault_lending/composite_router.ark
Three foundational contracts: VaultCovenant manages share/asset accounting via deposit/withdraw/reportYield functions; StrategyFragment tracks allocations with keeper-signed allocate/report functions; CompositeRouter validates dual keeper signatures and enforces atomic output constraints across vault and strategy successors.
Lending & Flow Contracts
examples/vault_lending/lending_market.ark, examples/vault_lending/supply_flow.ark, examples/vault_lending/repay_flow.ark
Three flow contracts: LendingMarket implements borrow/repay/liquidate/transferCredit with collateral management; SupplyFlow routes assets from vault to lending market; RepayFlow handles cooperative and time-locked unilateral repayment back to vault.
Build & Playground
playground/generate_contracts.sh, playground/main.js
Build script expanded to include examples/**/*.ark subdirectories with prefixed exports; playground project registry extended with new vault_lending project mapping six contract files to generated exports.
Documentation & Tests
examples/vault_lending/FLOWS.md, tests/vault_lending_test.rs
Comprehensive flow documentation with Mermaid diagrams and invariant descriptions; test suite validates compilation, opcode enforcement (OP_CHECKSIGFROMSTACK, OP_INSPECTOUTPUTVALUE, etc.), and ABI signatures across all six contracts with lifecycle smoke test.

Possibly Related PRs

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: introduction of a Morpho-style vault and lending market covenant system with multiple new smart contracts.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/arkade-contracts-ZAi2R

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Remove borrowSynthetic, isSynthetic flag, and the five token-related
constructor params (debtTokenId, creditTokenId, debtTokenCtrlId,
creditTokenCtrlId). Remove if/isSynthetic blocks from repay and
liquidate. Simplify transferCredit to a pure credit-holder update with
no CreditNote output. Remove debt_note.ark and credit_note.ark from the
vault_lending folder — synthetic token mechanics will live in a separate
lending market contract.

lending_market.ark: 15 → 10 constructor params, 5 → 4 functions.
supply_flow.ark: 17 → 12 constructor params.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (2)
playground/generate_contracts.sh (1)

20-39: Make the generator actually recursive.

The banner/header says examples/**/*.ark, but Lines 27-37 only descend one level. A future examples/foo/bar/baz.ark will be silently omitted from contracts.js.

♻️ Suggested recursive walk
 const entries = [];
-
-// 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) });
-      }
-    }
-  }
-}
+
+function collectArkFiles(currentDir, prefix = []) {
+  const dirents = fs.readdirSync(currentDir, { withFileTypes: true })
+    .sort((a, b) => a.name.localeCompare(b.name));
+
+  for (const dirent of dirents) {
+    if (dirent.isDirectory()) {
+      collectArkFiles(path.join(currentDir, dirent.name), [...prefix, dirent.name]);
+      continue;
+    }
+
+    if (dirent.isFile() && dirent.name.endsWith('.ark')) {
+      const base = dirent.name.replace(/\.ark$/, '');
+      entries.push({
+        name: [...prefix, base].join('_'),
+        file: path.join(currentDir, dirent.name),
+      });
+    }
+  }
+}
+
+collectArkFiles(dir);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@playground/generate_contracts.sh` around lines 20 - 39, The current generator
only scans the root and one-level subdirectories (loops over dir and subdir) so
deeper .ark files (e.g., examples/foo/bar/baz.ark) are missed; replace the
two-loop logic with a recursive directory walk (e.g., implement a
walkDir(dirPath, prefix) that uses fs.readdirSync + fs.statSync and calls itself
for directories) that pushes entries with the proper namespace prefix (use
prefix + '_' + baseName for nested folders) into the existing entries array;
update usage of variables dir, subdir, d, f, and name construction to use the
prefix parameter so all nested .ark files are discovered while preserving the
existing header/out generation.
examples/vault_lending/lending_market.ark (1)

74-90: Consider verifying output 1 value equals borrowAmount.

Output 0's value is verified to equal collateral, but output 1 (borrowed funds to borrower) has no value check. While the funds flow may be constrained by the transaction construction, explicitly verifying tx.outputs[1].value == borrowAmount would strengthen the contract's guarantees.

Proposed fix
     // Output 1: borrowed funds flow to borrower
+    require(tx.outputs[1].value == borrowAmount, "borrowed amount mismatch");
     require(
       tx.outputs[1].scriptPubKey == new SingleSig(borrowerPk),
       "borrower output mismatch"
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/vault_lending/lending_market.ark` around lines 74 - 90, Add an
explicit value check for the borrower output: inside the same validation block
that asserts tx.outputs[1].scriptPubKey == new SingleSig(borrowerPk), also
require that tx.outputs[1].value == borrowAmount to ensure the borrowed funds
amount is enforced; update the LendingMarket successor validation area (the
block that checks tx.outputs[0] and tx.outputs[1]) to include this additional
require referencing tx.outputs[1], borrowAmount and SingleSig(borrowerPk).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/vault_lending/composite_router.ark`:
- Around line 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.
- Around line 25-26: Enforce the invariant that weight0 + weight1 == 10000
whenever the router's weights are set or before they are used to compute
newTotalAssets: add an on-chain check (assert/require/abort) that validates
weight0 + weight1 == 10000 in the function or constructor that receives/stores
weight0 and weight1 (and any setter), and also add the same check right before
any logic that uses weight0/weight1 (e.g., where newTotalAssets is computed) so
misconfigured routers cannot be deployed or invoked; apply this to the two
parameter sites shown (the weight0/weight1 declarations around lines 25-26 and
the other occurrence around 43-44).

In `@examples/vault_lending/credit_note.ark`:
- Around line 25-33: The transfer entrypoint currently lets a holder reassign a
CreditNote without updating the authoritative creditHolder in LendingMarket;
replace this by forbidding standalone holder changes and require transfers to be
performed via LendingMarket.transferCredit(): in the transfer function (and/or
remove it) ensure that any output with a different holder must be produced as
the result of a LendingMarket.transferCredit() call by checking the transaction
includes an input/output pair that matches the LendingMarket transfer action
(i.e., verify the tx includes the LendingMarket transfer invocation tied to
marketRef and that the new output CreditNote's holder equals the holder set by
that LendingMarket action), and otherwise reject the transfer so
repayment/liquidation continue to rely on the single authoritative creditHolder
in LendingMarket.

In `@examples/vault_lending/lending_market.ark`:
- Around line 59-72: The oracle signature verifies priceHash but the contract
never ties priceHash to the numeric price, allowing signature replay with
arbitrary price values; compute the canonical hash of the provided price inside
the function (e.g. derive expectedPriceHash from price using the same hashing
scheme used off-chain) and then either require(expectedPriceHash == priceHash)
before calling checkSigFromStack(oracleSig, oraclePk, priceHash) or call
checkSigFromStack(oracleSig, oraclePk, expectedPriceHash) directly so the
oracleSig is validated against the actual price value; update the logic around
priceHash/price and the checkSigFromStack call to enforce this binding (refer to
symbols priceHash, price, oracleSig, oraclePk, and checkSigFromStack).
- Around line 178-182: The repayment output check only verifies
tx.outputs[1].scriptPubKey but not the amount; update the contract to also
validate tx.outputs[1].value equals the expected repayAmount (or use a >=
repayAmount if partial overpayment allowed) by adding a require that combines or
follows the existing SingleSig(creditHolder) check so the credit holder cannot
be sent less than repayAmount; reference tx.outputs[1].value, repayAmount, and
the existing require that checks tx.outputs[1].scriptPubKey == new
SingleSig(creditHolder) when making this change.
- Around line 127-135: The successor construction for LendingMarket in the
borrowSynthetic flow incorrectly reuses the current isSynthetic value; update
the successor to force the synthetic flag to 1 so the new market is always
synthetic. Locate the successor creation that calls new LendingMarket(...) in
the borrowSynthetic handler and replace the isSynthetic argument with the
literal 1 (or true if the code uses booleans) so the successor's isSynthetic
field is explicitly set to synthetic. Ensure you only change the isSynthetic
argument and leave all other LendingMarket constructor parameters (borrowerPk,
oraclePk, vaultKeeperPk, creditHolder, collateral, borrowAmount, lltv,
collateralAssetId, loanAssetId, oracleHash, debtTokenId, creditTokenId,
debtTokenCtrlId, creditTokenCtrlId) unchanged.
- Around line 191-202: The partial-repay branch currently only checks successor
script equality (LendingMarket(...)) but does not ensure the collateral UTXO's
value is preserved; add a require that tx.outputs[0].value == collateralAmount
alongside the existing require in the else branch so the successor output
created by LendingMarket(borrowerPk, oraclePk, vaultKeeperPk, creditHolder,
collateralAmount, newDebtAmount, lltv, isSynthetic, collateralAssetId,
loanAssetId, oracleHash, debtTokenId, creditTokenId, debtTokenCtrlId,
creditTokenCtrlId) must also carry the unchanged collateralAmount to prevent
collateral draining.
- Around line 236-254: The liquidation waterfall can produce a negative residual
when collateralAmount < fee + debtAmount; add an explicit guard in the
liquidation block (before checking outputs) that validates collateralAmount >=
fee + debtAmount (or equivalently that residual >= 0) and fail with a clear
message so tx.outputs[2].value >= residual cannot be bypassed; update the block
around the fee/residual calculation (symbols: collateralAmount, fee, debtAmount,
residual, tx.outputs) to enforce this precondition and prevent negative residual
edge cases.

In `@examples/vault_lending/repay_flow.ark`:
- Around line 31-42: The vault's accounting must tie returnAmount to the actual
funds arriving in the vault: replace the current unconditional value check with
a requirement that the net increase in the vault output equals returnAmount
(i.e. assert tx.outputs[0].value - tx.input.current.value == returnAmount or
equivalently that the asset-group delta flowing into the VaultCovenant equals
returnAmount), keeping the existing newVaultAssets calculation and VaultCovenant
equality (newVaultAssets, totalAssets, returnAmount, tx.outputs[0].value,
tx.input.current.value are the symbols to adjust).

In `@examples/vault_lending/strategy_fragment.ark`:
- Around line 28-36: The keeper signature must be bound to the actual reported
state: compute a canonical report digest inside report (e.g., hashReport =
keccak(strategyId, reportedAmount) with any domain/version prefix) and verify
the signature against that digest instead of an arbitrary caller-supplied
reportHash; then require that the provided reportHash (if kept) equals the
computed digest or simply remove the reportHash parameter and call
checkSigFromStack(keeperSig, keeperPk, hashReport). Update the checkSigFromStack
call in function report and ensure the subsequent successor check still uses the
local reportedAmount and strategyId so the keeper signature authorizes exactly
those values.

In `@examples/vault_lending/supply_flow.ark`:
- Around line 42-68: Ensure supplyAmount is validated and the exact amount
leaves the vault by adding two checks: require supplyAmount >= 0 && supplyAmount
<= totalAssets (prevent negative/overflow and over-withdrawal from
VaultCovenant), and require the outputs reflect the transfer of that exact
amount by asserting tx.outputs[0].value == tx.input.current.value - supplyAmount
(vault lost exactly supplyAmount) and tx.outputs[1].value == supplyAmount
(market received exactly supplyAmount). Reference symbols: supplyAmount,
totalAssets, VaultCovenant / tx.outputs[0].value, tx.outputs[1].value, and
tx.input.current.value.

In `@playground/main.js`:
- Around line 18-29: The vault_lending fixture references eight missing exports
(contracts.vault_lending_vault_covenant,
contracts.vault_lending_strategy_fragment,
contracts.vault_lending_composite_router, contracts.vault_lending_debt_note,
contracts.vault_lending_credit_note, contracts.vault_lending_lending_market,
contracts.vault_lending_supply_flow, contracts.vault_lending_repay_flow) but
playground/contracts.js is absent, causing undefined values at runtime;
regenerate the contract artifacts by running ./playground/generate_contracts.sh
and then ./playground/build.sh to recreate playground/contracts.js, verify that
it exports the above symbols, and commit the generated playground/contracts.js
so vault_lending can import the concrete contract strings.

---

Nitpick comments:
In `@examples/vault_lending/lending_market.ark`:
- Around line 74-90: Add an explicit value check for the borrower output: inside
the same validation block that asserts tx.outputs[1].scriptPubKey == new
SingleSig(borrowerPk), also require that tx.outputs[1].value == borrowAmount to
ensure the borrowed funds amount is enforced; update the LendingMarket successor
validation area (the block that checks tx.outputs[0] and tx.outputs[1]) to
include this additional require referencing tx.outputs[1], borrowAmount and
SingleSig(borrowerPk).

In `@playground/generate_contracts.sh`:
- Around line 20-39: The current generator only scans the root and one-level
subdirectories (loops over dir and subdir) so deeper .ark files (e.g.,
examples/foo/bar/baz.ark) are missed; replace the two-loop logic with a
recursive directory walk (e.g., implement a walkDir(dirPath, prefix) that uses
fs.readdirSync + fs.statSync and calls itself for directories) that pushes
entries with the proper namespace prefix (use prefix + '_' + baseName for nested
folders) into the existing entries array; update usage of variables dir, subdir,
d, f, and name construction to use the prefix parameter so all nested .ark files
are discovered while preserving the existing header/out generation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1aaf364-f87f-4e43-b111-ea61c983d0b7

📥 Commits

Reviewing files that changed from the base of the PR and between cdd3625 and e0e9c5c.

📒 Files selected for processing (10)
  • examples/vault_lending/composite_router.ark
  • examples/vault_lending/credit_note.ark
  • examples/vault_lending/debt_note.ark
  • examples/vault_lending/lending_market.ark
  • examples/vault_lending/repay_flow.ark
  • examples/vault_lending/strategy_fragment.ark
  • examples/vault_lending/supply_flow.ark
  • examples/vault_lending/vault_covenant.ark
  • playground/generate_contracts.sh
  • playground/main.js

Comment on lines +30 to +44
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 newTotalAssets = (reported0 * weight0 + reported1 * weight1) / 10000;
require(newTotalAssets >= currentTotalAssets, "PPS decrease forbidden");
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.

Comment on lines +59 to +72
bytes32 priceHash,
signature oracleSig,
int price,
int borrowAmount,
int collateral
) {
require(checkSig(borrowerSig, borrowerPk), "invalid borrower");
require(checkSigFromStack(oracleSig, oraclePk, priceHash), "invalid oracle");

// 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");
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 | 🟠 Major

Same binding vulnerability: priceHash not verified to commit to price.

Similar to the reportHash issue in vault_covenant.ark, the oracle signs priceHash but the contract doesn't verify that priceHash actually commits to the price value. An attacker could reuse a single oracle signature with arbitrary price values.

Consider computing priceHash from price within the contract or documenting the required off-chain verification.

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

In `@examples/vault_lending/lending_market.ark` around lines 59 - 72, The oracle
signature verifies priceHash but the contract never ties priceHash to the
numeric price, allowing signature replay with arbitrary price values; compute
the canonical hash of the provided price inside the function (e.g. derive
expectedPriceHash from price using the same hashing scheme used off-chain) and
then either require(expectedPriceHash == priceHash) before calling
checkSigFromStack(oracleSig, oraclePk, priceHash) or call
checkSigFromStack(oracleSig, oraclePk, expectedPriceHash) directly so the
oracleSig is validated against the actual price value; update the logic around
priceHash/price and the checkSigFromStack call to enforce this binding (refer to
symbols priceHash, price, oracleSig, oraclePk, and checkSigFromStack).

Comment on lines +28 to +36
function report(signature keeperSig, bytes32 reportHash, int reportedAmount) {
require(reportedAmount > 0, "reported amount must be positive");
require(checkSigFromStack(keeperSig, keeperPk, reportHash), "invalid keeper");
require(
tx.outputs[0].scriptPubKey == new StrategyFragment(
keeperPk, reportedAmount, strategyId
),
"successor mismatch"
);
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

Bind the keeper signature to the reported state.

Line 30 verifies an arbitrary caller-supplied reportHash, but nothing proves that hash commits to strategyId or reportedAmount. Any previously signed keeper message can be replayed here to authorize a different report.

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

In `@examples/vault_lending/strategy_fragment.ark` around lines 28 - 36, The
keeper signature must be bound to the actual reported state: compute a canonical
report digest inside report (e.g., hashReport = keccak(strategyId,
reportedAmount) with any domain/version prefix) and verify the signature against
that digest instead of an arbitrary caller-supplied reportHash; then require
that the provided reportHash (if kept) equals the computed digest or simply
remove the reportHash parameter and call checkSigFromStack(keeperSig, keeperPk,
hashReport). Update the checkSigFromStack call in function report and ensure the
subsequent successor check still uses the local reportedAmount and strategyId so
the keeper signature authorizes exactly those values.

Comment on lines +18 to +29
vault_lending: {
name: 'Vault + Lending',
description: 'ERC-4626 vault with yield strategies, lending market, and DebtToken/CreditToken synthetics',
files: {
'vault_covenant.ark': contracts.vault_lending_vault_covenant,
'strategy_fragment.ark': contracts.vault_lending_strategy_fragment,
'composite_router.ark': contracts.vault_lending_composite_router,
'debt_note.ark': contracts.vault_lending_debt_note,
'credit_note.ark': contracts.vault_lending_credit_note,
'lending_market.ark': contracts.vault_lending_lending_market,
'supply_flow.ark': contracts.vault_lending_supply_flow,
'repay_flow.ark': contracts.vault_lending_repay_flow,
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
from pathlib import Path

wanted = [
    'vault_lending_vault_covenant',
    'vault_lending_strategy_fragment',
    'vault_lending_composite_router',
    'vault_lending_debt_note',
    'vault_lending_credit_note',
    'vault_lending_lending_market',
    'vault_lending_supply_flow',
    'vault_lending_repay_flow',
]

path = Path('playground/contracts.js')
if not path.exists():
    raise SystemExit('playground/contracts.js is missing')

text = path.read_text()
missing = [name for name in wanted if f'export const {name} =' not in text]
if missing:
    raise SystemExit('Missing exports: ' + ', '.join(missing))

print('All vault_lending exports are present in playground/contracts.js')
PY

Repository: arkade-os/compiler

Length of output: 96


Regenerate playground/contracts.js—the file is missing entirely.

The new vault_lending project references eight contracts.vault_lending_* exports (lines 18–29), but playground/contracts.js does not exist in the repository. This will cause all these references to resolve as undefined at runtime.

Regenerate the artifacts using ./playground/generate_contracts.sh and ./playground/build.sh.

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

In `@playground/main.js` around lines 18 - 29, The vault_lending fixture
references eight missing exports (contracts.vault_lending_vault_covenant,
contracts.vault_lending_strategy_fragment,
contracts.vault_lending_composite_router, contracts.vault_lending_debt_note,
contracts.vault_lending_credit_note, contracts.vault_lending_lending_market,
contracts.vault_lending_supply_flow, contracts.vault_lending_repay_flow) but
playground/contracts.js is absent, causing undefined values at runtime;
regenerate the contract artifacts by running ./playground/generate_contracts.sh
and then ./playground/build.sh to recreate playground/contracts.js, verify that
it exports the above symbols, and commit the generated playground/contracts.js
so vault_lending can import the concrete contract strings.

claude added 2 commits April 5, 2026 00:16
- Add examples/vault_lending/lending_pool.ark: permissionless token-layer
  pooled lending market with supply() and borrow() functions using asset
  group token mechanics (supplyTokenId/borrowTokenId + control assets)

- lending_market.ark: add missing value checks
  - borrow: require outputs[1].value == borrowAmount
  - repay: require outputs[1].value == repayAmount; require outputs[0].value
    == collateralAmount in partial repay branch
  - liquidate: guard residual >= 0 before waterfall

- supply_flow.ark: bound supplyAmount <= totalAssets; prove exact per-output
  value splits (vault gets inputVal - supplyAmount, market gets supplyAmount)

- repay_flow.ark: bind returnAmount to tx.input.current.value so vault
  accounting cannot drift from actual settled value

- composite_router.ark: enforce weight0 + weight1 == 10000 on-chain

- playground/main.js: add lending_pool.ark to vault_lending project

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

Playground Preview

A live preview of this PR's playground is available at:
https://arkade-os.github.io/compiler/pr-previews/pr-26/

Built from commit 2e38350e01fe922142cf8a91ffae2cb0f376114d · Workflow run

claude added 3 commits April 5, 2026 00:28
Pooled lending conflicts with the Morpho vault+market design where
VaultCovenant allocates to isolated LendingMarket instances. A pooled
model socialises risk and duplicates what the vault layer already provides.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
creditHolder in LendingMarket changes from pubkey to bytes32 (a script
hash). At supply time, SupplyFlow sets it to the precomputed scriptPubKey
of RepayFlow(keeperPk, ownerPk, totalAssets - supplyAmount, totalShares).
Repayments and liquidation proceeds therefore land directly in the correct
RepayFlow covenant — the keeper cannot redirect them.

RepayFlow drops returnAmount from its constructor; reclaim() derives the
return amount from tx.input.current.value, binding vault accounting to
the actual settled value with no caller input.

transferCredit becomes keeper-only (checkSig against vaultKeeperPk) since
creditHolder is now a script and cannot be checked with checkSig directly.

Add examples/vault_lending/FLOWS.md with Mermaid diagrams of every UTXO
spending path across the vault+lending system.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
@tiero tiero changed the title Add lending market covenant system with vault integration feat: vault + lending market covenant system (Morpho-style) Apr 5, 2026
claude added 2 commits April 5, 2026 00:39
Replace flowchart+subgraph with plain graph LR nodes — avoids the
chunk-loading failure GitHub shows with nested direction declarations.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
If the keeper is unresponsive after the 144-block exit timelock, the LP
(ownerPk) can call reclaimExpired() unilaterally — no keeper co-sign
required. The LP supplies the current vault totalAssets/totalShares
(observable from the vault VTXO scriptPubKey on-chain); the covenant
verifies only that all value flows to a valid VaultCovenant successor.

This gives LPs a self-sovereign exit for deployed capital, matching the
liveness guarantee borrowers already have via the exit clause. Liquidations
remain keeper-gated (unavoidable without keeper oracle access).

Updated FLOWS.md: added diagram 6b (reclaimExpired path) and a liveness
tradeoff table covering all participants.

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
Copy link
Copy Markdown

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

Ark Protocol Review — Vault + Lending Market Covenant System

Reviewed all 6 covenant files, FLOWS.md, and playground integration across all 11 commits.


Architecture — Well-Designed

The Morpho-style isolated-market architecture is a strong fit for UTXO covenants. Key strengths:

  • No shared risk between positions. Each LendingMarket is per-borrower with its own collateral and debt, which avoids the pooled-lending antipattern where one bad position drags down the pool.
  • creditHolder as bytes32 script hash is the right call. Binding repayment destination at supply time (via precomputed RepayFlow scriptPubKey) eliminates an entire class of keeper misdirection attacks.
  • returnAmount = tx.input.current.value in RepayFlow is elegant — vault accounting is bound to actual settled value, not a caller-supplied number.
  • reclaimExpired() after 144 blocks closes the LP liveness gap. Good that this was added in the final commit.

Security Findings

PROTOCOL-CRITICAL — Flagging for human review. This code handles collateral, debt, and fund flows. Several items warrant attention:

1. Oracle price binding is still weak (lending_market.ark, borrow + liquidate)

The oracle signature verifies priceHash via checkSigFromStack(oracleSig, oraclePk, priceHash), but the contract never verifies that priceHash is actually the hash of price. The caller supplies both priceHash and price independently. This means:

  • A borrower could replay a stale oracle signature with a favorable price value
  • A keeper could liquidate with a manipulated price

This was flagged by CodeRabbit but does not appear to be addressed in the current code. The security of the entire collateral ratio check depends on this binding. Without it, the LLTV check is essentially unenforced.

I understand the .ark language may not have an on-chain hash opcode available for this — if binding is enforced off-chain or by the oracle message format, that should be documented explicitly in the contract header.

2. reclaimExpired() — LP supplies arbitrary vault state (repay_flow.ark:30)

The LP provides currentTotalAssets and currentTotalShares with no on-chain verification. The comment says these are "observable from the vault VTXO scriptPubKey on-chain," but the covenant does not verify them. A malicious LP could supply fabricated values to create a VaultCovenant successor with incorrect accounting.

In practice this may be acceptable because:

  • The LP is the ownerPk and would only harm themselves
  • The resulting VaultCovenant still needs valid keeper co-signatures for subsequent operations

But if the vault serves multiple LPs (which the single ownerPk design currently prevents), this would be a critical vulnerability. Worth documenting this as a known trust assumption.

3. Liquidation waterfall — >= vs == (lending_market.ark:135-146)

The liquidation outputs use >= checks (tx.outputs[0].value >= fee, etc.) rather than ==. This means a keeper could overpay themselves on the fee output by reducing the residual output, as long as the residual stays >= the computed residual. Since the computed residual is collateralAmount - fee - debtAmount, and fee is fixed at 5%, the keeper could extract extra value from the transaction's other inputs.

Using == for at least the fee and debt outputs would be stricter. The >= on residual is fine since any surplus goes to the borrower.

4. Self-import pattern (lending_market.ark:6, vault_covenant.ark:12, strategy_fragment.ark:5)

Several contracts import themselves (import "lending_market.ark" inside lending_market.ark). This appears to be needed for recursive covenant construction (new LendingMarket(...) in successor checks). If the compiler handles this correctly, it's fine, but it's unusual and worth confirming it doesn't cause circular dependency issues.

5. StrategyFragment.allocate() — no output value check (strategy_fragment.ark:17-25)

The allocate() function verifies the successor script but does not check tx.outputs[0].value. A keeper could drain value from the fragment UTXO while updating the allocation amount upward.

6. Composite router PPS monotonicity check may be circumventable

CompositeRouter.route() requires newTotalAssets >= currentTotalAssets, but currentTotalAssets is supplied by the caller. A keeper could supply a deflated currentTotalAssets to pass the check while actually decreasing PPS. The vault output is verified against newTotalAssets, but currentTotalAssets is not verified against the actual current vault state.


Test Coverage

No tests are included in this PR. For financial covenant code of this complexity, this is a significant gap. At minimum, I'd expect:

  • Unit tests for each covenant function (happy path + revert cases)
  • Integration tests for the full lifecycle (deposit -> supply -> borrow -> repay -> reclaim -> withdraw)
  • Edge case tests for liquidation waterfall (residual = 0, collateral barely underwater)
  • Oracle price manipulation scenarios

Code Quality

  • Documentation is excellent — FLOWS.md with Mermaid diagrams and the liveness table are very helpful
  • Division/rounding notes in each contract header are good practice
  • The iterative cleanup across commits (removing synthetic paths, removing lending_pool, simplifying credit flow) shows solid design evolution
  • Playground integration is clean

Summary

The architecture is sound and the design decisions (isolated markets, script-hash credit binding, value-derived return amounts) are well-reasoned. However, several security items need attention before this should be merged — particularly the oracle price binding (#1) and the lack of test coverage. The >= vs == in liquidation (#3) and the missing value check in allocate() (#5) are lower priority but should also be addressed.

Recommendation: Request changes. Items #1 (oracle binding) and #3 (liquidation output values) need fixes or explicit documentation of trust assumptions. Tests should be added before merge. This is protocol-critical financial code — bugs here lose real money.

claude added 2 commits April 8, 2026 04:23
…docs, tests

Security fixes:
- lending_market liquidate(): fee and debt outputs changed from >= to ==
  preventing keeper from over-extracting from other tx inputs while keeping
  residual >= (surplus goes to borrower is correct)
- strategy_fragment allocate(): add tx.outputs[0].value == tx.input.current.value
  to prevent keeper draining fragment value while updating allocation amount

Documentation of trust assumptions:
- lending_market: add oracle price binding caveat — checkSigFromStack verifies
  priceHash but language has no on-chain hash opcode to bind priceHash to price;
  deployments must ensure oracle message format commits to a canonical price encoding
- composite_router: document that currentTotalAssets is caller-supplied and the
  PPS monotonicity check can be circumvented by a keeper supplying deflated values

Tests (tests/vault_lending_test.rs — 22 tests):
- Compilation smoke test for all vault+lending contracts
- ABI structure: parameter names, types, counts, function variants
- Security regressions: creditHolder is bytes32, borrower output value checked,
  repay output value checked, liquidation uses exact == amounts, residual guarded,
  allocate preserves value, transferCredit is keeper-only with bytes32 newHolder
- Lifecycle checks: reclaim requires keeperSig, reclaimExpired requires ownerSig
  with currentTotalAssets/currentTotalShares params, both produce VaultCovenant

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
Copy link
Copy Markdown

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review — New Commits (ff7021e, 0091b0a)

Reviewed the two commits pushed in response to my previous review. All six findings were addressed.

Status of Previous Findings

# Finding Resolution
1 Oracle price binding weak Documented as trust assumption in lending_market.ark header (language has no on-chain hash opcode — correct limitation)
2 reclaimExpired() LP supplies arbitrary vault state Documented in repay_flow.ark:63-67 — LP bears accounting risk, single-owner vault limits blast radius
3 Liquidation >= vs == on fee/debt outputs Fixed: lending_market.ark:155,158 now use == fee and == debtAmount. Residual correctly stays >=
4 Self-import pattern Not a bug — needed for recursive covenant construction
5 StrategyFragment.allocate() missing value check Fixed: strategy_fragment.ark:20tx.outputs[0].value == tx.input.current.value
6 Composite router PPS monotonicity circumventable Documented as trust assumption in composite_router.ark:8-12 — not constrainable without spending vault UTXO in same tx

Test Coverage

364 lines of Rust tests added (tests/vault_lending_test.rs). Tests cover:

  • Compilation smoke tests for all 4 contracts
  • ABI structural checks (parameter types, function counts, variants)
  • Security regression tests: OP_EQUAL in liquidation, value introspection in allocate/borrow/repay, OP_CHECKSIGFROMSTACK in oracle paths

These are compiler-level tests (correct scope for this repo). Runtime behavior testing belongs in the VM/integration test suite.

Minor Remaining Notes (non-blocking)

  1. StrategyFragment.report() has no output value check — unlike allocate() which was fixed, report() still doesn't verify tx.outputs[0].value. A keeper could drain value during a report. However, the keeper already controls the attestation, so this is self-sabotage rather than an external attack vector. Non-blocking, but worth noting for completeness.

  2. CompositeRouter.route() doesn't verify output values — scripts are checked but not tx.outputs[N].value for any of the 3 outputs. Value preservation depends on the transaction builder. Acceptable for a routing covenant, but worth documenting.


PROTOCOL-CRITICAL: This code handles collateral, debt, and fund flows. Human review still required before merge. The trust assumptions are now clearly documented, the liquidation waterfall is tightened, and regression tests are in place. Approving the code quality — a human must sign off on the protocol design decisions (oracle trust model, keeper trust for PPS reporting).

…put values

strategy_fragment report(): add tx.outputs[0].value == tx.input.current.value
for consistency with allocate() — a keeper could otherwise drain fragment
value during a report attestation.

composite_router: document that output values are not checked by the covenant;
value conservation across the 3 outputs relies on the Ark round mechanism.

tests: add test_strategy_fragment_report_preserves_value (23 tests total).

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
Copy link
Copy Markdown

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review — Commit 505a162

Reviewed the single commit pushed after my approval. It addresses both non-blocking notes:

Note Fix
report() missing value check strategy_fragment.ark:32tx.outputs[0].value == tx.input.current.value added
CompositeRouter.route() output values undocumented composite_router.ark:14-17 — documented Ark round mechanism enforces value conservation
Test coverage test_strategy_fragment_report_preserves_value() added — checks value introspection in compiled output

No new issues. Approval stands.

⚠️ PROTOCOL-CRITICAL: Human review still required before merge. Trust assumptions around keeper honesty and oracle binding are documented but represent protocol design decisions that need human sign-off.

Copy link
Copy Markdown

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review — Commit 505a162

Reviewed the single commit pushed after my approval. It addresses both non-blocking notes:

Note Fix
report() missing value check strategy_fragment.ark:32tx.outputs[0].value == tx.input.current.value added
CompositeRouter.route() output values undocumented composite_router.ark:14-17 — documented Ark round mechanism enforces value conservation
Test coverage test_strategy_fragment_report_preserves_value() added — checks value introspection in compiled output

No new issues. Approval stands.

⚠️ PROTOCOL-CRITICAL: Human review still required before merge. Trust assumptions around keeper honesty and oracle binding are documented but represent protocol design decisions that need human sign-off.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (1)
examples/vault_lending/lending_market.ark (1)

17-24: ⚠️ Potential issue | 🔴 Critical

priceHash still does not commit to the price used in the checks.

Line 58 and Line 143 verify a signature over priceHash, but Line 62-Line 64 and Line 146-Line 148 use the separately supplied price. Anyone with one valid oracle signature can replay it with a different numeric price and bypass both the borrow collateral-ratio gate and the liquidation-underwater check. This needs a signed preimage the covenant actually derives from the same price witness.

Also applies to: 57-64, 142-148

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

In `@examples/vault_lending/lending_market.ark` around lines 17 - 24, The contract
currently verifies checkSigFromStack(oracleSig, oraclePk, priceHash) but then
uses the separately supplied price value, allowing signature replay with a
different price; fix by deriving the signed commitment on-chain from the
provided price (e.g. compute priceHash = hash(canonical_encode(price)) using the
same canonical encoding the oracle signs) and then call
checkSigFromStack(oracleSig, oraclePk, priceHash) before using price in the
borrow and liquidation checks so the signature binds to the exact numeric price;
ensure you use the same canonical encoding in the oracle off-chain signer and in
the covenant code and update every place that accepts price (the checks that
currently reference price and priceHash) to compute-and-verify rather than
trusting an externally supplied priceHash.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/vault_lending/lending_market.ark`:
- Around line 169-183: transferCredit currently lets the keeper arbitrarily set
newHolder (in transferCredit) with only the keeper signature
(checkSig(keeperSig, vaultKeeperPk)), enabling redirection of
repay()/liquidate() proceeds; fix by requiring authorization from the current
credit beneficiary or a deterministic whitelist: either require an additional
signature from the current creditHolder (e.g., verify a signature over newHolder
from the existing creditHolder) or enforce that tx.outputs[0].scriptPubKey
equals a RepayFlow address deterministically derived from known state (e.g.,
derive allowed RepayFlow from current creditHolder + rotation nonce) instead of
accepting any newHolder value so repay()/liquidate() cannot be redirected by the
keeper alone.
- Around line 66-81: The borrow() covenant currently doesn't tie the input's
surplus to the two created outputs, allowing extra input value to be diverted
and positions to be shrunk; fix by adding explicit value-accounting and an
anti-reborrow guard: require tx.input.current.value (or sum of relevant funding
inputs) equals collateral + borrowAmount (plus any fees) and/or require
sum(tx.outputs[*].value) == tx.input.current.value so no surplus can be routed
elsewhere, and ensure the successor LendingMarket state (LendingMarket(...)
fields like collateralAmount and debtAmount) reflects correct non-decreasing
position invariants (e.g., successor.collateralAmount >= collateral and
successor.debtAmount == existing.debtAmount + borrowAmount or at least not less
than previous values) to prevent rewriting an open position. Use the identifiers
tx.input.current.value, tx.outputs[0].value, tx.outputs[1].value, LendingMarket,
collateral, borrowAmount, collateralAmount and debtAmount to locate and apply
these checks.

In `@tests/vault_lending_test.rs`:
- Around line 482-489: The test test_all_vault_lending_contracts_compile()
currently lists only VaultCovenant, StrategyFragment, RepayFlow, and
LendingMarket, omitting the two new contracts CompositeRouter and SupplyFlow;
update the contracts array in that test to also include ("CompositeRouter",
COMPOSITE_ROUTER_SRC) and ("SupplyFlow", SUPPLY_FLOW_SRC) so the smoke test
compiles all six vault-lending contracts and covers the new integration
surfaces.
- Around line 5-126: The test currently uses hand-maintained string literals
(VAULT_COVENANT_SRC, STRATEGY_FRAGMENT_SRC, REPAY_FLOW_SRC, LENDING_MARKET_SRC)
that can drift from the real example contracts; change each constant to load the
real example source with include_str! for the corresponding example files so the
tests compile the actual contracts (replace the literal values assigned to
VAULT_COVENANT_SRC, STRATEGY_FRAGMENT_SRC, REPAY_FLOW_SRC and LENDING_MARKET_SRC
with include_str! calls pointing at the matching example .ark files), ensuring
ABI/opcode assertions remain tied to the real sources.
- Around line 394-457: The current ASM assertions in tests
test_lending_market_borrow_checks_borrower_output_value,
test_lending_market_repay_checks_repay_output_value,
test_lending_market_liquidate_uses_exact_fee_and_debt_amounts, and
test_lending_market_liquidate_guards_residual are too permissive; replace the
loose .any(op == ...) checks with concrete requirement checks via
AbiFunction.require (from src/models/mod.rs:85-105) or assert exact opcode
sequences/counts (e.g., require OP_INSPECTOUTPUTVALUE followed immediately by
OP_EQUAL for borrow/repay, require two distinct OP_EQUAL outputs for liquidate
fees/debt, and require the specific OP_0 + OP_GREATERTHANOREQUAL sequence for
residual) so each test asserts the precise opcode pattern expected rather than
any occurrence.

---

Duplicate comments:
In `@examples/vault_lending/lending_market.ark`:
- Around line 17-24: The contract currently verifies
checkSigFromStack(oracleSig, oraclePk, priceHash) but then uses the separately
supplied price value, allowing signature replay with a different price; fix by
deriving the signed commitment on-chain from the provided price (e.g. compute
priceHash = hash(canonical_encode(price)) using the same canonical encoding the
oracle signs) and then call checkSigFromStack(oracleSig, oraclePk, priceHash)
before using price in the borrow and liquidation checks so the signature binds
to the exact numeric price; ensure you use the same canonical encoding in the
oracle off-chain signer and in the covenant code and update every place that
accepts price (the checks that currently reference price and priceHash) to
compute-and-verify rather than trusting an externally supplied priceHash.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3f9ac722-807d-4c20-9c25-f9800f3da1d4

📥 Commits

Reviewing files that changed from the base of the PR and between e0e9c5c and 0091b0a.

📒 Files selected for processing (8)
  • examples/vault_lending/FLOWS.md
  • examples/vault_lending/composite_router.ark
  • examples/vault_lending/lending_market.ark
  • examples/vault_lending/repay_flow.ark
  • examples/vault_lending/strategy_fragment.ark
  • examples/vault_lending/supply_flow.ark
  • playground/main.js
  • tests/vault_lending_test.rs
✅ Files skipped from review due to trivial changes (3)
  • playground/main.js
  • examples/vault_lending/FLOWS.md
  • examples/vault_lending/composite_router.ark
🚧 Files skipped from review as they are similar to previous changes (3)
  • examples/vault_lending/supply_flow.ark
  • examples/vault_lending/strategy_fragment.ark
  • examples/vault_lending/repay_flow.ark

…quality

lending_market borrow():
- Add require(debtAmount == 0) + require(collateralAmount == 0) to prevent
  re-borrowing over an existing position (anti-reborrow guard)
- Add require(tx.input.current.value == borrowAmount) to enforce value
  conservation: borrower must draw the full available liquidity, no surplus
  can be routed to arbitrary outputs

lending_market transferCredit():
- Add require(debtAmount == 0) so creditHolder is immutable once a position
  is open; keeper can only rotate the RepayFlow target before the borrower
  draws, preventing post-borrow redirection of repay()/liquidate() proceeds

tests/vault_lending_test.rs:
- Switch all source constants from hand-maintained literals to include_str!
  pointing at the real example files — tests now compile the actual contracts
- Add CompositeRouter and SupplyFlow to smoke test (was missing 2 of 6)
- Add asm_contains_sequence() helper for precise opcode subsequence assertions
- Replace loose .any(op == ...) checks with exact ASM window matching:
  borrow reborrow guards, value conservation, borrower output value,
  repay output value, liquidation exact fee/debt, residual guard,
  transferCredit open-position block
- 25 tests total (was 23)

https://claude.ai/code/session_01SatYwcP7Swjvrfy4w49ad2
Copy link
Copy Markdown

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review — Commit 0a9d361

Reviewed the single commit since my last approval (505a1620a9d361). This addresses CodeRabbit findings with three protocol-critical fixes in lending_market.ark and a significant test quality upgrade.

Contract Changes (lending_market.ark)

1. Anti-reborrow guard in borrow() — ✅ Correct & critical

require(debtAmount == 0, "position already open");
require(collateralAmount == 0, "position already has collateral");

Without this, a borrower could call borrow() on an already-open position, resetting collateral and debtAmount to new values. The collateralAmount == 0 check is technically redundant (can't have collateral without debt in normal flow) but is good defense-in-depth.

2. Value conservation in borrow() — ✅ Correct & critical

require(tx.input.current.value == borrowAmount, "borrow amount must equal available liquidity");

Without this, if the LendingMarket UTXO held more sats than borrowAmount, the excess would be lost as miner fees (outputs only account for collateral + borrowAmount). Good fix.

3. transferCredit() blocked on open positions — ✅ Correct & critical

require(debtAmount == 0, "cannot rotate credit on open position");

This closes a keeper rug vector: without it, a malicious keeper could redirect repay()/liquidate() proceeds to a different RepayFlow script after the borrower has already drawn funds. Now creditHolder is immutable once borrow() is called.

Test Changes (vault_lending_test.rs)

include_str! refactor — Excellent. Tests now compile the actual contract files instead of hand-maintained inline copies. This eliminates the snapshot-drift problem where tests could pass against stale copies while the real contracts have changed.

Sequence-based assertions (asm_contains_sequence) — Much stronger than the previous any(|op| op.contains(...)) pattern. Verifying opcode sequences (e.g. ["<debtAmount>", "0", "OP_EQUAL"]) catches incorrect operand ordering that single-opcode checks would miss.

New tests for reborrow guard, value conservation, and transferCredit lockdown — all properly verify the new contract invariants at the ASM level.

Smoke test expanded to include CompositeRouter and SupplyFlow — good, all 6 contracts now covered.

Verdict

All three contract fixes address real attack vectors. Tests are materially stronger. No issues found. Approved.

⚠️ Protocol-critical reminder: This PR modifies lending market covenant logic (collateral management, debt lifecycle, credit routing). Human sign-off required before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants