DEV Community

Daniel
Daniel

Posted on

How Repay Clamping Created Permanent Dust Debt in Rujira Ghost Vault

A repayment path should never trap a borrower between two rounding rules.

If the borrower sends enough tokens to cover the final debt share, the protocol should burn that share and let the debt reach zero.

Rujira Ghost Vault could fail that basic closure invariant.

The vault represented debt through shares. After interest increased debt_pool.size without increasing debt_pool.shares, the token value of one debt share could become noninteger.

That accounting state was expected.

The failure came from how repayment handled it:

  1. The vault converted the borrower's shares into token debt using floor rounding

  2. It clamped the internal repayment amount to that floored debt

  3. It converted the clamped token amount back into shares using another floor

  4. The result could be zero shares

  5. SharePool::leave(0) reverted

For a borrower holding one final debt share, burning that share required the ceiling of its token value.

The repayment path only allowed the floor of that value to reach the share-burn calculation.

Sending more did not help because the extra amount was removed by the clamp before state.repay() was called.

The result was a residual debt share that the borrower could not eliminate through the normal repayment path.

This was another of my three Medium findings in the Rujira Code4rena contest. Across the contest, I had three High and three Medium findings and earned $252.39 in total.

The closure invariant

A debt-share system should preserve this rule:

A borrower who supplies enough tokens to cover all remaining debt shares must be able to reduce the share balance to zero.

Let:

S = debt_pool.size

T = debt_pool.shares

b = borrower debt shares
Enter fullscreen mode Exit fullscreen mode

The vault reported the borrower's debt as:

floor(
    S × b
    ÷ T
)
Enter fullscreen mode Exit fullscreen mode

Repayment converted a token amount back into shares as:

floor(
    repayment amount
    × T
    ÷ S
)
Enter fullscreen mode Exit fullscreen mode

When S / T was an integer, those conversions aligned cleanly.

When S / T was noninteger, they did not.

The amount reported as debt could be smaller than the amount required to burn the shares that represented that debt.

How interest created the vulnerable ratio

The debt pool tracked:

size
Total token value represented by the pool

shares
Total debt shares
Enter fullscreen mode Exit fullscreen mode

Interest distribution increased the pool's size without minting new debt shares.

The important state was:

S > T

S mod T != 0
Enter fullscreen mode Exit fullscreen mode

Once that happened, each share represented a fractional token amount.

For debt display, the protocol rounded that value down.

For final repayment, however, the borrower needed to pay enough to cover the complete share value.

That required rounding up.

The first floor: borrower debt was understated

MarketMsg::Repay first calculated the borrower's debt through SharePool::ownership():

let borrower_debt =
    state
        .debt_pool
        .ownership(
            borrower_shares,
        );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

borrower_debt =
    floor(
        S × borrower_shares
        ÷ T
    )
Enter fullscreen mode Exit fullscreen mode

For a borrower with one remaining share:

borrower_debt =
    floor(
        S
        ÷ T
    )
Enter fullscreen mode Exit fullscreen mode

If the ratio was noninteger, this value was lower than the exact token value of the share.

That was the first underestimation.

The clamp blocked the required payment

The contract then calculated:

let repay_amount =
    min(
        amount,
        borrower_debt,
    );
Enter fullscreen mode Exit fullscreen mode

The intent was understandable.

The intent was to avoid collecting more than the reported borrower debt.

But borrower_debt had already been rounded down.

The minimum amount required to burn one share was:

ceil(
    S
    ÷ T
)
Enter fullscreen mode Exit fullscreen mode

The maximum amount allowed into the internal repayment calculation was:

floor(
    S
    ÷ T
)
Enter fullscreen mode Exit fullscreen mode

When S mod T != 0:

ceil(S / T)
=
floor(S / T) + 1
Enter fullscreen mode Exit fullscreen mode

The borrower needed one additional token unit, but the clamp prevented that unit from reaching state.repay().

The second floor: repayment burned zero shares

The internal repayment logic converted the clamped token amount back into debt shares:

