Repository navigation
Conversation
…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
Author
|
@LabsCrypt I've resolved the merge conflicts with All other changes from Merge commit: Could you take another look when you have a moment? Thanks! |
flare-999
force-pushed
the
fix/issue-1803-reverse-default-onchain
branch
from
October 6, 2026 11:03
95588af to
47a4984
Compare
…e-default-onchain Resolves the merge conflicts with main.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
adminDisputeController.resolveLoanDispute('reverse')used to insert aDefaultReversedrow intocontract_eventsand 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 permanentlyDefaultedinLoanManager, the seized collateral sitting in the lending pool, and the borrower'sRemittanceNFTscore 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_managerreverse_default(loan_id)— admin-only counterpart ofcheck_default, and the only path back out ofLoanStatus::Defaulted. In one atomic call it:Approvedand adds back the outstanding principalcheck_defaultremoved;check_defaultfreed;due_dateout 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;DataKey::SeizedCollateral(u32)— recorded byseize_collateral_internalbefore it zeroesLoan::collateral_amount. Without it the seized amount was unrecoverable, so no reversal could refund it. Both default paths (check_defaultand thecheck_defaultsbatch) already go through that helper, so both are covered.LoanError::LoanNotDefaulted = 29(only a default can be reversed).LoanDefaultReversedevent, registered in theevents.rscatalogue.RemittanceNftInterfacegainsclear_defaultso the loan manager can repair the credit record in the same call.Two deliberate asymmetries with
check_default, both documented on the function:Liquidatedloans 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
InsufficientPoolLiquiditybefore 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_nftclear_default(user, minter)— admin/authorized-minter only, counterpart ofrecord_default: decrements the default count (saturating at zero, dropping the key likeburn_internaldoes at zero) and lifts the seized flag so the borrower can transfer/refinance again. The score points are restored separately viaapply_score_delta, because the penalty size is loan-manager policy.clear_defaultis a documented no-op and the sanctioned path back staysapprove_remint+admin_remint.remittance_nft/src/test.rsdefinedtest_transfer_rejects_burned_destinationtwice (lines 1230 and 1327), socargo test -p remittance_nftdid not compile onmain. The second, distinct test (explicitburninstead ofrecord_default) is renamedtest_transfer_rejects_explicitly_burned_destination.backendservices/defaultChecker.ts—reverseDefault(loanId)submitsreverse_default(loan_id)signed with the sameLOAN_MANAGER_ADMIN_SECRETthe checker already uses forcheck_defaults. It reports a usabletxHashonly on a confirmedSUCCESS; aFAILEDtx, an unconfirmable tx, or a prepare/send failure all come back as anerror(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.controllers/adminDisputeController.ts—action === 'reverse'now submits the on-chain call before the dispute is marked resolved:503 ServiceUnavailable, logs the reason, and writes nothing — the dispute stays open and retryable instead of being recorded as resolved;DefaultReversedrow carries the realtx_hashand ledger instead ofadmin-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,InsufficientPoolLiquidityleaving 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:defaultCheckergains 4 tests (function name andu32argument really arereverse_default/loan id,FAILEDstatus, unconfirmable tx, prepare failure);loanDisputegains 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
LoanManager::reverse_default, admin-auth (the deployed admin role is the governance multisig),LoanNotDefaultedfor anything elseapply_score_delta(+penalty)+ newRemittanceNFT::clear_default(default count decremented, seized flag lifted)reverse_defaultsubmitted first; the row stores the real tx hash/ledger; failure returns 503 and leaves the dispute openLoanNotDefaulted