esthhdam-stack/P2P-Lending-Pool (1 contract: p2p-lending.clar, ~317 lines)
Audited: February 21, 2026 · By: cocoa007
View source on GitHub ↗
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:
| Severity | Count | Description |
|---|---|---|
| CRITICAL | 3 | Total loss of funds, broken core functions |
| HIGH | 3 | Significant financial risk or functionality bypass |
| MEDIUM | 3 | Economic exploits, accounting errors |
| LOW | 3 | Missing 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.
as-contract self-transfer bug — all outbound transfers brokenpool-total-assets accounting diverges from actual STX balanceadd-liquidity-rewards has no access controlas-contract self-transfer bug — all outbound transfers brokenLocation: 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:
withdraw-funds — lenders can never withdraw depositsborrow — borrowers post collateral but never receive the loanrepay — borrowers repay debt but collateral is never returnedliquidate — liquidator pays debt but collateral stays in contractwithdraw-collateral — collateral withdrawal silently failsThe 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.
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.
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.
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.
Location: set-interest-rate, set-collateral-ratio, set-liquidation-threshold
The contract owner can set:
interest-rate-per-block to any value (including 0 or extremely high)collateral-ratio to 0 (allowing uncollateralized borrowing) or to an extreme valueliquidation-threshold to 0 (making all loans instantly liquidatable) or 100+ (making liquidation impossible)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.
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.
pool-total-assets diverges from actual STX balanceLocation: 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:
pool-total-assetsadd-liquidity-rewards increases pool-total-assets but these rewards are not attributable to specific lendersThis accounting mismatch means pool-available calculations may under- or over-state actual liquidity.
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:
health-benchmark = 150 * 80 / 100 = 120total-due > 120This 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.
add-liquidity-rewards has no access controlLocation: 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.
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.
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.
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.
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.
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.
as-contract transfers immediately. Capture tx-sender before entering as-contract. This single class of bug makes the entire contract non-functional.add-liquidity-rewards.contract-caller instead of tx-sender for authorization checks to prevent intermediary contract exploits.