let shares =
    amount.multiply_ratio(
        self.debt_pool.shares(),
        self.debt_pool.size(),
    );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

shares_to_burn =
    floor(
        repay_amount
        × T
        ÷ S
    )
Enter fullscreen mode Exit fullscreen mode

For the final share:

repay_amount =
floor(S / T)
Enter fullscreen mode Exit fullscreen mode

Therefore:

shares_to_burn =
floor(
    floor(S / T)
    × T
    ÷ S
)
Enter fullscreen mode Exit fullscreen mode

Because:

floor(S / T)
<
S / T
Enter fullscreen mode Exit fullscreen mode

the value inside the outer floor was less than one.

The result was:

shares_to_burn = 0
Enter fullscreen mode Exit fullscreen mode

The protocol then called:

self.debt_pool.leave(shares)?;
Enter fullscreen mode Exit fullscreen mode

SharePool::leave(0) rejected the operation with a zero-amount error.

The debt share remained.

Why sending more tokens did not help

A normal borrower response would be to overpay slightly.

That still failed.

The external payment amount was clamped before the share calculation:

repay_amount =
min(
    amount sent,
    floored borrower debt
)
Enter fullscreen mode Exit fullscreen mode

The excess was excluded from the amount passed to state.repay().

The successful repayment path was designed to calculate the difference and refund it:

let refund =
    amount.checked_sub(
        repay_amount,
    )?;
Enter fullscreen mode Exit fullscreen mode

But in the vulnerable case, state.repay(repay_amount) reverted before the transaction reached a successful refund path.

The transaction rolled back, so the sender did not lose the attached funds.

The important security result was different:

No amount of overpayment could increase the internal repayment amount above the floored borrower debt.

The ceiling amount required to burn the final share could never reach the share-burn calculation.

The full failure path

The complete sequence was:

1. Borrowers create debt shares

2. Interest increases debt_pool.size

3. debt_pool.shares remains unchanged

4. The size-to-shares ratio becomes noninteger

5. A borrower is left with one debt share

6. ownership(1) reports floor(S / T)

7. Burning one share requires ceil(S / T)

8. Repay clamps the internal amount to floor(S / T)

9. Token-to-share conversion returns zero

10. leave(0) reverts

11. The whole transaction rolls back

12. The final debt share remains
Enter fullscreen mode Exit fullscreen mode

This was not ordinary display dust.

The same rounded value controlled whether the borrower could complete repayment.

The proof-of-concept setup

The deterministic test used the real Ghost Vault mock environment.

It created:

Owner

Borrower one

Borrower two
Enter fullscreen mode Exit fullscreen mode

The owner deposited:

1,000 USDC
Enter fullscreen mode Exit fullscreen mode

Borrower one borrowed:

1 USDC
Enter fullscreen mode Exit fullscreen mode

Borrower two borrowed:

999 USDC
Enter fullscreen mode Exit fullscreen mode

Before interest accrual, the debt pool contained 1,000 shares.

The test then advanced time by:

11,000 seconds
Enter fullscreen mode Exit fullscreen mode

and triggered the path that distributed accrued interest into the debt pool.

The pool size increased while the share count remained unchanged.

The test verified:

assert!(
    status.debt_pool.size
        > status.debt_pool.shares
);
Enter fullscreen mode Exit fullscreen mode

and:

assert_ne!(
    size % shares,
    0,
);
Enter fullscreen mode Exit fullscreen mode

The required noninteger ratio was present.

Proving the floor and ceiling mismatch

Borrower one still held:

1 debt share
Enter fullscreen mode Exit fullscreen mode

The test calculated the reported debt and the minimum payment required to burn one share:

let borrower_debt =
    borrower_info
        .current
        .u128();

let min_pay_for_one_share =
    (size + shares - 1)
        / shares;
Enter fullscreen mode Exit fullscreen mode

The first expression represented:

floor(S / T)
Enter fullscreen mode Exit fullscreen mode

The second represented:

ceil(S / T)
Enter fullscreen mode Exit fullscreen mode

