Skip to content

feat: one-shot factory initialization and upgrade state preservation (#1141, #1149) - #1204

Open
temitope-007 wants to merge 1 commit into
Creditra:mainfrom
temitope-007:feat/1141-1149-init-replay-and-upgrade-state
Open

temitope-007 wants to merge 1 commit into
Creditra:mainfrom
temitope-007:feat/1141-1149-init-replay-and-upgrade-state

Conversation

@temitope-007

Copy link
Copy Markdown

Summary

Two initialization/upgrade safety gaps, both of which allowed a repeated or
partially-failed operation to leave the protocol in a state nobody could detect
or recover from.

Issue Area Implementation Tests
#1141 Auction contract gateway-contract/contracts/auction_contract/src/lib.rs tests/factory_init_replay.rs
#1149 Credit contract contracts/credit/src/upgrade_migration.rs contracts/credit/tests/upgrade_migration_state.rs

#1141 — Prevent replay of factory initialization calls

The gap

set_factory_contract was an unbounded setter. The same initialization call
could be submitted repeatedly, and a stale but still-valid authorization could
re-point the factory long after deployment — and the factory controls
init_auction, close_auction, and settle_default_liquidation.

The fix

Registration is one-shot. The first success sets a persistent
FactoryInitialized marker; every later call reverts
FactoryAlreadyInitialized (15) and mutates nothing. The barrier is checked
before the auth check and before any write, so a replayed call leaves
storage byte-identical.

The barrier keys off the marker, not the presence of an address. A rotation
that re-points the slot therefore cannot reopen the initialization window and
let an unprivileged address claim the factory role a second time — the property
rotation_does_not_reopen_initialization pins.

Deliberate replacement — the case the old setter served implicitly — moves to
rotate_factory_contract, which requires both the outgoing and the
incoming factory to authorize, so a hand-over can neither be forced on an
unwilling successor nor taken unilaterally.

Compatibility

set_factory_contract keeps its signature and its first-time behaviour. The
only behavioural withdrawal is replacement through that entrypoint, which is
replaced by the dedicated, strictly-stronger rotate_factory_contract.
is_factory_initialized lets a client distinguish "not yet initialized" from
"initialized" without provoking a rejection.


#1149 — Preserve credit-line state through upgrade migrations

The gap

upgrade bumped the schema version blind:

  • nothing recorded what state looked like beforehand, so a swap that lost or
    silently re-interpreted credit-line records was undetectable;
  • the "previous" wasm hash written to the upgrade event was a zero sentinel,
    leaving no rollback target;
  • nothing stopped a second upgrade being stacked on a migration that had
    already gone wrong.

The fix

upgrade now records an UpgradeCheckpoint — schema versions,
CreditLineCount, TotalUtilized, wasm hashes, timestamps — before the
schema bump and the wasm swap (UP-1); capturing them afterwards would
compare the new binary against itself and prove nothing.

  • verify_upgrade_migration compares live aggregates against the checkpoint,
    clearing it on success. On mismatch it reverts UpgradeStateMismatch and
    deliberately retains the checkpoint (UP-4) — silently repairing a
    mismatch would destroy the evidence that records were lost.
  • A second upgrade while a checkpoint is outstanding reverts
    UpgradeVerificationPending (UP-2), so a failed migration cannot be
    compounded or its evidence overwritten.
  • rollback_upgrade restores the pre-upgrade schema version. It deliberately
    does not re-swap the wasm: a binary that just failed its own migration
    cannot be trusted to deploy its replacement, and Soroban has no atomic
    revert-to-previous-hash primitive. The returned checkpoint carries
    previous_wasm_hash so an operator redeploys the known-good binary
    explicitly. Documenting that boundary is the point — an operator must not
    believe rollback is a complete undo.

Aggregates are used rather than a per-borrower scan because the scan cost is
unbounded in the number of credit lines, while these two accumulators are
already maintained as invariants over every credit-line mutation: if any record
were lost or misread, at least one of them moves.


Acceptance criteria

  • Deterministic for valid / invalid / duplicate / boundary inputs — valid:
    first_registration_succeeds_and_marks_initialized,
    upgrade_records_checkpoint_of_pre_upgrade_state; duplicate:
    second_registration_is_rejected, replaying_same_factory_is_rejected,
    second_upgrade_is_blocked_until_resolved; invalid:
    rotate_before_initialization_is_rejected, verify_without_checkpoint_is_rejected,
    rollback_without_checkpoint_is_rejected; boundary:
    checkpoint_captures_zero_state_on_fresh_contract.
  • Authorization / validation / state-transition invariants enforced
    rotation_requires_both_parties proves a successor cannot seize the role;
    both upgrade mutators remain admin-gated.
  • Retries, partial failure, and concurrency safe
    rejected_replay_leaves_factory_unchanged and
    rejected_second_upgrade_leaves_checkpoint_intact assert the state is
    byte-identical after a rejected retry.
  • Existing callers compatible — see the [Quality-2][High] Prevent replay of factory initialization calls #1141 compatibility note; [Quality-2][High] Preserve credit-line state through upgrade migrations #1149 is
    purely additive (new entrypoints and one new storage key).
  • Failures diagnosable without exposing sensitive data — stable appended
    discriminants (factory_errors_are_stable, upgrade_errors_are_classified);
    the checkpoint view carries only aggregates and hashes, never borrower
    addresses or amounts (checkpoint_exposes_only_aggregate_state).

Validation

Check Result
cargo test -p gateway-auction --test factory_init_replay 10 passed, 0 failed
cargo test -p creditra-credit --test upgrade_migration_state 12 passed, 0 failed
cargo test -p gateway-auction --test factory_auth (pre-existing) 14 passed, 0 failed
cargo test -p gateway-auction --test err_stab (pre-existing) 21 passed, 0 failed
cargo check -p gateway-auction --lib / -p creditra-credit --lib 0 errors
cargo test -p gateway-auction --lib 89 passed / 6 failed — identical to baseline

The six liquidation_grace_window failures are pre-existing. I measured the
baseline by applying only the two repository repairs below to clean main,
without either feature, and got the identical 89/6 — so these changes
introduce no new failures
.

A note on test methodology for #1149

upgrade cannot be driven to completion in-process: it ends in
update_current_contract_wasm, which needs a genuinely uploaded wasm binary,
and a failure there reverts the whole invocation including the checkpoint
write
. That revert is the correct atomic behaviour, so rather than weaken it
for testability, the tests call the same record_checkpoint the entrypoint
calls, in the contract's own storage context, and exercise every downstream
path through the client. The UP-2 guard is still proven at the entrypoint by
second_upgrade_is_blocked_until_resolved, because that guard runs before the
swap is ever reached.

Repository repairs required to build

Two byte-identical merge duplicates on main block every build. Neither
relates to these issues, but nothing compiles without them, so no validation of
any kind was possible until they were fixed. Both are removals of an exact
duplicate — no semantic choice was involved:

  1. Cargo.lock listed package creditra-credit twice, so cargo could not
    parse the lockfile at all.
  2. contracts/credit/src/lib.rs defined pub fn init twice in one
    #[contractimpl], producing 9 compile errors.

Happy to split these into a separate PR if maintainers prefer.

Security and failure-mode note

For #1141 the ordering is the security property: the replay barrier precedes
the auth check, so a replayed call cannot even consume an authorization, and
the marker is never cleared so the one-shot window cannot be reopened by any
later rotation. For #1149 the checkpoint is both the evidence and the recovery
target, which is why a failed verification retains it and why a stacked upgrade
is refused rather than allowed to overwrite it. Every rejection path validates
before its first write, and a Soroban panic reverts the transaction, so a
rejected initialization, upgrade, verification, or rollback leaves storage
unchanged.

Closes #1141
Closes #1149

Closes two initialization/upgrade safety gaps.

Creditra#1141 Prevent replay of factory initialization calls
  gateway-contract/contracts/auction_contract/

set_factory_contract was an unbounded setter, so the same initialization call
could be submitted repeatedly and a stale but still-valid authorization could
re-point the factory long after deployment. Registration is now one-shot: the
first success sets a persistent FactoryInitialized marker and every later call
reverts FactoryAlreadyInitialized (15) without mutating state. The barrier is
checked before the auth check and before any write, so a replay leaves storage
byte-identical.

The barrier keys off the marker, not the presence of an address, so a rotation
that re-points the slot cannot reopen the initialization window and let an
unprivileged address claim the factory role a second time.

Deliberate replacement - the case the old setter served implicitly - moves to
rotate_factory_contract, which requires both the outgoing and the incoming
factory to authorize, so a hand-over can neither be forced on an unwilling
successor nor taken unilaterally. is_factory_initialized exposes deployment
state without provoking a rejection.

Compatibility: set_factory_contract keeps its signature and first-time
behaviour; only replacement through it is withdrawn, and that path is replaced
by the dedicated entrypoint above.

Creditra#1149 Preserve credit-line state through upgrade migrations
  contracts/credit/src/upgrade_migration.rs

upgrade bumped the schema version blind: nothing recorded what state looked
like beforehand, so a swap that lost or re-interpreted credit-line records was
undetectable, and the "previous" wasm hash written to the event was a zero
sentinel, leaving no rollback target.

upgrade now records an UpgradeCheckpoint - schema versions, CreditLineCount,
TotalUtilized, wasm hashes - before the schema bump and the wasm swap
(UP-1); capturing them afterwards would compare the new binary against itself.
verify_upgrade_migration compares the live aggregates against the checkpoint
and clears it on success. On mismatch it reverts UpgradeStateMismatch and
deliberately retains the checkpoint (UP-4), because silently repairing a
mismatch would destroy the evidence that records were lost. A second upgrade
while a checkpoint is outstanding reverts UpgradeVerificationPending (UP-2), so
a failed migration cannot be compounded or its evidence overwritten.
rollback_upgrade restores the pre-upgrade schema version; it deliberately does
not re-swap the wasm, and the returned checkpoint carries previous_wasm_hash so
an operator redeploys the known-good binary explicitly. The checkpoint view
carries only aggregates and hashes, never per-borrower data.

Repository repairs required to build (byte-identical merge duplicates on main,
unrelated to either issue but blocking every build):
- Cargo.lock listed package `creditra-credit` twice, so cargo could not parse
  the lockfile at all.
- contracts/credit/src/lib.rs defined `pub fn init` twice in one
  #[contractimpl], producing 9 compile errors.

Validation:
- cargo test -p gateway-auction --test factory_init_replay   10 passed, 0 failed
- cargo test -p creditra-credit --test upgrade_migration_state 12 passed, 0 failed
- cargo test -p gateway-auction --test factory_auth           14 passed, 0 failed
- cargo test -p gateway-auction --test err_stab               21 passed, 0 failed
- cargo check -p gateway-auction --lib / -p creditra-credit --lib   0 errors
- cargo test -p gateway-auction --lib   89 passed / 6 failed, identical to the
  baseline measured with the two repairs and without these changes; the six
  liquidation_grace_window failures are pre-existing.

Closes Creditra#1141
Closes Creditra#1149
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@temitope-007 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

@temitope-007

Copy link
Copy Markdown
Author

@greatest0fallt1me check out the PR

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-2][High] Preserve credit-line state through upgrade migrations [Quality-2][High] Prevent replay of factory initialization calls

1 participant