Zharta logo

Zharta P2P Lending Protocol Security Review Report

February 2026

Overview

This report covers the security review for Zharta. This audit covered updated contracts of Zharta’s peer-to-peer lending protocol for ERC20 tokens. Our security assessment was a full review of the new code, spanning a total of 3 days. During our review, we identified 2 High severity vulnerabilities that could have resulted in a loss of principal assets. We also identified several minor vulnerabilities and code optimizations. All reported issues were fixed by the development team and subsequently verified by us.  We can confidently say that the overall security and code quality have increased after completion of our audit. 

Scope

The analyzed resources are located on:

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/v1/P2PLendingVaultSecuritize.vy

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/v1/P2PLendingSecuritizeBase.vy

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/v1/P2PLendingSecuritizeErc20.vy

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/v1/P2PLendingSecuritizeLiquidation.vy

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/v1/P2PLendingSecuritizeRefinance.vy

https://github.com/Zharta/lending-erc20-protocol/blob/cd0aebc70e83a5b8d7cc04434631851c935887b1/contracts/SecuritizeProxy.vy

The issues described in this report were fixed in the following commits:

https://github.com/Zharta/lending-erc20-protocol/tree/a51890e6050dc9537c5fe42c8be63a7d243f365b

https://github.com/Zharta/lending-erc20-protocol/tree/44ac867748647dd1075cd28e3439252fe7036f7b

https://github.com/Zharta/lending-erc20-protocol/tree/9cdfb8147fce53a0315a54c15871d6a38d798a8b

https://github.com/Zharta/lending-erc20-protocol/tree/d7de0216fdf9f304db3150cbb28f36e816c7bf5e

https://github.com/Zharta/lending-erc20-protocol/tree/f2c03b5249d78dbd5bf19808af9bcfc6f3729c29

https://github.com/Zharta/lending-erc20-protocol/tree/1dc09099a50d839e87355edc3e1eaa290725a74c

Summary

Total number of findings
5

Weaknesses

This section contains the list of discovered weaknesses.

ZHAR3-2 | LOAN LIQUIDATION UNDERFLOW COULD LEAD TO FROZEN ASSETS

Severity:

High

Status:

Fixed

Path:

contracts/v1/P2PLendingSecuritizeLiquidation.vy

Description:

In the P2PLendingSecuritizeLiquidation::liquidate_loan() function, if the loan has been redeemed, the redemption payment token will be split into in_vault_payment_token and liquidation_fee:

if liquidation_fee <= in_vault_payment_token:
    in_vault_payment_token -= liquidation_fee