The assertions confirmed:

assert_eq!(
    borrower_debt,
    size / shares,
);

assert_eq!(
    min_pay_for_one_share,
    borrower_debt + 1,
);
Enter fullscreen mode Exit fullscreen mode

The vault reported exactly one token unit less than the amount required to burn the final share.

The ceiling payment still reverted

Borrower one first sent the minimum amount mathematically required to burn one share:

let result = app.execute_contract(
    borrower_one.clone(),
    vault.addr().clone(),
    &ExecuteMsg::Market(
        MarketMsg::Repay {
            delegate: None,
        },
    ),
    &coins(
        min_pay_for_one_share,
        USDC,
    ),
);
Enter fullscreen mode Exit fullscreen mode

The transaction reverted.

The clamp reduced the internal repayment amount to the lower floored debt.

The subsequent token-to-share conversion produced zero shares, and leave(0) failed.

The test checked for the corresponding Zero("Amount") error.

A larger overpayment also reverted

The borrower tried again with:

minimum amount required for one share
+
10 additional tokens
Enter fullscreen mode Exit fullscreen mode

That transaction also reverted.

The extra amount did not affect state.repay() because the clamp still reduced the internal amount to the floored borrower debt.

The PoC therefore established:

Exact ceiling payment
FAILED

Payment above the ceiling
FAILED

Final share burned
NO
Enter fullscreen mode Exit fullscreen mode

Under the vulnerable implementation, the borrower could not close the debt through MarketMsg::Repay.

What “permanent” means here

The remaining share was permanent through the ordinary repayment path while the vulnerable code and accounting state remained unchanged.

It was not necessarily impossible for the protocol to recover through:

A contract upgrade

A migration

A privileged accounting correction

A dedicated recovery function
Enter fullscreen mode Exit fullscreen mode

The ordinary borrower, however, could not resolve the debt by paying the reported amount, the mathematically required amount, or a larger amount.

That is the relevant availability failure.

Operational impact

A nonzero residual debt share can block workflows that require exact closure:

Closing a credit position

Unwinding an integration

Migrating debt

Emergency de-risking

Removing borrower exposure

Reconciling exact debt accounting
Enter fullscreen mode Exit fullscreen mode

The residual token amount may be small.

The operational impact can still be meaningful when another action requires:

borrower shares = 0
Enter fullscreen mode Exit fullscreen mode

This is why rounding dust can become a denial-of-service issue rather than a cosmetic accounting discrepancy.

Severity

The vault's market entry points were restricted to whitelisted borrower contracts.

That reduced direct exploitability and supported the vault-level CVSS vector:

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
Enter fullscreen mode Exit fullscreen mode

Base score:

4.9 Medium
Enter fullscreen mode Exit fullscreen mode

That matched the final Medium classification.

There was also an integration-level consideration.

A whitelisted borrower may itself be a permissionless market contract that exposes repayment to ordinary users.

At that wider system boundary, effective privileges may resemble:

PR:N
Enter fullscreen mode Exit fullscreen mode

which corresponds to:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Enter fullscreen mode Exit fullscreen mode

Base score:

7.5 High
Enter fullscreen mode Exit fullscreen mode

The final contest classification remained Medium.

The practical attack surface depends on how each whitelisted borrower exposes the vault.

Why I initially argued for High

The issue prevented exact debt finalization and persisted despite overpayment.

For integrations that require a clean zero-debt state, it could block:

Position closure

User exits

Migration

Emergency unwind

Protocol de-risking
Enter fullscreen mode Exit fullscreen mode

That made the availability impact stronger than ordinary rounding dust.

I initially argued that permanent failure of debt closure could justify High severity where public users reached the repayment flow or exact closure controlled critical operations.

The article preserves the official Medium result.

Recommended correction: repay by shares

The cleanest design is to make final repayment share-aware.

An explicit operation could accept:

RepayShares {
    shares
}
Enter fullscreen mode Exit fullscreen mode

