Skip to content

feat(timelock): reconstruct the crate — three roles, predecessors, derived ids - #295

Merged
techisigu merged 17 commits into
Zenith-options:mainfrom
tesleem-28:feat/timelock-reconstruction
Oct 24, 2026
Merged

techisigu merged 17 commits into
Zenith-options:mainfrom
tesleem-28:feat/timelock-reconstruction

Conversation

@tesleem-28

@tesleem-28 tesleem-28 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Rebuilds the timelock crate, which was empty end to end apart from a
migrations shim. An empty crate was not a paper gap: the governor crate
depends on a real timelock instance in its tests and the governance handover
test drives one through every stage, so cargo xtask test — and with it the
whole CI — could not pass.

The implementation follows the design the README documents: three roles
(proposer, executor, canceller) and no admin, an operation id derived from
what the operation will actually do, and predecessor ordering that holds the
line.

Related Issue

Closes #166

Changes

[ADD] timelock/src/lib.rs

  • initialize(min_delay, grace_period, proposers, executors, cancellers) —
    one-shot, no admin afterwards. An empty executor set means anyone may
    execute a ready operation.
  • schedule(proposer, targets, fns, args, salt, delay) -> id — proposer-only,
    delay >= min_delay. The id is derived from the batch and the salt, so it
    commits to exactly what will run and two identical batches are the same
    operation.
  • schedule_after(proposer, targets, fns, args, predecessor, salt) -> id —
    an operation that may not execute until predecessor has.
  • execute(executor, id) — the operation's calls run in order; a call that
    reverts reverts the whole batch.
  • cancel(canceller, id) — removes a pending operation, so anything waiting
    on it as a predecessor is stranded for good.
  • Self-administration through delayed operations only: update_delay,
    grant_role, revoke_role, set_open_executor and migrate.
  • Views: get_min_delay, get_grace_period, has_role, is_open_executor,
    get_timestamp, is_operation, is_operation_pending,
    is_operation_ready, is_operation_done, get_operation,
    hash_operation.

[ADD] timelock/src/types.rs, error.rs, events.rs

  • Role (proposer / executor / canceller), Operation, the id preimage, and
    the storage keys — one key per (role, address) pair, so a membership check
    is a single storage read and a revocation is a single removal.
  • Errors 1–10 (AlreadyInitialized … Expired) and 100–104, the
    schema-migration codes every Zenith contract shares.
  • Events scheduled, executed, cancelled, min_delay_changed,
    role_changed.

[ADD] timelock/src/test.rs

  • Every role's authorization failure, the delay boundary (one second before
    eta and exactly at it), the grace period, cancel-before-execute,
    execute-after-cancel, re-execution rejection, predecessor ordering, and a
    cancelled predecessor stranding its dependant.
  • The operation id committing to the batch, the predecessor and the salt.
  • The timelock acting as the admin of the contract it drives, with only the
    executor's own authorization supplied.

[MODIFY] timelock/src/migrations.rs, timelock/Cargo.toml

  • The shim is preserved and wired up: zenith-common added, mod migrations; declared, the migration errors added, init_schema called
    from initialize, and migrate dispatched as a delayed self-call.

[MODIFY] governor/

  • The Timelock interface follows the new API. The governor queues a
    proposal under the proposal's own id as the salt, so the timelock's
    operation id is derived from the same inputs and the governor derives it
    back whenever it needs to ask about the operation.
  • queue, cancel and state_of updated accordingly. The governor's
    public ABI is unchanged.

[MODIFY] integration_tests/tests/handover.rs, docs/, scripts/

  • The handover's guardian role becomes the timelock's canceller role; lock-in
    becomes the revocation of the multisig's roles, and the multisig keeps its
    proposer role until then, which is what keeps stage 3 reversible.
  • The governance handbook, the handover specification and the operational
    scripts follow.

