Skip to content

fix(contracts,backend): reverse a defaulted loan on-chain when an admin dispute is reversed - #1838

Open
flare-999 wants to merge 3 commits into
LabsCrypt:mainfrom
flare-999:fix/issue-1803-reverse-default-onchain
Open

flare-999 wants to merge 3 commits into
LabsCrypt:mainfrom
flare-999:fix/issue-1803-reverse-default-onchain

Conversation

@flare-999

Copy link
Copy Markdown

Overview

adminDisputeController.resolveLoanDispute('reverse') used to insert a DefaultReversed row into contract_events and stop there. The comment above that insert admitted the row was synthetic (admin-dispute:<id>), and nothing ever touched the Soroban contracts: an overturned default left the loan permanently Defaulted in LoanManager, the seized collateral sitting in the lending pool, and the borrower's RemittanceNFT score still slashed with the account flagged as seized. The dispute resolution was a database fiction.

This PR gives the reversal a real on-chain counterpart and makes the backend submit it before it records anything.

Related Issue

Closes #1803

Changes

contracts/loan_manager

  • [ADD] reverse_default(loan_id) — admin-only counterpart of check_default, and the only path back out of LoanStatus::Defaulted. In one atomic call it:
    • returns the loan to Approved and adds back the outstanding principal check_default removed;
    • reuses the borrower's active-loan slot that check_default freed;
    • pushes due_date out by the loan's own term (falling back to the configured default term) and restarts the interest/late-fee clocks, so a reinstated loan is neither instantly re-defaultable nor charged interest for the window in which it was wrongly defaulted — debt accrued up to the default is untouched;
    • transfers the collateral seized into the lending pool back to the borrower, reinstates it on the loan, and clears the seizure record;
    • refunds the score penalty on the NFT and clears the default/seized record there.
  • [ADD] DataKey::SeizedCollateral(u32) — recorded by seize_collateral_internal before it zeroes Loan::collateral_amount. Without it the seized amount was unrecoverable, so no reversal could refund it. Both default paths (check_default and the check_defaults batch) already go through that helper, so both are covered.
  • [ADD] LoanError::LoanNotDefaulted = 29 (only a default can be reversed).
  • [ADD] LoanDefaultReversed event, registered in the events.rs catalogue.
  • [MODIFY] RemittanceNftInterface gains clear_default so the loan manager can repair the credit record in the same call.

Two deliberate asymmetries with check_default, both documented on the function:

  • Pausing does not block a reversal. An emergency pause freezes new activity; it must not freeze remediation of a wrongful default.
  • Liquidated loans are rejected. That collateral has already been auctioned to a third party and cannot be unwound.

If the pool can no longer cover the refund the call fails with InsufficientPoolLiquidity before any state is written, so a failed reversal leaves the default exactly as it was and can be retried once the pool is funded.

contracts/remittance_nft

  • [ADD] clear_default(user, minter) — admin/authorized-minter only, counterpart of record_default: decrements the default count (saturating at zero, dropping the key like burn_internal does at zero) and lifts the seized flag so the borrower can transfer/refinance again. The score points are restored separately via apply_score_delta, because the penalty size is loan-manager policy.
  • A burned account is never resurrected: if the reversal arrives after the burn threshold was hit, clear_default is a documented no-op and the sanctioned path back stays approve_remint + admin_remint.
  • [FIX] remittance_nft/src/test.rs defined test_transfer_rejects_burned_destination twice (lines 1230 and 1327), so cargo test -p remittance_nft did not compile on main. The second, distinct test (explicit burn instead of record_default) is renamed test_transfer_rejects_explicitly_burned_destination.

backend

  • [MODIFY] services/defaultChecker.ts — reverseDefault(loanId) submits reverse_default(loan_id) signed with the same LOAN_MANAGER_ADMIN_SECRET the checker already uses for check_defaults. It reports a usable txHash only on a confirmed SUCCESS; a FAILED tx, an unconfirmable tx, or a prepare/send failure all come back as an error (with the hash when one exists) so a caller can never mistake a submission for a reversal. No distributed lock or batching: it runs inside an admin request, for one loan, and the operator has to see the failure.
  • [MODIFY] controllers/adminDisputeController.ts — action === 'reverse' now submits the on-chain call before the dispute is marked resolved:
    • on failure it throws 503 ServiceUnavailable, logs the reason, and writes nothing — the dispute stays open and retryable instead of being recorded as resolved;
    • on success the DefaultReversed row carries the real tx_hash and ledger instead of admin-dispute:<id>.
  • action === 'confirm' is unchanged and still writes its synthetic marker: confirming a default genuinely has no on-chain effect.

