Skip to content

feat(liquidation): reserve pool backstop for bad debt vaults (#11) - #1298

Merged
Ceejaytech25 merged 2 commits into
ceejaylaboratory:mainfrom
barry01-hash:feat/issue-11-bad-debt-backstop
Sep 28, 2026
Merged

Ceejaytech25 merged 2 commits into
ceejaylaboratory:mainfrom
barry01-hash:feat/issue-11-bad-debt-backstop

Conversation

@barry01-hash

@barry01-hash barry01-hash commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1152

Refs internal issue #11 (reserve pool backstop for bad debt). Upstream ceejaylaboratory#11 is not this issue, so this PR intentionally does not close it.

Summary

Adds a reserve pool that absorbs bad debt, so an under-collateralised vault cannot sit on the books indefinitely. Before this, a vault whose collateral was worth less than its debt had no exit: liquidate and partial_liquidate both liquidate for a liquidator's profit, and on a vault with no profitable collateral left there is nothing to pay them, so the position just lingered.

pub fn fund_reserve(env: Env, from: Address, amount: i128) -> i128
pub fn reserve_debt(env: Env) -> i128
pub fn reserve_collateral(env: Env) -> i128
pub fn liquidate_bad_debt(env: Env, vault_id: u32)

liquidate_bad_debt prices the vault through the oracle, requires collateral_value < debt_value, pays the debt in full from the reserve, takes the remaining collateral into the reserve, and closes the vault at zero.

Design decisions worth reviewing

Permissionless, and with no liquidator argument, following the signature in the issue. The caller is not paid and gains nothing here, so requiring authorisation would only add friction to protocol maintenance everyone benefits from. A bad vault should never be holdable open by the absence of a liquidator.

Two balances, not one. reserve_debt is denominated in debt-token units because that is what has to be spent to clear a debt; reserve_collateral is denominated in collateral-token units because that is what gets seized. A single number would mix two different tokens, and denominating in oracle value would bake a price into a balance that has to be spent at a later, unknown price.

Funding is permissionless too. The contract has no admin concept and adding one would have meant changing initialize's signature and touching every existing caller, so fund_reserve authorises the funder's own contribution instead. Anyone can recapitalise the backstop, which is the property you want from a reserve.

The vault record is zeroed, not deleted. "Close vault record" is implemented as debt = 0, collateral = 0 rather than removing the storage entry, matching what liquidate already does. Deleting it would make a cleared vault panic on get_health_factor and break the existing guarantee that a cleared vault stays queryable — test_zero_debt_after_full_liquidation_is_safe depends on that.

The event reports the shortfall in oracle value units (debt_value - collateral_value), because the reserve debit is in token units and a reserve operator needs the value loss to size future funding:

env.events().publish(
    (symbol_short!("baddebt"),),
    (vault_id, debt_cleared, collateral_absorbed, shortfall),
);

Refactor

get_health_factor was pricing the vault inline; the pricing now lives in one private vault_values helper shared with liquidate_bad_debt, so there is a single implementation of the oracle comparison. The arithmetic and its ordering are unchanged, and all 18 pre-existing tests pass untouched.

Tests

11 new tests, 29 total, all passing:

  • test_liquidate_bad_debt_clears_vault_from_reserve — 1 collateral against 2 debt; vault closed to zero, reserve debited 2, 1 collateral unit absorbed.
  • test_liquidate_bad_debt_is_driven_by_the_oracle — a vault at exactly par (HF 1.0) is not bad debt, then a collateral price halving makes it bad debt. This is the case raw token counts would get wrong.
  • test_reserve_absorbs_collateral_from_several_vaults — balances accumulate across two vaults.
  • test_liquidate_bad_debt_requires_a_funded_reserve — reverts with reserve pool cannot cover bad debt rather than clearing half a position.
  • test_liquidate_bad_debt_rejects_vault_at_par, ..._rejects_healthy_vault, ..._rejects_vault_without_debt — all revert with vault is not under-collateralized.
  • test_liquidate_bad_debt_reports_the_shortfall — asserts the full event tuple.
  • test_liquidate_bad_debt_is_permissionless — no authorisation required.
  • test_fund_reserve_accumulates, test_fund_reserve_rejects_zero.

Verification

cargo test -p liquidation                       # 29 passed; 0 failed
cargo clippy -p liquidation --all-targets       # clean
cargo fmt -p liquidation -- --check             # clean

Limitations, deliberate and worth stating

  • No tokens move. This module has always been accounting-plus-events, so fund_reserve records a balance rather than transferring tokens, and the seized collateral is booked to reserve_collateral rather than held. Wiring the reserve to real token transfers is a separate piece of work; until then the reserve is a ledger, not a treasury.
  • There is no way to withdraw from the reserve. It can only grow or be spent on bad debt. Adding a governed withdrawal path needs an admin, which this contract does not have.
  • A depleted reserve blocks the backstop by design, preferring a loud revert over silently closing a vault whose debt is still outstanding.

Interaction with #1295

liquidate_bad_debt only triggers below HF 1.0, so it is orthogonal to the partial-liquidation work in #1295. It is worth noting that it is also the correct path for the deeply-underwater vaults whose pro-rata partial liquidation I flagged in #1295 as making the owner worse off — this gives the protocol a way to close those out properly.

Note on CI

As with #1295 and #1297, rust.yml does not build or test liquidation, so the 29 green tests are local evidence only. I have a one-line matrix change ready to add that crate to CI; say the word and I will send it as a separate PR so these three stay independently reviewable.

@drips-wave

drips-wave Bot commented Sep 27, 2026

Copy link
Copy Markdown

@barry01-hash Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Ceejaytech25
Ceejaytech25 merged commit e6dedb0 into ceejaylaboratory:main Sep 28, 2026
4 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Soroban/Liquidation] Add Bad Debt Socialization & Emergency Reserve Backstop

2 participants