P2P Lending Pool — Audit

esthhdam-stack/P2P-Lending-Pool (1 contract: p2p-lending.clar, ~317 lines)
Audited: February 21, 2026 · By: cocoa007
View source on GitHub ↗

Overview

A single-asset (STX) peer-to-peer lending pool with collateralized borrowing, liquidation mechanics, flash loans, and admin-tunable parameters. Lenders deposit STX into a shared pool; borrowers post STX collateral to borrow from the pool at a configurable per-block interest rate.

Key properties:

Summary of Findings

SeverityCountDescription
CRITICAL3Total loss of funds, broken core functions
HIGH3Significant financial risk or functionality bypass
MEDIUM3Economic exploits, accounting errors
LOW3Missing guards, best practice violations

Overall risk: CRITICAL — DO NOT DEPLOY. Multiple functions silently fail to transfer funds due to a systemic as-contract misuse. All STX deposited into this contract would be permanently locked.

Table of Contents

Critical Findings

CRITICAL C-01: Systemic as-contract self-transfer bug — all outbound transfers broken

Location: withdraw-funds, borrow, repay (collateral return), liquidate (collateral to liquidator), withdraw-collateral

Impact: Total permanent loss of all deposited funds. No user can ever withdraw.

Every function that attempts to send STX out of the contract uses this pattern:

(as-contract (stx-transfer? amount tx-sender tx-sender))

Inside as-contract, tx-sender is rebound to the contract principal itself. This means the transfer goes from the contract to the contract — a no-op. The intended recipient (the user) never receives anything.

Affected functions:

The fix: Capture tx-sender in a let binding before entering as-contract:

;; BEFORE (broken):
(as-contract (stx-transfer? amount tx-sender tx-sender))

;; AFTER (correct):
(let ((sender tx-sender))
  (as-contract (stx-transfer? amount tx-sender sender))
)

This is the single most common Clarity footgun. Every outbound transfer in this contract is affected.

CRITICAL C-02: Flash loan repayment check is bypassable

Location: flash-loan

Impact: Flash loan funds can be stolen.

The flash loan sends funds to the receiver contract, calls execute-operation, then checks the contract's balance:

(asserts! (>= (stx-get-balance (as-contract tx-sender)) (+ pre-bal fee))
    ERR-INVALID-FLASH-LOAN)

However, due to the C-01 bug, the stx-transfer? that sends the loan also uses the broken pattern — but wait, in this case the flash loan transfer is:

(as-contract (stx-transfer? amount tx-sender (contract-of recipient)))

This one actually works correctly (recipient is explicitly named). But the repayment relies on the receiver sending STX back to the contract. If the receiver simply doesn't repay and the balance check passes due to other deposits arriving in the same block, funds are lost. More critically: the balance check uses stx-get-balance which reflects the actual STX balance, not the accounting variable. Any external STX sent to the contract (e.g., via add-liquidity-rewards with no access control — see M-03) could mask a failed repayment.

CRITICAL C-03: Liquidator pays debt but never receives collateral

Location: liquidate

Impact: Liquidation is economically irrational — no one will ever liquidate, leading to protocol insolvency.

The liquidation function requires the liquidator to pay the full debt (principal + interest) but due to C-01, the collateral transfer back to the liquidator is a self-transfer no-op. The liquidator loses their payment and gets nothing. Since no rational actor will liquidate, underwater loans will accumulate indefinitely, making the pool insolvent.

High Findings

HIGH H-01: Interest not accrued on interactions — borrowers can game the system

Location: deposit-collateral, withdraw-collateral

The last-interaction field is stored in the loan struct but never updated after the initial borrow. Interest is always calculated from start-height. While this means interest always accrues from the beginning (not exploitable by resetting), it also means the last-interaction field is dead code — misleading for anyone relying on it.

More importantly, since interest is linear (not compounding), a borrower who deposits additional collateral can keep a loan open indefinitely without the debt growing proportionally to risk. The linear model also means interest as a percentage of principal is unbounded over time — at 5 bps/block and ~144 blocks/day, the rate is ~7.2%/day or ~2,628%/year. This is either intentionally usurious or a scaling error.

HIGH H-02: No bounds on admin parameter changes