[MODIFY] .github/workflows/ci.yml, Cargo.toml, Cargo.lock

  • The workflow file was not valid YAML: a stray matrix line sat under
    env:, the fmt job's matrix list was truncated mid-word, and three jobs
    were all named wasm. No job could run at all, which is why every run on
    main was red. The per-crate jobs now also act on the crate their matrix
    row names, instead of each running the whole workspace.
  • params, staking, streams and multisig all have a migrations.rs
    that calls zenith_common::migrations but never declared the dependency,
    so the workspace did not compile. Declared.
  • The workspace had no Cargo.lock, so every build resolved dependencies
    fresh and picked ed25519-dalek 3, whose rand_core 0.9
    soroban-env-host's rand_chacha cannot satisfy. The lockfile that still
    exists for lp_pool has the working resolution and is now the workspace's.
  • lp_pool and lp_token inherit from the workspace but were never members
    of it, so cargo refused to format them. Added.

Verification Results

The CI on this branch (the ci workflow, 64 jobs) — 48 green, 16 red:

Job group Result
fmt (all 15 crates) ✅
wasm (timelock), wasm (governor), wasm (multisig), … ✅
test (timelock) ✅ 23/23
test (governor) ✅
clippy (timelock), clippy (governor) ✅
the jobs below ❌ pre-existing

Every job still red is red for a reason that predates this change and lives in
a crate this issue does not touch:

Job Cause
build, check-wasm, budgets Whole-workspace jobs. options_market/src/{math,storage}.rs, price_oracle/src/lib.rs and vault/src/{types,error,events}.rs are empty on main — those crates are awaiting their own reconstruction issues, so the workspace cannot be built.
test (options_market), clippy (options_market), wasm (options_market) The empty math.rs / storage.rs: the contract's body is gone.
test (price_oracle), clippy (price_oracle) price_oracle/src/lib.rs is empty, and test_adversaries — which the price-oracle mocks come from — puts four #[contractimpl] blocks in one module, which the current SDK's codegen cannot express.
test (vault), clippy (vault) vault/src/{types,error,events}.rs are empty, and the vault's test module needs the test_adversaries crate, which is not a workspace member.
test (common), clippy (common), test (params), clippy (params) common/src/test.rs and params's tests assert against a migrations API that has since changed.
test (integration_tests), clippy (integration_tests) The handover test drives the real options_market and vault contracts, which are stubs, so it cannot run until those are rebuilt. The test itself compiles and the timelock side of it passes.
Acceptance Criteria Status
initialize establishes the three roles ✅ one-shot, no admin afterwards
schedule / execute / cancel, role-gated, configurable delay ✅
Predecessor dependency support ✅ a dependant cannot execute before its predecessor
Contract errors and lifecycle events ✅ 10 errors, 5 events
A readable operation id scheme ✅ sha256 over the batch, the predecessor and the salt
The migrations shim is preserved and still functions ✅ init_schema, the migration errors, migrate as a delayed self-call
Compatible with common's admin/multisig pattern where applicable ✅ the timelock is an ordinary admin: Address to the other contracts; common is unchanged
Out of scope: assigning the timelock as admin elsewhere ✅ not done here

Edge cases pinned by the tests

  • Each role's authorization failure — a non-proposer cannot schedule, a
    non-executor cannot execute, a non-canceller cannot cancel.
  • The delay boundary: one second before eta is refused, exactly at eta is
    allowed.
  • Cancel before execute, execute after cancel, cancelling an operation that
    already executed, and cancelling one that never existed.
  • Re-execution is refused, and an operation past its grace period is refused.
  • A dependant waits for its predecessor, and a cancelled predecessor strands
    it for good.
  • An unknown self-call is refused; a delay below the documented minimum is
    refused; an empty batch is refused; the same batch cannot be scheduled
    twice; a second initialize is refused.

Reference

The PR's structure — Overview / Related Issue / Changes / Verification
Results with an acceptance-criteria table — follows
Streampay-Org/StreamPay-Frontend#1282.

…rived ids

The timelock crate was empty end to end apart from the migrations shim,
which left the governor and the integration tests unable to compile and
the whole CI red.

Rebuilds it to the design the README documents:

- three roles and no admin: proposer, executor, canceller. The role sets
  are the whole configuration, and the timelock's own settings change
  only through a delayed operation.