else:
    liquidation_fee_collateral = min(in_vault_collateral, (liquidation_fee - in_vault_payment_token) * rate.denominator * collateral_token_decimals // (rate.numerator * payment_token_decimals))
    liquidation_fee = in_vault_payment_token
    in_vault_payment_token = 0

However, after that, there is no check to ensure that outstanding_debt is greater than in_vault_payment_token + liquidation_fee (the total redeemed payment tokens) before calling _received_funds.

if in_vault_payment_token >= outstanding_debt:
    ...
    
elif in_vault_payment_token + remaining_collateral_value >= outstanding_debt:
# if remaining_collateral_value >= outstanding_debt:
    if liquidator != loan.lender:
        base._receive_funds(liquidator, outstanding_debt - in_vault_payment_token - liquidation_fee, payment_token)
        base._send_funds(loan.lender, outstanding_debt - protocol_settlement_fee_amount, payment_token)
        base._send_funds(base.protocol_wallet, protocol_settlement_fee_amount, payment_token)
        base._reduce_commited_liquidity(loan.lender, loan.offer_tracing_id, outstanding_debt)

Therefore, the liquidate_loan() function could revert due to an underflow in the case where in_vault_payment_token < outstanding_debt but in_vault_payment_token + liquidation_fee > outstanding_debt.

This issue can cause the loan to become permanently unliquidatable, resulting in permanently frozen funds. It could occur due to an innocent mistake by the borrower when redeeming an incorrect amount, or be exploited by a malicious borrower to prevent liquidation.

Remediation:

Separately handle the edge case so that the underflow cannot occur.

ZHAR3-6 | LENDERS ARE AT RISK OF LOSING THEIR FUNDS IF THEY LIQUIDATE THEIR LOAN WHICH WAS PREVIOUSLY REDEEMED

Severity:

High

Status:

Fixed

Path:

contracts/v1/P2PLendingSecuritizeLiquidation.vy

Description:

In the P2PLendingSecuritizeLiquidation contract, the liquidate_loan() function allows the liquidation of a redeemed loan. To do this, it first calls the withdraw_funds() function from the vault to pull the redemption results (payment tokens) into the lending contract in order to continue the liquidation process.

extcall _vault.withdraw_funds(payment_token, in_vault_payment_token + liquidation_fee)

However, if the liquidator is the lender of the loan, the liquidation logic in the P2PLendingSecuritizeLiquidation contract remains similar to that of the P2PLendingVaultedLiquidation contract. This means that the liquidate_loan() function does not consider the redemption payment tokens received from the vault, causing the lender to be unable to receive those funds.

elif in_vault_payment_token + remaining_collateral_value >= outstanding_debt:
# if remaining_collateral_value >= outstanding_debt:
    if liquidator != loan.lender:
        base._receive_funds(liquidator, outstanding_debt - in_vault_payment_token - liquidation_fee, payment_token)
        base._send_funds(loan.lender, outstanding_debt - protocol_settlement_fee_amount, payment_token)
        base._send_funds(base.protocol_wallet, protocol_settlement_fee_amount, payment_token)
        base._reduce_commited_liquidity(loan.lender, loan.offer_tracing_id, outstanding_debt)
    else:
        base._transfer_funds(liquidator, base.protocol_wallet, protocol_settlement_fee_amount, payment_token)
        base._send_funds(liquidator, liquidation_fee, payment_token)
 
    base._send_collateral(liquidator, collateral_for_debt + liquidation_fee_collateral, _vault)
    if remaining_collateral > collateral_for_debt + liquidation_fee_collateral:
        base._send_collateral(loan.borrower, remaining_collateral - collateral_for_debt - liquidation_fee_collateral, _vault)
else:
    if liquidator != loan.lender:
        base._receive_funds(liquidator, remaining_collateral_value - in_vault_payment_token - liquidation_fee, payment_token)
        base._send_funds(loan.lender, remaining_collateral_value - protocol_settlement_fee_amount, payment_token)
        base._send_funds(base.protocol_wallet, protocol_settlement_fee_amount, payment_token)
        base._reduce_commited_liquidity(loan.lender, loan.offer_tracing_id, remaining_collateral_value)
    else:
        base._transfer_funds(liquidator, base.protocol_wallet, protocol_settlement_fee_amount, payment_token)
 
    base._send_funds(liquidator, liquidation_fee, payment_token)
    base._send_collateral(liquidator, remaining_collateral, _vault)

As shown in the code above, if the liquidator is the lender of the loan, the liquidation process does not send the in_vault_payment_token amount of funds. This causes the lender to lose their legitimate payment funds, while those funds remain in the lending contract.

Remediation:

In case of the liquidator is the lender of the loan, redeemed payment tokens (in_vault_payment_token) should also be sent to liquidator.

ZHAR3-3 | THE LTV CALCULATION FOR LIQUIDATION DOES NOT EXCLUDE THE REDEMPTION COLLATERAL AND ASSETS

Severity:

Medium

Status:

Fixed

Path:

contracts/v1/P2PLendingSecuritizeLiquidation.vy#L175-L202

Description:

The liquidate_loan() function allows a loan that was previously redeemed to be liquidated if its LTV is greater than liquidation_ltv (the liquidation threshold).

However, the LTV calculation still uses loan.collateral_amount as the current collateral and loan.amount + current_interest as the current debt, even though these values are not updated after redemption. After redemption, a portion of the collateral is converted into payment tokens at a previous rate, which may differ from the current rate.

As a result, a loan that should be healthy after redemption could still be liquidated because the calculation relies on the original collateral and debt amounts. Even if the redemption payment tokens are sufficient to fully cover the debt, the loan could still be liquidated, causing a loss to the borrower.

if not base._is_loan_defaulted(loan):
 
    current_interest = base._compute_settlement_interest(loan)
    convertion_rate: base.UInt256Rational = base._get_oracle_rate(oracle_addr, oracle_reverse)
    current_ltv: uint256 = base._compute_ltv(loan.collateral_amount, loan.amount + current_interest, convertion_rate, payment_token_decimals, collateral_token_decimals)
    assert loan.liquidation_ltv > 0, "not defaulted, partial disabled"
    assert current_ltv >= loan.liquidation_ltv, "not defaulted, ltv lt partial"
 
    if not is_loan_redeemed:
        principal_written_off: uint256 = 0
        collateral_claimed: uint256 = 0
        liquidation_fee: uint256 = 0
        principal_written_off, collateral_claimed, liquidation_fee = base._compute_partial_liquidation(
            loan.collateral_amount,
            loan.amount + current_interest,
            loan.initial_ltv,
            loan.partial_liquidation_fee,
            convertion_rate,
            payment_token_decimals,
            collateral_token_decimals
        )
 
        assert principal_written_off >= loan.amount + current_interest, "not defaulted, partial possible"
 
else:
    current_interest = self._compute_liquidation_interest(loan)

Remediation:

The liquidate_loan() function should exclude redemption collateral and assets from the LTV calculation.

ZHAR3-5 | THE BUY FUNCTION IN P2PLENDINGVAULTSECURITIZE MAY BE DENIED DUE TO A ZERO-AMOUNT TRANSFER

Severity:

Low

Status:

Fixed

Path:

contracts/v1/P2PLendingVaultSecuritize.vy#L186-L212

Description:

The P2PLendingVaultSecuritize::buy() function pulls a stable_coin_amount of payment tokens from msg.sender, then swaps that amount for collateral tokens (DS tokens). Using the SecuritizeSwap::swap() function, it always swaps the full stable_coin_amount of payment tokens for DS tokens. Therefore, the remaining_balance is always equal to the initial_balance.

initial_balance: uint256 = staticcall IERC20(payment_token).balanceOf(self)
extcall IERC20(payment_token).transferFrom(msg.sender, self, stable_coin_amount)
extcall IERC20(payment_token).approve(securitize_swap_contract, stable_coin_amount)
extcall SecuritizeSwap(securitize_swap_contract).swap(stable_coin_amount, min_ds_token_amount)
 
self.pending_transfers[self.owner] += ds_token_amount.ds_token_amount
self.pending_transfers_total += ds_token_amount.ds_token_amount
 
remaining_balance: uint256 = staticcall IERC20(payment_token).balanceOf(self)
extcall IERC20(payment_token).transfer(msg.sender, remaining_balance - initial_balance)

As a result, the transfer of remaining_balance - initial_balance is a zero-amount transfer. It may revert if the payment token does not allow zero-value transfers, causing the buy() function to be denied. Some ERC20 tokens do not allow zero-value transfers, such as BNB.

@external
def buy(payment_token: address, min_ds_token_amount: uint256, stable_coin_amount: uint256):
    """
    @notice Buy DS tokens using stable coins via the SecuritizeSwap contract.
    @dev Approves the SecuritizeSwap contract to spend stable coins and executes the buy operation.
    @param min_ds_token_amount The minimum amount of DS tokens to receive.
    @param stable_coin_amount The amount of stable coins to spend.
    """
    assert self._check_user(self.owner), "unauthorized"
 
    securitize_swap_contract: address = staticcall SecuritizeDSToken(self.token).getDSService(1<<14)
 
    ds_token_amount: DsTokenAmountResult = staticcall SecuritizeSwap(securitize_swap_contract).calculateDsTokenAmount(stable_coin_amount)
    assert ds_token_amount.ds_token_amount >= min_ds_token_amount, "ds token amount lt min"
 
    initial_balance: uint256 = staticcall IERC20(payment_token).balanceOf(self)
    extcall IERC20(payment_token).transferFrom(msg.sender, self, stable_coin_amount)
    extcall IERC20(payment_token).approve(securitize_swap_contract, stable_coin_amount)
    extcall SecuritizeSwap(securitize_swap_contract).swap(stable_coin_amount, min_ds_token_amount)
 
    self.pending_transfers[self.owner] += ds_token_amount.ds_token_amount
    self.pending_transfers_total += ds_token_amount.ds_token_amount
 
    remaining_balance: uint256 = staticcall IERC20(payment_token).balanceOf(self)
    extcall IERC20(payment_token).transfer(msg.sender, remaining_balance - initial_balance)

Remediation:

The last transfer action in the buy() function should be removed.

ZHAR3-7 | ORACLE MAY RARELY RETURN 0, ENABLING ZERO-COLLATERAL LOANS

Severity:

Low

Status:

Fixed

Path:

P2PLendingSecuritizeBase.vy#L418-L432

Description:

External price oracles such as Chainlink are designed to return positive price values under normal operation. However, due to round finalization delays, node consensus failures, feed interruptions, stale round reads, or temporary oracle outages, there exists a non-zero probability that latestRoundData().answer may return 0.

Because _get_oracle_rate() does not validate that answer > 0, a rare oracle malfunction returning 0 can propagate into the LTV computation. When oracle_reverse=True, this results in:

  • denominator = answer = 0
  • LTV calculation evaluating to 0
  • Loan approval despite zero effective collateral value This creates a scenario where a transient oracle anomaly can lead to fully uncollateralized loans and total lender fund loss.
# P2PLendingSecuritizeBase.vy:418-432
@view
@internal
def _get_oracle_rate(oracle_addr: address, oracle_reverse: bool) -> UInt256Rational:
    if oracle_reverse:
        return UInt256Rational(
            numerator=10 ** convert(staticcall AggregatorV3Interface(oracle_addr).decimals(), uint256),
            denominator=convert(
                (staticcall AggregatorV3Interface(oracle_addr).latestRoundData()).answer,
                uint256
            )
            # No validation that answer > 0
        )
    else:
        return UInt256Rational(
            numerator=convert(
                (staticcall AggregatorV3Interface(oracle_addr).latestRoundData()).answer,
                uint256
            ),
            denominator=10 ** convert(staticcall AggregatorV3Interface(oracle_addr).decimals(), uint256)
        )

Scenario

Although oracle failures returning 0 are rare, they are not impossible in distributed oracle systems. If such an event occurs while:

  • oracle_reverse=True

  • A borrower initiates a loan

  • LTV validation is performed Then:

  • answer = 0

  • LTV = 0

  • Loan is approved unconditionally The borrower receives funds while posting effectively worthless collateral.

Remediation:

Add strict validation before using oracle data:

assert answer > 0, "Invalid oracle price"

Table of contents