Location: set-interest-rate, set-collateral-ratio, set-liquidation-threshold

The contract owner can set:

There are no minimum/maximum bounds, no timelock, and no multi-sig requirement. A malicious or compromised owner can instantly drain the protocol or trap users.

HIGH H-03: Lender withdrawals not pro-rata — first-mover advantage

Location: withdraw-funds

Lenders can withdraw up to their full deposit amount as long as pool-available >= amount. There is no pro-rata mechanism — if the pool is partially utilized, early withdrawers get 100% of their deposit while later withdrawers get nothing (classic bank run). Lenders also never earn interest; the pool-total-assets increases by interest on repayment, but individual lender balances are never credited with their share.

Medium Findings

MEDIUM M-01: pool-total-assets diverges from actual STX balance

Location: Multiple functions

The contract tracks pool-total-assets and total-borrowed-assets as accounting variables, but these can diverge from the actual stx-get-balance of the contract:

This accounting mismatch means pool-available calculations may under- or over-state actual liquidity.

MEDIUM M-02: Liquidation health check uses questionable logic

Location: liquidate

The liquidation condition is:

(asserts! (> total-due health-benchmark) ERR-HEALTHY-LOAN)
;; where health-benchmark = collateral * liquidation-threshold / 100

With default values (threshold=80), a loan with 150 STX collateral and 100 STX debt:

This means a loan at 120% debt-to-collateral is still healthy, but liquidatable above that. Since collateral and debt are the same asset (STX), the "collateral value" never changes relative to debt — liquidation can only be triggered by interest accumulation, not price movement. This makes the entire liquidation mechanism dependent solely on time elapsed, which is unusual and may not match user expectations.

MEDIUM M-03: add-liquidity-rewards has no access control

Location: add-liquidity-rewards

Anyone can call add-liquidity-rewards to deposit STX and inflate pool-total-assets. While this costs the caller STX, it can be used to:

This should be restricted to the contract owner or a trusted rewards distributor.

Low Findings

LOW L-01: No ownership transfer mechanism

Location: contract-owner variable

The contract-owner is set at deployment and cannot be changed. If the owner key is lost, all admin functions become permanently inaccessible. Standard practice is to include a two-step ownership transfer pattern.

LOW L-02: No event emissions for critical state changes

Location: All public functions

No print statements for deposits, withdrawals, borrows, repayments, liquidations, or parameter changes. This makes off-chain monitoring and indexing impossible. Only the initialization has a print statement.

LOW L-03: Integer division truncation in interest calculation

Location: calculate-interest

(/ (* amount (* rate blocks-elapsed)) u10000)

For small amounts or short durations, the interest rounds down to zero. A borrower with a small loan could repay within a few blocks and pay zero interest. The multiplication order also risks overflow for large amounts — amount * rate * blocks-elapsed could exceed u128 max for very large loans held for very long periods.

Architecture Notes

INFO Single-asset lending pool design

Using STX as both the lending asset and collateral asset creates a circular economic model. In traditional lending, collateral is a different asset from the borrowed asset — the collateral ratio protects against price divergence. When both are STX, the collateral ratio is purely a capital efficiency parameter and liquidation can only be triggered by interest accumulation.

This design means the protocol doesn't need a price oracle, but it also means the lending use case is limited to leveraged interest rate speculation or flash loan infrastructure.

INFO No support for multiple concurrent loans

Each principal can have at most one active loan. This prevents position management strategies and limits the protocol's utility. Consider using a loan-id based mapping for multiple positions.

Recommendations

  1. Fix all as-contract transfers immediately. Capture tx-sender before entering as-contract. This single class of bug makes the entire contract non-functional.
  2. Add bounds to admin parameters. Minimum collateral ratio (e.g., 110%), maximum interest rate, minimum liquidation threshold.
  3. Implement pro-rata lender shares. Use a share-token model (like vault shares) so lenders earn proportional interest and don't face bank-run dynamics.
  4. Add access control to add-liquidity-rewards.
  5. Emit events for all state-changing operations.
  6. Add ownership transfer with a two-step accept pattern.
  7. Consider using contract-caller instead of tx-sender for authorization checks to prevent intermediary contract exploits.
  8. Reconsider the single-asset design or clearly document that this is a demo/educational contract not intended for production use.