The protocol would:

  1. Cap requested shares at the borrower's share balance

  2. Calculate the required token amount with ceiling rounding

  3. Verify that the attached funds cover that amount

  4. Burn the selected shares

  5. Refund any excess after the obligation is known

For x shares:

required_amount =
ceil(
    S × x
    ÷ T
)
Enter fullscreen mode Exit fullscreen mode

A checked ceiling calculation can be implemented as:

quotient =
    numerator / denominator

remainder =
    numerator % denominator

required =
    quotient
    when remainder is zero

required =
    quotient + 1
    otherwise
Enter fullscreen mode Exit fullscreen mode

The implementation should use the project's checked Uint256 arithmetic and return contract errors instead of relying on unchecked conversions.

Alternative correction: choose shares before refunding

The existing token-based interface can remain if the function changes its order of operations:

1. Read the borrower's share balance

2. Determine the intended shares to burn

3. Cap that amount at the borrower balance

4. Calculate the ceiling token amount required

5. Ensure the supplied funds cover it

6. Burn the shares

7. Refund only the true excess
Enter fullscreen mode Exit fullscreen mode

For full repayment, the function should explicitly target every remaining borrower share.

It should not first clamp the payment to a floored token estimate.

Why the generic share math should not be changed blindly

Some share-pool operations may intentionally round down.

Changing SharePool::ownership() or every use of multiply_ratio() globally could alter unrelated accounting behavior.

The safer correction is local to debt repayment:

Use borrower shares as the closure target

Use ceiling rounding for the required token amount

Calculate refunds only after the obligation is known
Enter fullscreen mode Exit fullscreen mode

Rounding direction is part of the business rule for each operation.

Regression tests

A complete fix should test:

  1. Integer and noninteger debt-pool ratios

  2. A borrower with one remaining share

  3. Repaying the exact ceiling amount burns the share

  4. Repaying above the ceiling burns the share and refunds the true excess

  5. Repaying below the required amount does not burn too many shares

  6. The borrower can reach exactly zero shares

  7. Multiple borrowers with different share balances

  8. Interest accrual before repayment

  9. Repeated partial repayments

  10. Tokens with different decimal precision

  11. No repayment path calls leave(0)

  12. Failed repayment leaves balances and debt state unchanged

The central invariant is:

A borrower who supplies enough tokens to cover all remaining debt shares must always be able to reduce the debt-share balance to zero.

Broader audit lessons

Floor rounding is not always conservative

Rounding debt down may look borrower-friendly.

During final repayment, it can make the protocol accept too little value internally to burn the corresponding share.

Share-denominated debt needs a share-aware exit

A token-denominated debt query is not always sufficient to close a share-denominated position.

The exit path must reason about the shares that need to disappear.

Clamping can remove the exact value needed for recovery

The borrower supplied enough tokens.

The clamp prevented the required ceiling amount from reaching the share calculation.

Input normalization must not destroy a valid closure path.

Refunds should be calculated after the obligation

The safe order is:

Determine shares to burn

Calculate required amount

Consume the obligation

Refund the excess
Enter fullscreen mode Exit fullscreen mode

Dust can become an availability bug

A small residual amount can block a position from reaching its terminal state.

The severity depends on what zero debt unlocks elsewhere in the system.

Conclusion

Rujira Ghost Vault reported borrower debt through a floored share-to-token conversion.

After interest created a noninteger debt-pool ratio, a borrower with one remaining share needed:

ceil(S / T)
Enter fullscreen mode Exit fullscreen mode

tokens to burn it.

The vault reported only:

floor(S / T)
Enter fullscreen mode Exit fullscreen mode

and clamped every repayment to that lower value.

Converting the clamped amount back into shares returned zero, and leave(0) reverted.

Sending the ceiling amount did not work.

Sending more did not work.

The transaction rolled back and the final debt share remained.

Code4rena classified the finding as Medium.

The engineering invariant is simple:

Repayment must be derived from the debt shares that need to be burned, using ceiling-safe token amounts, so every position can reach zero debt.

Top comments (0)