Skip to content

Timelocked upgrade pattern for all three contracts - #63

Merged
codemagician1949 merged 1 commit into
IntensifierLab:mainfrom
CelestinaBeing:feat/upgrade-timelock-pattern-32
Aug 21, 2026
Merged

codemagician1949 merged 1 commit into
IntensifierLab:mainfrom
CelestinaBeing:feat/upgrade-timelock-pattern-32

Conversation

@CelestinaBeing

Copy link
Copy Markdown
Contributor

Overview

Adds a proxy-upgradability-equivalent pattern to pool-manager, invoice-registry, and repayment-waterfall: queue_upgrade / execute_upgrade / cancel_upgrade / set_upgrade_paused, admin-gated (pool-manager reuses its existing require_timelock_admin / Address + require_auth() mechanism; the other two use their existing Symbol-based admin check).

Design rationale (documented in each contract's own doc comments)

Soroban contracts are natively upgradable in place via env.deployer().update_current_contract_wasm(hash), and persistent/instance storage is automatically preserved across that call — storage lives at the contract's address, not in a separate delegatecall-style proxy. So the idiomatic Soroban equivalent of "proxy holds storage, logic contract swappable" is a same-contract Wasm-hash swap, not a separate proxy contract forwarding calls — that's what's implemented here, not a shortcut. "Storage layout migration" under this model means the next version's storage reads must stay backward-compatible with what the prior version wrote, since there's no separate proxy storage to explicitly migrate.

API (per contract)

  • queue_upgrade(new_wasm_hash) -> execute_after — gated by that contract's existing admin mechanism; 48h delay.
  • execute_upgrade() — callable by anyone once the timelock elapses; errors if nothing's queued, the timelock hasn't elapsed, or upgrades are emergency-paused.
  • cancel_upgrade() — admin-gated, clears the queue.
  • set_upgrade_paused(bool) — admin-gated; while paused, execute_upgrade is blocked (queueing/cancelling still work).

Each added as a separate #[contractimpl] block per contract, so this doesn't touch any contract's existing entrypoints region — keeps it independent of the other invoice-registry PRs in flight (#60 multisig, #62 NFT tokenisation).

Tests

Mirrored across all three contracts: queue requires admin, queue sets the correct 48h-out execute_after, execute before the timelock elapses errors, execute with nothing queued errors, cancel clears the queue, execute while paused errors even after the timelock has elapsed.

Actually driving execute_upgrade to a successful Wasm swap needs a real uploaded Wasm hash to target, which this workspace's native-registered test fixtures don't produce — that specific path relies on Soroban's documented storage-persistence guarantee rather than being re-proven per PR (noted in the test module's doc comment, honestly scoped rather than faked).

Verification

  • cargo check --workspace — clean.
  • cargo test -p pool-manager — 29/29. cargo test -p invoice-registry — 28/28. cargo test -p repayment-waterfall — 13/13.
  • cargo test --workspace — full clean run, 0 failures.

Closes #32

@CelestinaBeing
CelestinaBeing force-pushed the feat/upgrade-timelock-pattern-32 branch from c826028 to a4cb559 Compare August 19, 2026 15:08
@CelestinaBeing

Copy link
Copy Markdown
Contributor Author

Thanks for taking a swing at the conflict here @codemagician1949! I'd already rebased locally with a fix, and noticed the merge commit didn't quite resolve it cleanly — invoice-registry's ContractError ended up with two variants sharing discriminant 8 (NoQueuedUpgrade and NotVerifier), which doesn't compile. Force-pushed my rebase over it, renumbering the new variants to continue after the existing ones. cargo check --workspace and cargo test --workspace both pass clean now. Sorry for the churn on your branch state, and thanks again for jumping in!

Adds a proxy-upgradability-equivalent pattern to pool-manager,
invoice-registry, and repayment-waterfall: queue_upgrade /
execute_upgrade / cancel_upgrade / set_upgrade_paused, each admin-
gated (pool-manager reuses its existing require_timelock_admin/
Address+require_auth mechanism; invoice-registry and
repayment-waterfall use their existing Symbol-based admin check).

Design rationale (documented in each contract's own doc comments):
Soroban contracts are natively upgradable in place via
env.deployer().update_current_contract_wasm(hash), and persistent/
instance storage is automatically preserved across that call -
storage lives at the contract's address, not in a separate
delegatecall-style proxy. So the idiomatic Soroban equivalent of
"proxy holds storage, logic contract swappable" is a same-contract
Wasm-hash swap, not a separate proxy contract forwarding calls -
that's what's implemented here, not a shortcut. "Storage layout
migration" under this model means the next version's storage reads
must stay backward-compatible with what the prior version wrote,
since there's no separate proxy storage to explicitly migrate.

Per contract:
- queue_upgrade(new_wasm_hash) -> execute_after timestamp, gated by
  the same admin mechanism as that contract's existing timelocked
  actions (pool-manager: 48h, matching TIMELOCK_SECS; the other two:
  new UPGRADE_TIMELOCK_SECS = 48h, since neither had an existing
  timelock constant to reuse).
- execute_upgrade() - callable by anyone once the timelock elapses;
  errors if nothing's queued, the timelock hasn't elapsed, or
  upgrades are emergency-paused.
- cancel_upgrade() - admin-gated, clears the queue.
- set_upgrade_paused(bool) - admin-gated; while paused, execute_upgrade
  is blocked (queueing/cancelling still work).

Added as a separate #[contractimpl] impl block per contract (Rust
allows multiple impl blocks per type) so this doesn't touch each
contract's existing entrypoints region.

Tests (mirrored across all three) cover: queue requires admin, queue
sets the correct 48h-out execute_after, execute before the timelock
elapses errors, execute with nothing queued errors, cancel clears the
queue, and execute while paused errors even after the timelock has
elapsed. Actually driving execute_upgrade to a successful Wasm swap
needs a real uploaded Wasm hash to target, which the existing
native-registered test fixtures in this workspace don't produce, so
that specific path relies on Soroban's documented storage-persistence
guarantee rather than being re-proven per PR (noted in the test
module's doc comment).

Closes IntensifierLab#32
@CelestinaBeing
CelestinaBeing force-pushed the feat/upgrade-timelock-pattern-32 branch from a4cb559 to 536970f Compare August 19, 2026 19:06
@codemagician1949
codemagician1949 merged commit 6e8ada5 into IntensifierLab:main Aug 21, 2026
3 checks passed
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.

feat(contract): design proxy upgradability pattern for all three contracts

2 participants