feat(contracts): versioned invoice lifecycle storage with resumable migration (#2438) - #2554
Open
Sam-Rytech wants to merge 1 commit into
Open
Conversation
…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
|
@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! 🚀 |
Author
|
@Baskarayelu PR opened |
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.
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.
quicklendx-contracts/src/invoice_lifecycle.rsquicklendx-contracts/src/test_invoice_lifecycle.rsquicklendx-contracts/docs/invoice-lifecycle-migration.mdquicklendx-contracts/src/lib.rsAcceptance criteria
☑ Define forward and backward compatibility, preserve existing records, and make migrations resumable and observable
load()classifies a stored record byprobing the raw map for a
schema_versionfield, then decodes with thematching 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.
schema_version; a reader refuses a version above its own withOperationNotAllowedinstead of misreading a newer layout.InvoiceRecord::from_v1is lossless and total — v2 ispurely additive and no v1 field changed meaning.
page resumes at the last checkpoint and never reprocesses a committed record.
#[contractevent], andinvoice_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.rsdocuments that all 50 XDR error slots areconsumed and new variants require replacing an existing one.
☑ Ensure rejected, stale, repeated, and failed operations leave no unauthorized or partial state
beginrequires the caller'sfrom_versionto equal the committed versionbeginrejected; repeated cancel/complete rejected; re-run page skips already-current records☑ Add focused regression coverage at the actual integration boundary
All 23 new tests drive the contract client (
QuickLendXContractClient), notthe 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
Cancelled/Completedinvoice cannever 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.
businessaddress, never acaller-supplied one.
updated_atnever rewinds andamendment_countonly increases, so a stale replay cannot rewind state.Required validation
cargo build -p quicklendx-contractscargo test -p quicklendx-contracts --libcargo build -p quicklendx-contracts --release --target wasm32v1-nonerustfmt --edition 2021 --check <changed files>Test coverage against the required validation matrix:
migration_upgrades_legacy_records_and_commitsrollback_leaves_version_and_records_intactrerunning_a_page_is_idempotentmigration_is_resumable_from_cursorlegacy_record_is_readable_before_migration,legacy_record_lifecycle_works_before_migration,legacy_terminal_record_stays_terminalrejects_stale_from_version,rejects_second_migration_while_one_is_runningwrites_rejected_while_migration_in_progress,rejects_invalid_amount_and_due_date_before_any_writeEvents use the
#[contractevent]macro rather than the deprecatedenv.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 generatedartifacts, no disabled checks.
Security / correctness note
Migration control is admin-only and every migration entrypoint calls
require_authon the admin recorded at init; re-initialisation is rejected sothe 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 itselfmigrated while legacy records remain.
Reviewer note on scope
quicklendx-contracts/src/lib.rscurrently declares onlyerrorsandinvoice_amount, so the crate that actually compiles is small;contract.rs,storage.rs, andtypes.rsare not part of the compiled crate onmain. Thischange 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