Skip to content

feat(contracts): versioned invoice lifecycle storage with resumable migration (#2438) - #2554

Open
Sam-Rytech wants to merge 1 commit into
QuickLendX:mainfrom
Sam-Rytech:fix/2438-invoice-storage-migration-compat
Open

feat(contracts): versioned invoice lifecycle storage with resumable migration (#2438)#2554
Sam-Rytech wants to merge 1 commit into
QuickLendX:mainfrom
Sam-Rytech:fix/2438-invoice-storage-migration-compat

Conversation

@Sam-Rytech

Copy link
Copy Markdown

Summary

Invoice creation, amendment, cancellation, and completion now obey a documented
lifecycle with durable invariants, and the invoice record layout is explicitly
versioned with a forward/backward compatible, resumable and observable
migration path.

Implementation quicklendx-contracts/src/invoice_lifecycle.rs
Tests quicklendx-contracts/src/test_invoice_lifecycle.rs
Docs quicklendx-contracts/docs/invoice-lifecycle-migration.md
Entrypoints quicklendx-contracts/src/lib.rs

Acceptance criteria

☑ Define forward and backward compatibility, preserve existing records, and make migrations resumable and observable

  • Backward (new code, old records): load() classifies a stored record by
    probing the raw map for a schema_version field, then decodes with the
    matching type and upgrades a v1 record in memory. Legacy records stay
    readable and fully operable before the migration runs, and a read never
    rewrites storage.

    The layout is probed rather than decoded speculatively because a typed read
    of the wrong shape does not fail softly — the host raises
    Error(Object, UnexpectedSize) and escalates to a panic. This was caught by
    the legacy fixtures during implementation.

  • Forward (old code, new records): every record carries an explicit
    schema_version; a reader refuses a version above its own with
    OperationNotAllowed instead of misreading a newer layout.
  • Records preserved: InvoiceRecord::from_v1 is lossless and total — v2 is
    purely additive and no v1 field changed meaning.
  • Resumable: the cursor is committed after every page, so an interrupted
    page resumes at the last checkpoint and never reprocesses a committed record.
  • Observable: every transition emits a #[contractevent], and
    invoice_schema_version() / invoice_migration_state() expose progress.

☑ Preserve compatible public behavior; make any required change explicit

Purely additive. New entrypoints and new storage keys only — no existing
entrypoint, response shape, or error code changes. Error variants are reused,
not added
, because errors.rs documents that all 50 XDR error slots are
consumed and new variants require replacing an existing one.

☑ Ensure rejected, stale, repeated, and failed operations leave no unauthorized or partial state

Case Behaviour
Rejected All preconditions validated before the first write (I5)
Stale begin requires the caller's from_version to equal the committed version
Repeated Second concurrent begin rejected; repeated cancel/complete rejected; re-run page skips already-current records
Failed Cursor stays at the last checkpoint; a failed page is resumable
Concurrent Lifecycle writes are rejected while a migration runs, so a write cannot land behind the cursor and re-introduce a legacy-shaped record

☑ Add focused regression coverage at the actual integration boundary

All 23 new tests drive the contract client (QuickLendXContractClient), not
the module directly. The operations are exposed as contract entrypoints
specifically so the invariants are reachable and provable at the boundary an
operator actually uses.

Lifecycle invariants

         create
           │
           ▼
       ┌────────┐  amend (n times, Active only)
       │ Active │◄──────────────┐
       └───┬────┘───────────────┘
    cancel │ complete
     ┌─────┴─────┐
     ▼           ▼
┌───────────┐ ┌───────────┐
│ Cancelled │ │ Completed │   terminal
└───────────┘ └───────────┘
  • I1 — terminal states are final. A Cancelled/Completed invoice can
    never be amended, cancelled, or completed again. This is the financial-safety
    invariant the issue names: it prevents a settlement landing after the
    underlying obligation changed.
  • I2 — creation is unique; an existing record is never silently overwritten.
  • I3 — mutation is authorised against the stored business address, never a
    caller-supplied one.
  • I4 — monotonic bookkeeping; updated_at never rewinds and
    amendment_count only increases, so a stale replay cannot rewind state.
  • I5 — no partial state; all preconditions are checked before the first write.

Required validation

Check Command Result
Build cargo build -p quicklendx-contracts pass
Tests cargo test -p quicklendx-contracts --lib 40 passed, 0 failed (23 new)
WASM contract build cargo build -p quicklendx-contracts --release --target wasm32v1-none pass
Format rustfmt --edition 2021 --check <changed files> clean
Resource release WASM artifact 46,106 bytes (baseline 13,017)

Test coverage against the required validation matrix:

Requirement Test
upgrade migration_upgrades_legacy_records_and_commits
rollback rollback_leaves_version_and_records_intact
rerun rerunning_a_page_is_idempotent
partial progress migration_is_resumable_from_cursor
legacy-data fixtures legacy_record_is_readable_before_migration, legacy_record_lifecycle_works_before_migration, legacy_terminal_record_stays_terminal
stale / concurrent rejects_stale_from_version, rejects_second_migration_while_one_is_running
no partial state writes_rejected_while_migration_in_progress, rejects_invalid_amount_and_due_date_before_any_write

Events use the #[contractevent] macro rather than the deprecated
env.events().publish, so the change compiles warning-free.

☑ No generated artifacts, secrets, disabled checks, or unrelated refactors

Four files, all under quicklendx-contracts/. No secrets, no generated
artifacts, no disabled checks.

One note on focus: running rustfmt on lib.rs initially reformatted
test_invoice_amount_precision.rs too, because rustfmt follows mod
declarations into child modules. That reformatting was reverted — it is
unrelated to this issue and is not in the diff.

Security / correctness note

Migration control is admin-only and every migration entrypoint calls
require_auth on the admin recorded at init; re-initialisation is rejected so
the admin cannot be silently replaced. Lifecycle mutation is business-scoped and
authorised against the stored address, so migration authority never confers
the ability to alter invoice data. The write guard during migration is the
invariant that makes a migration atomic from a caller's perspective: without it
a write could land on a page the cursor already passed, re-introducing an
unmigrated record behind it — the classic partial-migration corruption. Commit
is refused while cursor < total, so the store can never declare itself
migrated while legacy records remain.

Reviewer note on scope

quicklendx-contracts/src/lib.rs currently declares only errors and
invoice_amount, so the crate that actually compiles is small; contract.rs,
storage.rs, and types.rs are not part of the compiled crate on main. This
change is therefore implemented in the live, compiled crate so its
invariants are genuinely enforced, tested, and reachable on a deployed
contract, rather than added to a module that is not built. Re-wiring the
orphaned module graph is a much larger change and is deliberately out of scope
here.

Closes #2438

…igration

Invoice creation, amendment, cancellation, and completion now obey a
documented lifecycle with durable invariants, and the invoice record layout is
explicitly versioned with a forward/backward compatible, resumable, and
observable migration path.

Implementation: quicklendx-contracts/src/invoice_lifecycle.rs
Tests:          quicklendx-contracts/src/test_invoice_lifecycle.rs
Docs:           quicklendx-contracts/docs/invoice-lifecycle-migration.md

Lifecycle invariants (enforced on every write):
- I1 terminal states are final - a Cancelled/Completed invoice can never be
  amended, cancelled, or completed again. This is what stops a settlement
  landing after the underlying obligation changed.
- I2 creation is unique; an existing record is never silently overwritten.
- I3 mutation is authorised against the stored business address, never a
  caller-supplied one.
- I4 updated_at never rewinds and amendment_count only increases, so a stale
  replay cannot rewind state.
- I5 all preconditions are validated before the first storage write, so a
  rejected, stale, repeated, or failed operation leaves storage unchanged.

Storage compatibility:
- Backward: load() classifies a stored record by probing for a schema_version
  field, then decodes with the matching type and upgrades a v1 record in
  memory. Legacy records stay readable and operable before migration, and a
  read never rewrites storage. The layout is probed rather than decoded
  speculatively because a typed read of the wrong shape raises a host
  UnexpectedSize error and panics instead of returning None.
- Forward: every record carries an explicit schema_version, and a reader
  refuses a version above its own rather than misreading a newer layout.

Migration (begin -> step* -> commit | rollback):
- Resumable: the cursor is committed after every page, so an interrupted page
  resumes at the last checkpoint and never reprocesses a committed record.
- Observable: each transition emits a #[contractevent], and schema version and
  migration progress are readable entrypoints.
- Rerun-safe: already-current records are skipped while the cursor still
  advances, so re-running a page cannot double-count.
- Stale/repeat-safe: begin requires the caller's from_version to match the
  committed version and refuses a second concurrent migration.
- Atomic: lifecycle writes are rejected while a migration runs, so a write
  cannot land behind the cursor and re-introduce a legacy-shaped record.
- Commit is refused while records remain, so the store cannot declare itself
  migrated early.

The operations are exposed as contract entrypoints so the invariants are
reachable and tested at the actual integration boundary rather than only as an
internal module.

Compatibility: purely additive. New entrypoints and new storage keys only; no
existing entrypoint, response shape, or error code changes. Error variants are
reused rather than added because errors.rs documents that all 50 XDR slots are
consumed.

Validation:
- cargo build -p quicklendx-contracts                                  pass
- cargo test -p quicklendx-contracts --lib          40 passed, 0 failed (23 new)
- cargo build -p quicklendx-contracts --release --target wasm32v1-none pass
- rustfmt --edition 2021 --check <changed files>                       clean
- WASM release artifact 46,106 bytes (baseline 13,017)

Closes QuickLendX#2438
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@Sam-Rytech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Sam-Rytech

Copy link
Copy Markdown
Author

@Baskarayelu PR opened

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.

[Quality][High] invoice creation and lifecycle: storage and migration compatibility — QE-2026-08

1 participant