Tests

  • contracts/loan_manager: 5 new tests — full reversal (state, accounting, borrower balance, pool balance, score, default count, seized flag), rejection for a non-defaulted loan (active and rejected-terminal), reversal without collateral, InsufficientPoolLiquidity leaving the default untouched and then succeeding after a top-up, and reversal while paused.
  • contracts/remittance_nft: 4 new tests — count decrement + un-seize, last default dropping the key (and not re-arming the burn threshold), no-op for a burned account, unauthorized minter rejected.
  • backend: defaultChecker gains 4 tests (function name and u32 argument really are reverse_default/loan id, FAILED status, unconfirmable tx, prepare failure); loanDispute gains an assertion that the recorded hash is the real on-chain one plus a new test that a failed reversal returns 503 and writes nothing; the dispute integration flow asserts the reversal reaches the chain before the resolution is recorded.

Verification Results

$ cargo test -p loan_manager        → 160 passed; 0 failed  (155 on main + 5 new)
$ cargo test -p remittance_nft      →  92 passed; 0 failed  (88 on main + 4 new;
                                       suite did not compile on main before the duplicate-fn fix)
$ cargo test -p lending_pool -p multisig_governance -p money → 129 passed; 0 failed
$ cargo build --workspace --target wasm32-unknown-unknown --release → Finished (codeql path)

$ npm run typecheck                 → clean
$ npm run lint                      → 0 errors (1 pre-existing warning)
$ npx prettier --check <changed>    → all matched files use Prettier code style
$ npm test (backend, full suite)    → 98 passed / 5 skipped suites, 716 passed / 33 skipped, 0 failed
Acceptance criteria (from the issue) Status
Administrative contract method to reverse default on-chain ✅ LoanManager::reverse_default, admin-auth (the deployed admin role is the governance multisig), LoanNotDefaulted for anything else
Refund escrowed/remaining seized collateral ✅ Seizure amount recorded at default; returned from the lending pool to the borrower and reinstated on the loan
Restore the borrower's credit score ✅ apply_score_delta(+penalty) + new RemittanceNFT::clear_default (default count decremented, seized flag lifted)
Backend stops being a database fiction ✅ reverse_default submitted first; the row stores the real tx hash/ledger; failure returns 503 and leaves the dispute open
Reversal is safe and reversible to reason about ✅ Checks before effects (no partial reversal when the pool can't refund), CEI ordering, paused-allowed, liquidation excluded, idempotent via LoanNotDefaulted

…eversal

adminDisputeController.resolveLoanDispute('reverse') only wrote a
contract_events row. Nothing touched the ledger, so an overturned default
left the loan permanently Defaulted in LoanManager, the seized collateral
in the lending pool, and the borrower's score on RemittanceNFT slashed.

- LoanManager: add admin-only reverse_default(loan_id) that reinstates the
  loan, refunds the seized collateral out of the pool, and restores the
  outstanding and borrower-loan accounting. Default-time seizures now
  record the amount in SeizedCollateral so a reversal can refund it.
- RemittanceNFT: add clear_default(user, minter) to decrement the default
  count and lift the seized flag; the score penalty is refunded through the
  existing apply_score_delta.
- Backend: submit reverse_default before recording the resolution and store
  the real tx hash/ledger; fail the request (503) when the call does not
  land, leaving the dispute open instead of resolved.
- Fix a duplicate test fn name that stopped remittance_nft's suite from
  compiling on main.

Closes LabsCrypt#1803
@flare-999

flare-999 commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

@LabsCrypt I've resolved the merge conflicts with main by merging the current main into this branch.

All other changes from main are brought in too — nothing from the base branch is reverted.

Merge commit: 0eb77742fe

Could you take another look when you have a moment? Thanks!

@flare-999
flare-999 force-pushed the fix/issue-1803-reverse-default-onchain branch from 95588af to 47a4984 Compare October 6, 2026 11:03
…e-default-onchain

Resolves the merge conflicts with main.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant