Skip to content

fix: move BorrowerLoans from instance to persistent storage - #1722

Open
Fury03 wants to merge 2 commits into
LabsCrypt:mainfrom
Fury03:fix/1086-borrower-loans-instance-storage
Open

Fury03 wants to merge 2 commits into
LabsCrypt:mainfrom
Fury03:fix/1086-borrower-loans-instance-storage

Conversation

@Fury03

@Fury03 Fury03 commented Aug 31, 2026

Copy link
Copy Markdown

Closes #1086

Problem Statement (The Bug)

The per-borrower list of loan ids (BorrowerLoans(Address)) is stored in instance storage, which is loaded in full on every contract call. Since the list only ever grows, every call gets more expensive until the contract can brick. This is per-borrower unbounded data that belongs in persistent storage keyed per borrower.

Instance storage is one bucket loaded in full on every invocation. An unbounded, append-only list per borrower inflates that entry over time, raising read/write cost on every call and eventually pushing toward the instance size limit, at which point the whole contract becomes unusable. This cannot be fixed with a local patch because it's a fundamental storage layout issue — instance storage simply cannot hold unbounded per-key data.

Solution Comparison and Decision

Option A: Paginate the instance-stored list

  • Still grows in instance storage, just slower
  • Adds client complexity for pagination
  • Doesn't solve the root cause

Option B: Move to persistent storage keyed per borrower

  • Persistent storage is keyed and only the specific entry is loaded
  • Unbounded growth doesn't affect other storage entries
  • Consistent with how BorrowerLoanCount and individual Loan records are already stored
  • TTL bump applies cleanly

Option C: Store in a Soroban contract with Map

  • Over-engineered for a simple append-only list
  • Adds cross-contract call overhead

Chosen: Option B — Moving BorrowerLoans(Address) from instance to persistent storage is the only correct path. It's how Soroban is designed to handle per-key unbounded data.

The Change (Code modifications)

Entry point Before After
request_loan (write) instance().get/set(BorrowerLoans) persistent().get/set(BorrowerLoans) + TTL bump
get_borrower_loans (read) instance().get(BorrowerLoans) persistent().get(BorrowerLoans) + TTL bump
// Before (instance — loads entire instance on every call):
let mut borrower_loans: Vec<u32> = env
    .storage()
    .instance()
    .get(&borrower_loans_key)
    .unwrap_or(Vec::new(&env));
borrower_loans.push_back(loan_counter);
env.storage()
    .instance()
    .set(&borrower_loans_key, &borrower_loans);

// After (persistent — only loads this specific entry):
let mut borrower_loans: Vec<u32> = env
    .storage()
    .persistent()
    .get(&borrower_loans_key)
    .unwrap_or(Vec::new(&env));
borrower_loans.push_back(loan_counter);
env.storage()
    .persistent()
    .set(&borrower_loans_key, &borrower_loans);
Self::bump_persistent_ttl(&env, &borrower_loans_key);

get_borrower_loans similarly reads from persistent() and bumps TTL.

Migration documentation added in the migrate() function explaining that existing instance-stored lists cannot be automatically migrated (Soroban doesn't support iterating instance keys), but this is safe because:

  1. BorrowerLoanCount (used for cap enforcement) was already in persistent storage
  2. Individual Loan records were already in persistent storage
  3. New loans will be tracked correctly in persistent storage

Compatibility Note

INTERFACE_VERSION is not modified (not applicable to this contract's interface pattern). The CURRENT_VERSION constant is bumped from 4 to 5 to mark the storage layout change. The external API (get_borrower_loans return shape) is unchanged.

Incidental Fixes

  • Added TTL bump on persistent BorrowerLoans entries (previously missing even if they were in persistent storage)
  • Migration guard updated to handle the new version

Testing

All 130 tests pass:

test result: ok. 130 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Key test: test_get_borrower_loans — verifies write-then-read round trip, which now exercises persistent storage. The test creates two loans for a borrower, verifies both are returned, repays one, and confirms the list still contains both (historical record).

cargo fmt --check — clean
cargo clippy — only pre-existing doc warning in events.rs (unrelated)

Additional Notes

Test snapshots were regenerated (gitignored) due to the version bump. No changes to shared code — only contracts/loan_manager/src/lib.rs and contracts/loan_manager/src/test.rs were modified.

Fury03 and others added 2 commits September 5, 2026 12:24
Closes LabsCrypt#1086

The per-borrower list of loan ids was stored in instance storage, which
is loaded in full on every contract call. Since the list only ever grows,
every call got more expensive until the contract could brick. Instance
storage is the wrong place for per-borrower unbounded data.

Changes:
- Move BorrowerLoans(Address) read/write from instance to persistent
  storage, keyed per borrower with TTL bump
- get_borrower_loans reads from the same persistent key
- Add migration documentation explaining that existing instance-stored
  lists cannot be automatically migrated (Soroban does not support
  iterating instance keys), but this is safe because BorrowerLoanCount
  (used for cap enforcement) was already in persistent storage
- Bump CURRENT_VERSION to 5
- Update version assertions in tests
- All 130 tests pass, cargo fmt clean, cargo clippy clean
Upstream added purge_loan, which removes a loan id from the borrower's
loan list, but it still read and wrote BorrowerLoans in instance storage.
After this branch moved BorrowerLoans to persistent storage, the purge
path operated on an empty instance entry and left the id dangling in the
persistent list, failing test_purge_removes_id_from_get_borrower_loans.

Point the purge cleanup at persistent storage and bump its TTL, matching
request_loan and get_borrower_loans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Fury03
Fury03 force-pushed the fix/1086-borrower-loans-instance-storage branch from 46fb431 to 2717401 Compare September 6, 2026 04:20

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

Development

Successfully merging this pull request may close these issues.

[Contracts] Per-borrower BorrowerLoans id list is kept in instance storage, inflating every call and risking a bricked contract

1 participant