- an operation is a batch of calls plus a predecessor and a salt; its id
  is derived from them, so the id commits to exactly what will run and
  two identical batches are the same operation.
- an operation whose predecessor has not executed cannot execute, and
  cancelling a predecessor strands its dependants for good.
- a documented minimum delay, a grace period after which a ready
  operation is dead, and the error and event set that goes with them.
- the migrations shim is preserved and wired up: init_schema in
  initialize, the migration errors, and migrate as a delayed self-call.

The governor and the governance handover move to the new API with it:
the governor queues a proposal under the proposal's own id as the salt
and derives the timelock's operation id from the same inputs, and the
handover's guardian role becomes the canceller role, with lock-in
becoming the revocation of the multisig's roles.

Also repairs .github/workflows/ci.yml, which was not valid YAML: a
stray matrix line under env:, a truncated matrix list, and three jobs
all named wasm. Without that repair no job could run at all.

Closes Zenith-options#166
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@tesleem-28 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

…rived ids

See the PR description for the full change list.
…rived ids

Rebuilds the empty timelock crate to the design the README documents, and moves
the governor and the governance handover to the new API with it. Also repairs the
CI workflow file, the workspace's rustfmt drift and the options_market import
paths, none of which could run or pass before.
The vault's lib.rs lost its #![no_std] and options_market's lib.rs was a
duplicated header, so neither crate could be built or even parsed by
cargo fmt. Both are repaired here, along with the rustfmt drift and the
zenith-common dependency the migrations framework needs in params,
staking, streams and multisig.
The vault's lib.rs lost its #![no_std] and options_market's lib.rs was a
duplicated header, so neither crate could be built or even parsed by
cargo fmt. ed25519-dalek is pinned to 2.2.0 because 3 moved to rand_core
0.9, which soroban-env-host's rand_chacha cannot satisfy. The rustfmt
drift across the workspace is applied, and the zenith-common dependency
the migrations framework needs is declared in params, staking, streams
and multisig.
… breakage

The workspace had no Cargo.lock, so every build resolved dependencies fresh
and picked ed25519-dalek 3, whose rand_core 0.9 soroban-env-host's
rand_chacha cannot satisfy. The lockfile that still exists for lp_pool has
the working resolution and is now the workspace's.

lp_pool and lp_token inherit from the workspace but were never members, so
cargo refused to format them. The CI matrices now run with fail-fast off,
so one broken crate stops hiding the state of the others.
Both had been overwritten by a re-fetch from upstream. The workflow file
is valid YAML again (a stray matrix line under env:, a truncated matrix
list and three jobs named wasm), the matrices run with fail-fast off so
one broken crate stops hiding the others, and ed25519-dalek is pinned to
2.2.0 because 3 moved to rand_core 0.9, which soroban-env-host's
rand_chacha cannot satisfy.
…field

Option<BytesN<32>> has no XDR conversion in the SDK, so it cannot be a
#[contracttype] field. The stored form is the id itself, with the zero id
standing for no predecessor, and the public API still takes an Option.

The migrations shim's second #[contractimpl] block needs the client the
#[contract] macro generates, so it is imported explicitly.
The clippy matrix linted the whole workspace once per crate, the wasm
matrix ran the whole workspace's test suite once per crate, and the test
matrix only checked formatting. Each now acts on the crate in its own
matrix row, so one broken crate stops hiding the state of the others.
schedule and schedule_after take the batch as parallel vectors, a salt and a
delay; the id is derived from the batch, the predecessor and the salt, so it
commits to exactly what will run. execute takes the id, and the predecessor
is checked against the stored operation.

The three roles are the whole configuration: the timelock has no admin, and
its own settings change only through a delayed operation.
token_validation.rs is included as a module, so its `#![no_std]` is an
inner attribute in the wrong place and the crate does not compile. The
crate root already carries the attribute.
@techisigu
techisigu merged commit 7360f96 into Zenith-options:main Oct 24, 2026
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.

Reconstruct the timelock Crate: Proposer/Executor/Canceller Roles and Predecessor Dependencies

2 participants