Skip to content

feat(#552): add timelocked upgradeability to vesting_escrow - #602

Merged
Wilfred007 merged 3 commits into
Protocol-Guild:mainfrom
balisdev:feat/552-vesting-upgradeability
Aug 24, 2026
Merged

feat(#552): add timelocked upgradeability to vesting_escrow#602
Wilfred007 merged 3 commits into
Protocol-Guild:mainfrom
balisdev:feat/552-vesting-upgradeability

Conversation

@balisdev

Copy link
Copy Markdown
Contributor

Closes #552

vesting_escrow had 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:

propose_upgrade(new_wasm_hash) -> executable_at
execute_upgrade()                 # only once executable_at has passed
cancel_upgrade()

Plus read-only get_pending_upgrade(), get_upgrade_admin(), get_upgrade_timelock().

Upgrade authority is a new role, DataKey::UpgradeAdmin, set at initialize time and 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. Auth goes through the existing common::require_admin.

Acceptance criteria

Criterion Where
Admin can propose upgrade with new WASM hash propose_upgrade, test_admin_can_propose_upgrade
Timelock (48h) before upgrade executes UPGRADE_TIMELOCK_SECONDS, test_upgrade_cannot_execute_before_timelock, test_upgrade_executes_exactly_at_timelock_expiry
Storage preserved across upgrades test_execute_upgrade_preserves_vesting_storage, test_vesting_continues_across_upgrade
Upgrade event emitted upgrade_proposed_event / upgrade_executed_event / upgrade_cancelled_event

Why 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_upgrade refuses 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_timelock asserts that.
  • The proposal is consumed before the WASM swap, so the incoming code never starts life holding a stale proposal it could replay.

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_vesting 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% 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) so cargo test needs no extra toolchain setup, and its crate is excluded from the workspace so stellar-scaffold build and the release workflow ignore it. test_fixtures/README.md has the two commands to regenerate it.

Compatibility

initialize takes one new trailing argument, upgrade_admin. Nothing else about vesting behaviour changes, and no existing storage key or type is touched. The frontend references vesting_escrow only 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 in vesting_escrow (12 new), all other crates unchanged and green
  • cargo build --release --target wasm32v1-none --workspace — clean
  • cargo clippy -p vesting_escrow --all-targets — no new warnings; initialize trips too_many_arguments at 10/7, as it already did at 9/7

Docs: docs/VESTING_UPGRADES.md covers the roles, the timelock rationale, the entry points and errors, what future versions must keep layout-compatible, and the stellar CLI calls for running an upgrade.

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.
@Wilfred007
Wilfred007 merged commit a80b727 into Protocol-Guild:main Aug 24, 2026
1 check passed
@Wilfred007

Copy link
Copy Markdown
Contributor

Thank you for contributing @balisdev

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.

Add upgradeability pattern to vesting_escrow contract

2 participants