Skip to content

[Cross-Layer] Cross-Contract Atomicity for Loan Disbursement and Credit Score Mutation #1377

Description

@ogazboiz

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:

  1. 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.
  2. 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):

  1. Backend calls loan_manager.approve(loan_id).
  2. 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.
  3. loan_manager invokes nft_client.try_update_score(borrower, delta).
  4. update_score traps: the borrower score entry was archived (persistent TTL expired) or the new score overflows under overflow-checks=true.
  5. The try_ call returns Err(NftError::...); loan_manager ignores it and returns Ok.
  6. Transaction commits. Principal moved; score unchanged.
  7. 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

  • Root-initiated make build compiles all three layers from a clean checkout.
  • Contract unit tests prove approve/repay/default revert fully when update_score traps (no partial custody change), using overflow-checks=true.
  • Contract test proves apply_pending_score and settle_intent are idempotent across duplicate intent_key submissions, with settle_intent reverting LoanError::IntentAlreadySettled on the second call.
  • Backend integration test (Postgres service) seeds a half_applied row and asserts reconciler.ts drives it to reconciled via a repair transaction, with attempts bounded.
  • eventIndexer test asserts one reconciliation row per custody event, matched by intent_key.
  • Frontend Playwright test:e2e asserts the score is hidden while state !== 'reconciled' and the badge clears on the SSE reconciliation_status event.
  • End-to-end container run (docker-compose) exercises the classic-payment split path and shows automatic repair.
  • PR attaches: pre/post getLedgerEntries state dumps for a repaired case, a cross_contract_reconciliation row dump, and CI logs for reconciler-check.

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.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26architectureArchitectural changesbackendIssues related to backend developmentcontractsIssues related to smart contractscriticalHigh-impact / hard issuefrontendIssues related to frontend developmentrustPull requests that update rust codesecuritySecurity related issues

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions