Skip to content

feat(timelock): reconstruct the delayed-execution admin crate - #294

Merged
techisigu merged 1 commit into
Zenith-options:mainfrom
AminMG044:feat/timelock-reconstruction
Oct 24, 2026
Merged

techisigu merged 1 commit into
Zenith-options:mainfrom
AminMG044:feat/timelock-reconstruction

Conversation

@AminMG044

@AminMG044 AminMG044 commented Oct 19, 2026 •

Copy link
Copy Markdown

Overview

timelock/ was empty on main apart from a migrations shim, even though
README.md documents it in full: a delayed-execution admin with
proposer / executor / canceller roles and predecessor dependencies,
meant to hold the admin role on the other contracts so users always get an
exit window before a destructive action lands.

This rebuilds the crate to match that documented design — the same function
signatures, the same view names, the same event names, the same error codes —
and wires the existing migrations shim back in.

Related Issue

Closes #166

Changes

[ADD] timelock/src/lib.rs — the contract

  • initialize(min_delay, proposers, executors, cancellers) — one-time
    setup. There is no admin: the timelock's own configuration changes only
    through a delayed operation. An empty executors list means anyone may
    execute a ready operation (an "open executor", as in OpenZeppelin's
    TimelockController).
  • schedule(proposer, calls, predecessor, salt, delay) -> id — proposer-only.
    calls is a Vec<Call { target, function, args }>, delay >= min_delay,
    and the id is sha256(xdr((calls, predecessor, salt))) — so the id commits
    to the whole payload and hash_operation derives it off-chain, before
    anything is scheduled.
  • execute(executor, calls, predecessor, salt) — executor-only (unless open).
    The operation is identified by the content the executor repeats, which
    hashes to the id that was scheduled. Every call runs in order via
    invoke_contract; a revert in any of them reverts the whole batch.
  • cancel(canceller, id) — canceller-only.
  • Self-administration. Soroban forbids re-entry, so a Call whose target
    is the timelock itself is dispatched internally by execute:
    update_delay(u64), grant_role(Role, Address),
    revoke_role(Role, Address), set_open_executor(bool) — and
    migrate(u32, u32), which the migrations shim needs. These are therefore
    only reachable through a delayed operation.
  • Views: get_timestamp(id) (0 unknown, 1 done, else ready-at),
    is_operation, is_operation_pending, is_operation_ready,
    is_operation_done, get_operation, get_min_delay,
    has_role(role, account), is_open_executor, hash_operation.

[ADD] timelock/src/types.rs — Call, Role, Operation, DataKey

Each role is a set of addresses, not a single holder: the protocol hands
the timelock to one signer during the handover and to a council later,
without the contract having to be redeployed.

[ADD] timelock/src/error.rs — the documented error codes

1 AlreadyInitialized, 2 Unauthorized, 3 InsufficientDelay,
4 AlreadyScheduled, 5 NotReady, 6 PredecessorNotDone, 7 NotPending,
8 UnknownSelfCall, 9 EmptyOperation — plus the migration codes 100–104
that zenith_common::migrations expects every contract to carry.

[ADD] timelock/src/events.rs — scheduled, executed, cancelled,

min_delay_changed, role_changed, open_executor_changed.

[MODIFY] timelock/src/migrations.rs — the shim, preserved and working

The step registry, require_current and self_migrate are untouched. The
crate now declares mod migrations;, carries the migration error variants,
writes the schema version in initialize, and dispatches the migrate
self-call — the four things the shim's own note asked for. The stale note
("this crate's lib.rs … is empty on main") is updated to say it is restored.

[MODIFY] timelock/Cargo.toml — zenith-common is now a dependency

The shim needs the shared migration framework.

[ADD] Cargo.lock — the workspace had no lockfile

Without one, cargo resolves ed25519-dalek to 3.0.0 for
soroban-env-host, whose >=2.0.0 requirement admits it, and the build
fails with a ChaCha20Rng: CryptoRng error before a single test runs. The
lockfile pins ed25519-dalek to the 2.x line the workspace already asks for.
This is what makes the timelock's own CI jobs (clippy, wasm, test)
build at all.

[MODIFY] README.md — the timelock reference

The section that specifies this contract now also records the two things the
implementation had to pin down: the delay boundary is inclusive (an
operation may run at exactly its ready-at, and not before), and a cancelled
predecessor can never run, so neither can its dependent.

[MODIFY] .github/workflows/ci.yml — the workflow file was invalid YAML

ci.yml on main does not parse. A bad merge left a stray crate: [...]
fragment at the top level, a truncated matrix list (tools/resource-sna),
and three jobs with the same key (wasm), one of them with two steps:
keys. GitHub rejects the file, so no workflow run has been created since
2026-09-30 — every CI run in this repo ends with zero jobs, on main and on
every open PR.

This repairs the three breakages and nothing else:

  • the stray fragment is gone;
  • the matrix list is closed;
  • the three wasm: jobs get their real names back — wasm (the matrix that
    runs cargo xtask test), build (cargo xtask build + cargo xtask test),
    and wasm-release (the release build, check-wasm and the artifact
    upload).

Without this, the timelock's own CI jobs cannot run at all.

Verification Results

cargo test -p zenith-timelock
✅ 23 passed | 0 failed
cargo fmt --check   (timelock/)
✅ clean

cargo clippy -p zenith-timelock --all-targets -- -D warnings
✅ no warnings

cargo build -p zenith-timelock --target wasm32-unknown-unknown --release
✅ Finished `release` profile
Acceptance Criteria Status
initialize establishing the three roles (and compatibility with common's admin/multisig pattern where applicable) ✅ initialize(min_delay, proposers, executors, cancellers); the timelock is the admin of the other contracts and satisfies their admin.require_auth() by being the direct invoker — the same Auth::Admin shape zenith-common speaks
schedule/propose, execute, and cancel entrypoints with a configurable delay, role-gated per the README's table ✅ all three, role-gated, with min_delay and a per-operation delay
Predecessor dependency support: an operation with an unexecuted predecessor cannot execute ✅ PredecessorNotDone; a cancelled predecessor can never run, so its dependent can never run either
Contract errors and lifecycle events for scheduled, executed, and cancelled operations, plus a readable operation id scheme ✅ errors 1–9 exactly as documented; scheduled / executed / cancelled events; the id is sha256(xdr((calls, predecessor, salt))), derivable off-chain with hash_operation and readable back through get_timestamp / is_operation*
The existing migrations shim is preserved and still functions ✅ step registry, require_current and self_migrate untouched; init_schema in initialize; the migrate self-call dispatches into the framework (a test asserts an invalid target is refused)
executing before the delay elapses must fail ✅ NotReady
double-execution must fail ✅ NotPending (a done operation is not pending)
cancelling an already-executed or nonexistent operation must fail ✅ NotPending
cancelling a predecessor should not leave a dependent operation executable ✅ the dependent can never run; a test asserts it
the delay boundary (exactly at eta) is decided and tested ✅ inclusive, decided in the README and covered by a test
unit tests for each role's authorization failures ✅ proposer / executor / canceller, each with a wrong-role case
the delay boundary (just before and exactly at eta) ✅ two cases around the boundary
cancel-before-execute, execute-after-cancel ✅ both
predecessor ordering (dependent blocked until predecessor executes) ✅
re-execution rejection ✅

Type of change

  • New corridor manifest (*.corridor.yaml only — no engine change)
  • Bug fix
  • Feature
  • Docs
  • Build / CI / chore

Checklist

  • cargo fmt --check passes for the timelock crate
  • cargo clippy --all-targets -- -D warnings passes
  • cargo test passes (23 tests)
  • cargo build --target wasm32-unknown-unknown --release succeeds
  • Tests added/updated for any behaviour change
  • Customer PII is never persisted in run records, alerts, or logs
  • No secrets, signing keys, or .env files committed
  • a grep for stale claims about the thing you changed (the shim's note and the README's timelock section)

Notes for the reviewer

Reference

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

@AminMG044
AminMG044 force-pushed the feat/timelock-reconstruction branch 2 times, most recently from 408294a to 3e8e24e Compare October 21, 2026 21:27
@techisigu
techisigu merged commit 6a1fb05 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

3 participants