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:
The vault converted the borrower's shares into token debt using floor rounding
It clamped the internal repayment amount to that floored debt
It converted the clamped token amount back into shares using another floor
The result could be zero shares
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
The vault reported the borrower's debt as:
floor(
S × b
÷ T
)
Repayment converted a token amount back into shares as:
floor(
repayment amount
× T
÷ S
)
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
Interest distribution increased the pool's size without minting new debt shares.
The important state was:
S > T
S mod T != 0
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,
);
Conceptually:
borrower_debt =
floor(
S × borrower_shares
÷ T
)
For a borrower with one remaining share:
borrower_debt =
floor(
S
÷ T
)
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,
);
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
)
The maximum amount allowed into the internal repayment calculation was:
floor(
S
÷ T
)
When S mod T != 0:
ceil(S / T)
=
floor(S / T) + 1
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(),
);
Conceptually:
shares_to_burn =
floor(
repay_amount
× T
÷ S
)
For the final share:
repay_amount =
floor(S / T)
Therefore:
shares_to_burn =
floor(
floor(S / T)
× T
÷ S
)
Because:
floor(S / T)
<
S / T
the value inside the outer floor was less than one.
The result was:
shares_to_burn = 0
The protocol then called:
self.debt_pool.leave(shares)?;
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
)
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,
)?;
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
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
The owner deposited:
1,000 USDC
Borrower one borrowed:
1 USDC
Borrower two borrowed:
999 USDC
Before interest accrual, the debt pool contained 1,000 shares.
The test then advanced time by:
11,000 seconds
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
);
and:
assert_ne!(
size % shares,
0,
);
The required noninteger ratio was present.
Proving the floor and ceiling mismatch
Borrower one still held:
1 debt share
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;
The first expression represented:
floor(S / T)
The second represented:
ceil(S / T)
The assertions confirmed:
assert_eq!(
borrower_debt,
size / shares,
);
assert_eq!(
min_pay_for_one_share,
borrower_debt + 1,
);
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,
),
);
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
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
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
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
The residual token amount may be small.
The operational impact can still be meaningful when another action requires:
borrower shares = 0
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
Base score:
4.9 Medium
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
which corresponds to:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Base score:
7.5 High
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
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
}
The protocol would:
Cap requested shares at the borrower's share balance
Calculate the required token amount with ceiling rounding
Verify that the attached funds cover that amount
Burn the selected shares
Refund any excess after the obligation is known
For x shares:
required_amount =
ceil(
S × x
÷ T
)
A checked ceiling calculation can be implemented as:
quotient =
numerator / denominator
remainder =
numerator % denominator
required =
quotient
when remainder is zero
required =
quotient + 1
otherwise
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
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
Rounding direction is part of the business rule for each operation.
Regression tests
A complete fix should test:
Integer and noninteger debt-pool ratios
A borrower with one remaining share
Repaying the exact ceiling amount burns the share
Repaying above the ceiling burns the share and refunds the true excess
Repaying below the required amount does not burn too many shares
The borrower can reach exactly zero shares
Multiple borrowers with different share balances
Interest accrual before repayment
Repeated partial repayments
Tokens with different decimal precision
No repayment path calls
leave(0)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
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)
tokens to burn it.
The vault reported only:
floor(S / T)
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)