Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Critical (audit-funded) cross-contract atomicity and reconciliation defect
Affected Layers: contracts (loan_manager, remittance_nft), backend, frontend
Affected Paths: contracts/loan_manager/src/lib.rs, contracts/remittance_nft/src/lib.rs, backend/src/services/eventIndexer.ts, backend/src/services/reconciler.ts (new), backend/migrations/, frontend/app/, .github/workflows/ci.yml
Severity: Critical - divergent on-chain financial state (funds without score, or score without funds)
Estimated Window: 72-96 hours
Technical Context & Monorepo Integration Failure
loan_manager mutates principal custody in three paths - approve (disburse), repay (return), default (write-off) - and each path issues a sub-invocation into remittance_nft to adjust the borrower credit score. A Soroban transaction is atomic: a trap in a non-try_ cross-contract call propagates and reverts the whole transaction. Divergence arises from exactly two anti-patterns, both present in the current tree:
- The score update is issued through the
try_ client variant (nft_client.try_update_score(...)), which converts an NftError trap into an Err the caller swallows, so a failing score does not block disbursement.
- The disbursement leg for one path is a classic Stellar payment submitted separately by the backend after the contract returns, placing the two legs in different transactions.
Either pattern breaks the invariant that a committed custody change and its score delta share one atomic boundary. A single-layer fix is insufficient: removing try_ closes the atomic path but cannot repair state already split across a classic payment, and cannot surface half-applied rows to operators or clients. The backend must own a durable reconciliation ledger; the frontend must render pending/half-applied states so borrowers are not shown a score that on-chain state contradicts.
Concrete failure walkthrough (approve path):
- Backend calls
loan_manager.approve(loan_id).
loan_manager sets loan status Active, writes the persistent loan entry, and transfers principal from lending_pool via the SAC token client. This leg is now staged in the transaction footprint.
loan_manager invokes nft_client.try_update_score(borrower, delta).
update_score traps: the borrower score entry was archived (persistent TTL expired) or the new score overflows under overflow-checks=true.
- The
try_ call returns Err(NftError::...); loan_manager ignores it and returns Ok.
- Transaction commits. Principal moved; score unchanged.
eventIndexer records LoanApproved but no ScoreUpdated. No component holds a record of the discrepancy, and the reconciler does not exist.
Core Component Invariants & Code Paths
Smart Contract Infrastructure. In loan_manager, replace every try_update_score with the trap-propagating update_score so any NftError reverts approve/repay/default as one unit; map the propagated failure to LoanError::ScoreUpdateFailed. For paths that must stage a classic disbursement, write a CrossContractIntent persistent entry keyed by intent_key: BytesN<32> (derived from loan_id, operation discriminant, and a monotonic nonce in instance storage) before returning, and emit ScoreSyncRequired. Add loan_manager.settle_intent(intent_key) and remittance_nft.apply_pending_score(intent_key, borrower, delta) as repair entrypoints. settle_intent reverts with LoanError::IntentAlreadySettled when the intent status is already settled, and the reconciler treats that error as terminal-success; apply_pending_score no-ops when the score for that intent_key is already applied. Both entrypoints are therefore idempotent under retry. Bump persistent TTL on both the loan entry and the score entry inside the same call.
Invariant: every committed custody transition either applies exactly one score delta in the same ledger, or leaves a live CrossContractIntent persistent entry with status pending; no committed disbursement exists with neither.
Backend/API Layer. New migration backend/migrations/NNN_create_cross_contract_reconciliation.sql creates cross_contract_reconciliation:
| Column |
Type |
Constraint |
id |
BIGSERIAL |
PK |
intent_key |
BYTEA |
UNIQUE NOT NULL |
loan_id |
TEXT |
|
borrower |
TEXT |
|
operation |
TEXT |
CHECK (operation IN ('approve','repay','default')) |
disbursement_ledger |
BIGINT |
|
disbursement_tx_hash |
TEXT |
|
expected_score_delta |
INTEGER |
|
score_applied |
BOOLEAN |
DEFAULT false |
score_ledger |
BIGINT |
|
state |
TEXT |
CHECK (state IN ('pending','half_applied','reconciled','failed')) DEFAULT 'pending' |
attempts |
INT |
DEFAULT 0 |
last_checked_at |
TIMESTAMPTZ |
|
created_at |
TIMESTAMPTZ |
DEFAULT now() |
updated_at |
TIMESTAMPTZ |
|
eventIndexer.ts inserts a pending row on LoanApproved/LoanRepaid/LoanDefaulted and marks it reconciled when the matching ScoreUpdated shares the intent_key. New reconciler.ts (patterned on defaultChecker.ts) polls unresolved rows, calls Soroban RPC getLedgerEntries to read the loan entry and the score entry, classifies half_applied on mismatch, and submits apply_pending_score/settle_intent via sendTransaction, incrementing attempts. State transitions push over SSE.
Invariant: the unique intent_key makes reconciliation and repair idempotent under retry.
Frontend Client. Add TanStack Query hook useLoanReconciliation(loanId) (key ['loan', loanId, 'reconciliation']) reading the reconciliation endpoint and subscribing to the SSE reconciliation_status event. Render a pending/half_applied badge on the loan detail view and gate any displayed credit score behind state === 'reconciled', showing a "score sync in progress" notice otherwise.
Invariant: the UI never presents a score the reconciliation row has not confirmed.
Root Configuration & Orchestration. Add a root Makefile with build (invoking cargo build in contracts/, npm run build in backend/ and frontend/), reconcile (starts reconciler.ts), and verify. Extend docker-compose.yml with a reconciler service. Add CI job reconciler-check in .github/workflows/ci.yml (service postgres:16) running reconciler integration tests. New env vars: SOROBAN_RPC_URL, LOAN_MANAGER_CONTRACT_ID, REMITTANCE_NFT_CONTRACT_ID, RECONCILE_INTERVAL_MS, NEXT_PUBLIC_RECONCILE_SSE_PATH.
Invariant: the reconciler is startable from root and CI fails if a seeded half-applied fixture is not repaired.
Verification & Acceptance Criteria
Suggested Execution Path
- Phase 1 - Contract invariant and intent model (8-12h). Define
intent_key derivation, CrossContractIntent layout, LoanError::ScoreUpdateFailed/LoanError::IntentAlreadySettled, and the invariant. Deliverable: written invariant plus storage/event schema. Exit check: reviewer sign-off on atomic vs saga path boundaries.
- Phase 2 - Contract implementation and tests (20-26h). Remove
try_ swallowing, add repair entrypoints, TTL bumps, unit/idempotency tests. Deliverable: passing cargo test in contracts/. Exit check: revert-on-trap and idempotency tests green.
- Phase 3 - Backend reconciliation (18-24h). Migration,
eventIndexer inserts, reconciler.ts diff-and-repair over getLedgerEntries/sendTransaction, SSE emission. Deliverable: integration tests on Postgres. Exit check: seeded half-applied fixture reaches reconciled.
- Phase 4 - Frontend surface (12-16h). Query hook, SSE consumer, gated score and badge, Playwright coverage. Deliverable:
test:e2e green. Exit check: score hidden until reconciled.
- Phase 5 - Root orchestration and end-to-end (14-18h).
Makefile, compose reconciler service, reconciler-check CI job, full container run with state dumps. Exit check: CI red on an unrepaired seeded divergence, green after repair.
Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Critical (audit-funded) cross-contract atomicity and reconciliation defect
Affected Layers: contracts (loan_manager, remittance_nft), backend, frontend
Affected Paths:
contracts/loan_manager/src/lib.rs,contracts/remittance_nft/src/lib.rs,backend/src/services/eventIndexer.ts,backend/src/services/reconciler.ts(new),backend/migrations/,frontend/app/,.github/workflows/ci.ymlSeverity: Critical - divergent on-chain financial state (funds without score, or score without funds)
Estimated Window: 72-96 hours
Technical Context & Monorepo Integration Failure
loan_managermutates principal custody in three paths -approve(disburse),repay(return),default(write-off) - and each path issues a sub-invocation intoremittance_nftto adjust the borrower credit score. A Soroban transaction is atomic: a trap in a non-try_cross-contract call propagates and reverts the whole transaction. Divergence arises from exactly two anti-patterns, both present in the current tree:try_client variant (nft_client.try_update_score(...)), which converts anNftErrortrap into anErrthe caller swallows, so a failing score does not block disbursement.Either pattern breaks the invariant that a committed custody change and its score delta share one atomic boundary. A single-layer fix is insufficient: removing
try_closes the atomic path but cannot repair state already split across a classic payment, and cannot surface half-applied rows to operators or clients. The backend must own a durable reconciliation ledger; the frontend must render pending/half-applied states so borrowers are not shown a score that on-chain state contradicts.Concrete failure walkthrough (approve path):
loan_manager.approve(loan_id).loan_managersets loan statusActive, writes the persistent loan entry, and transfers principal fromlending_poolvia the SAC token client. This leg is now staged in the transaction footprint.loan_managerinvokesnft_client.try_update_score(borrower, delta).update_scoretraps: the borrower score entry was archived (persistent TTL expired) or the new score overflows underoverflow-checks=true.try_call returnsErr(NftError::...);loan_managerignores it and returnsOk.eventIndexerrecordsLoanApprovedbut noScoreUpdated. No component holds a record of the discrepancy, and the reconciler does not exist.Core Component Invariants & Code Paths
Smart Contract Infrastructure. In
loan_manager, replace everytry_update_scorewith the trap-propagatingupdate_scoreso anyNftErrorrevertsapprove/repay/defaultas one unit; map the propagated failure toLoanError::ScoreUpdateFailed. For paths that must stage a classic disbursement, write aCrossContractIntentpersistent entry keyed byintent_key: BytesN<32>(derived fromloan_id, operation discriminant, and a monotonic nonce in instance storage) before returning, and emitScoreSyncRequired. Addloan_manager.settle_intent(intent_key)andremittance_nft.apply_pending_score(intent_key, borrower, delta)as repair entrypoints.settle_intentreverts withLoanError::IntentAlreadySettledwhen the intent status is alreadysettled, and the reconciler treats that error as terminal-success;apply_pending_scoreno-ops when the score for thatintent_keyis already applied. Both entrypoints are therefore idempotent under retry. Bump persistent TTL on both the loan entry and the score entry inside the same call.Invariant: every committed custody transition either applies exactly one score delta in the same ledger, or leaves a live
CrossContractIntentpersistent entry with statuspending; no committed disbursement exists with neither.Backend/API Layer. New migration
backend/migrations/NNN_create_cross_contract_reconciliation.sqlcreatescross_contract_reconciliation:idBIGSERIALintent_keyBYTEAUNIQUE NOT NULLloan_idTEXTborrowerTEXToperationTEXTCHECK (operation IN ('approve','repay','default'))disbursement_ledgerBIGINTdisbursement_tx_hashTEXTexpected_score_deltaINTEGERscore_appliedBOOLEANDEFAULT falsescore_ledgerBIGINTstateTEXTCHECK (state IN ('pending','half_applied','reconciled','failed')) DEFAULT 'pending'attemptsINTDEFAULT 0last_checked_atTIMESTAMPTZcreated_atTIMESTAMPTZDEFAULT now()updated_atTIMESTAMPTZeventIndexer.tsinserts apendingrow onLoanApproved/LoanRepaid/LoanDefaultedand marks itreconciledwhen the matchingScoreUpdatedshares theintent_key. Newreconciler.ts(patterned ondefaultChecker.ts) polls unresolved rows, calls Soroban RPCgetLedgerEntriesto read the loan entry and the score entry, classifieshalf_appliedon mismatch, and submitsapply_pending_score/settle_intentviasendTransaction, incrementingattempts. State transitions push over SSE.Invariant: the unique
intent_keymakes reconciliation and repair idempotent under retry.Frontend Client. Add TanStack Query hook
useLoanReconciliation(loanId)(key['loan', loanId, 'reconciliation']) reading the reconciliation endpoint and subscribing to the SSEreconciliation_statusevent. Render apending/half_appliedbadge on the loan detail view and gate any displayed credit score behindstate === 'reconciled', showing a "score sync in progress" notice otherwise.Invariant: the UI never presents a score the reconciliation row has not confirmed.
Root Configuration & Orchestration. Add a root
Makefilewithbuild(invokingcargo buildincontracts/,npm run buildinbackend/andfrontend/),reconcile(startsreconciler.ts), andverify. Extenddocker-compose.ymlwith areconcilerservice. Add CI jobreconciler-checkin.github/workflows/ci.yml(servicepostgres:16) running reconciler integration tests. New env vars:SOROBAN_RPC_URL,LOAN_MANAGER_CONTRACT_ID,REMITTANCE_NFT_CONTRACT_ID,RECONCILE_INTERVAL_MS,NEXT_PUBLIC_RECONCILE_SSE_PATH.Invariant: the reconciler is startable from root and CI fails if a seeded half-applied fixture is not repaired.
Verification & Acceptance Criteria
make buildcompiles all three layers from a clean checkout.approve/repay/defaultrevert fully whenupdate_scoretraps (no partial custody change), usingoverflow-checks=true.apply_pending_scoreandsettle_intentare idempotent across duplicateintent_keysubmissions, withsettle_intentrevertingLoanError::IntentAlreadySettledon the second call.half_appliedrow and assertsreconciler.tsdrives it toreconciledvia a repair transaction, withattemptsbounded.eventIndexertest asserts one reconciliation row per custody event, matched byintent_key.test:e2easserts the score is hidden whilestate !== 'reconciled'and the badge clears on the SSEreconciliation_statusevent.getLedgerEntriesstate dumps for a repaired case, across_contract_reconciliationrow dump, and CI logs forreconciler-check.Suggested Execution Path
intent_keyderivation,CrossContractIntentlayout,LoanError::ScoreUpdateFailed/LoanError::IntentAlreadySettled, and the invariant. Deliverable: written invariant plus storage/event schema. Exit check: reviewer sign-off on atomic vs saga path boundaries.try_swallowing, add repair entrypoints, TTL bumps, unit/idempotency tests. Deliverable: passingcargo testincontracts/. Exit check: revert-on-trap and idempotency tests green.eventIndexerinserts,reconciler.tsdiff-and-repair overgetLedgerEntries/sendTransaction, SSE emission. Deliverable: integration tests on Postgres. Exit check: seeded half-applied fixture reachesreconciled.test:e2egreen. Exit check: score hidden until reconciled.Makefile, composereconcilerservice,reconciler-checkCI job, full container run with state dumps. Exit check: CI red on an unrepaired seeded divergence, green after repair.