feat(#552): add timelocked upgradeability to vesting_escrow - #602
Merged
Wilfred007 merged 3 commits intoAug 24, 2026
Merged
Conversation
Vesting schedules run for months or years, so a bug found mid-schedule had no remedy: the contract had no upgrade path, and the only way out was to claw back every grant and re-create it under a fresh contract. Worse, a bug that blocked clawback would have locked the escrowed funds permanently. Add a two-step upgrade mechanism gated on a 48h timelock: propose_upgrade(wasm_hash) -> executable_at execute_upgrade() -- only once executable_at has passed cancel_upgrade() The delay is what makes upgradeability safe to grant at all. The upgrade admin can replace the code that governs beneficiary funds, so beneficiaries need a window in which they can see a pending change on-chain and claim their vested tokens before it lands. Ledger timestamps are Unix seconds in UTC, so the 48 hours are exact wall-clock hours no matter where the proposer sits. Two details worth calling out: - propose_upgrade refuses to overwrite a pending proposal. Overwriting would let an admin substitute a different hash without restarting the clock, so a beneficiary who reviewed the queued WASM could find something else entirely landing at the original deadline. Cancelling and re- proposing always restarts the full 48 hours. - The proposal is consumed before the WASM swap, so the incoming code never starts life holding a stale proposal it could replay. Upgrading swaps only the executable; the vesting config, claimed amounts and escrowed balance are left untouched, so in-flight schedules keep running with no migration step. While a proposal is pending the instance TTL is extended past the timelock so it cannot be archived before it can be executed. Upgrade authority is a new role (DataKey::UpgradeAdmin), deliberately separate from the config's clawback_admin: the employer funding a grant should not be able to swap out the escrow's code, and the platform running the escrow should not be able to revoke grants.
…age guarantees Twelve tests over the upgrade path: admin-only access on all three entry points, the proposal round-trip, both sides of the 48h boundary, event emission, single-proposal enforcement, cancellation restarting the clock, and vesting continuing to accrue while an upgrade sits in its timelock. Storage preservation is the claim that is easiest to assert vacuously, so the tests upgrade to a real second executable rather than a stub. Add test_fixtures/upgraded_vesting: a standalone contract that re-declares VestingConfig and DataKey and exposes a version() that only it has. After the swap the tests read the pre-upgrade config back through the *upgraded* client, so a layout change surfaces as a decode failure instead of passing silently, and version() proves the executable really changed. One test claims 20% first, so what survives the upgrade is genuine in-flight state, not a fresh grant. The fixture is committed as a .wasm so `cargo test` needs no extra toolchain setup, and its crate is excluded from the workspace so scaffold builds and the release workflow ignore it. See test_fixtures/README.md for how to regenerate it. Note for anyone extending these tests: Env::events() only reports the most recent invocation, so event assertions have to come before any further contract call.
Covers the two admin roles and why they are separate, why the timelock exists rather than just what it is, the entry points and their errors, what "storage is preserved" obliges future versions to keep compatible, the emitted events, and the stellar CLI calls for running an upgrade.
4 tasks
Contributor
|
Thank you for contributing @balisdev |
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.
Closes #552
vesting_escrowhad no upgrade path. Schedules run for months or years, so a bug found mid-schedule meant clawing back every grant and re-creating it under a fresh contract — and a bug that broke clawback itself would have locked the escrowed funds permanently.What this adds
A two-step upgrade gated on a 48-hour timelock:
Plus read-only
get_pending_upgrade(),get_upgrade_admin(),get_upgrade_timelock().Upgrade authority is a new role,
DataKey::UpgradeAdmin, set atinitializetime and deliberately separate from the config'sclawback_admin: the employer funding a grant should not be able to swap out the escrow's code, and the platform running the escrow should not be able to revoke grants. Auth goes through the existingcommon::require_admin.Acceptance criteria
propose_upgrade,test_admin_can_propose_upgradeUPGRADE_TIMELOCK_SECONDS,test_upgrade_cannot_execute_before_timelock,test_upgrade_executes_exactly_at_timelock_expirytest_execute_upgrade_preserves_vesting_storage,test_vesting_continues_across_upgradeupgrade_proposed_event/upgrade_executed_event/upgrade_cancelled_eventWhy the timelock, and two details behind it
The delay is what makes upgradeability safe to grant at all. The upgrade admin can replace the code that governs beneficiary funds, so beneficiaries need a window in which they can see a pending change on-chain and claim their vested tokens before it lands. Ledger timestamps are Unix seconds in UTC, so the 48 hours are exact wall-clock hours regardless of where the proposer sits.
Two decisions that follow from taking that window seriously:
propose_upgraderefuses to overwrite a pending proposal (UpgradeAlreadyPending) rather than replacing it silently. Overwriting would let an admin substitute a different hash without restarting the clock, so a beneficiary who reviewed the queued WASM could find something else entirely landing at the original deadline. Cancel and re-propose restarts the full 48 hours —test_cancel_upgrade_restarts_the_full_timelockasserts that.While a proposal is pending the instance TTL is extended past the timelock, so it cannot be archived before it becomes executable.
Storage preservation is tested against a real second executable
"Storage is preserved" is the easiest claim to assert vacuously, so these tests upgrade to an actual second contract rather than a stub.
contracts/vesting_escrow/test_fixtures/upgraded_vestingre-declaresVestingConfigandDataKeyand exposes aversion()that only it has. After the swap the tests read the pre-upgrade config back through the upgraded client, so a layout change surfaces as a decode failure instead of passing silently, andversion()proves the executable really changed. One test claims 20% of the grant first, so what survives the upgrade is genuine in-flight state — beneficiary, schedule,claimed_amount, clawback admin, upgrade admin and the escrowed token balance — not a fresh grant.The fixture is committed as a
.wasm(2.7 KB) socargo testneeds no extra toolchain setup, and its crate isexcluded from the workspace sostellar-scaffold buildand the release workflow ignore it.test_fixtures/README.mdhas the two commands to regenerate it.Compatibility
initializetakes one new trailing argument,upgrade_admin. Nothing else about vesting behaviour changes, and no existing storage key or type is touched. The frontend referencesvesting_escrowonly by name string (contracts.types.ts,transactionHistory.ts), never through the generated typed client, so no frontend changes are needed.Out of scope per the issue: governance-controlled upgrades. Upgrade-admin rotation is also left out — worth a follow-up.
Verification
cargo test --workspace— 13/13 investing_escrow(12 new), all other crates unchanged and greencargo build --release --target wasm32v1-none --workspace— cleancargo clippy -p vesting_escrow --all-targets— no new warnings;initializetripstoo_many_argumentsat 10/7, as it already did at 9/7Docs:
docs/VESTING_UPGRADES.mdcovers the roles, the timelock rationale, the entry points and errors, what future versions must keep layout-compatible, and thestellarCLI calls for running an upgrade.