From c900d1d8ad4dfb01c8b8acd9f5db78031edd6996 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Mon, 19 Oct 2026 18:17:02 +0100 Subject: [PATCH 01/17] =?UTF-8?q?feat(timelock):=20reconstruct=20the=20cra?= =?UTF-8?q?te=20=E2=80=94=20three=20roles,=20predecessors,=20derived=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #166 --- .github/workflows/ci.yml | 15 +- README.md | 37 +- docs_governance-handover.md | 104 +++ docs_governance.md | 95 +++ governor/src/interfaces.rs | 24 +- governor/src/lib.rs | 36 +- governor/src/test.rs | 39 +- integration_tests/tests/handover.rs | 240 ++++--- scripts/governance/common.sh | 34 +- .../governance/stage1_admin_to_multisig.sh | 1 + scripts/governance/stage1_rollback.sh | 1 + .../governance/stage2_admin_to_timelock.sh | 7 +- scripts/governance/stage2_rollback.sh | 10 +- .../governance/stage3_proposer_to_governor.sh | 20 +- scripts/governance/stage3_rollback.sh | 14 +- scripts/governance/stage4_lock_in.sh | 17 +- timelock/Cargo.toml | 2 + timelock/src/error.rs | 37 + timelock/src/events.rs | 36 + timelock/src/lib.rs | 429 +++++++++++ timelock/src/migrations.rs | 18 +- timelock/src/test.rs | 671 ++++++++++++++++++ timelock/src/types.rs | 78 ++ 23 files changed, 1780 insertions(+), 185 deletions(-) create mode 100644 docs_governance-handover.md create mode 100644 docs_governance.md mode change 100755 => 100644 scripts/governance/common.sh mode change 100755 => 100644 scripts/governance/stage1_admin_to_multisig.sh mode change 100755 => 100644 scripts/governance/stage1_rollback.sh mode change 100755 => 100644 scripts/governance/stage2_admin_to_timelock.sh mode change 100755 => 100644 scripts/governance/stage2_rollback.sh mode change 100755 => 100644 scripts/governance/stage3_proposer_to_governor.sh mode change 100755 => 100644 scripts/governance/stage3_rollback.sh mode change 100755 => 100644 scripts/governance/stage4_lock_in.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8fc7f..df1dd11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,13 +35,12 @@ env: # multisig build step above runs before the vault build step, not just # before options_market's. # - crate: [options_market, price_oracle, vault, multisig, streams, staking, timelock, governor, lp_token, lp_pool, integration_tests] jobs: fmt: runs-on: ubuntu-latest strategy: matrix: - crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-sna + crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-snapshot] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -91,7 +90,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - wasm: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -100,16 +99,9 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - run: cargo xtask build - # Fails if soroban-sdk/testutils - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - wasm: + check-wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -192,3 +184,4 @@ jobs: python3 scripts/resource-diff/resource_diff.py ${{ github.event_name == 'pull_request' && format('--base-ref origin/{0}', github.base_ref) || '' }} options_market + diff --git a/README.md b/README.md index 6f8b39f..626ce0e 100644 --- a/README.md +++ b/README.md @@ -958,34 +958,42 @@ seconds before it can run. Modelled on OpenZeppelin's TimelockController. | Function | Description | |---|---| -| `initialize(min_delay, proposers, executors, cancellers)` | One-time setup. Empty `executors` means anyone may execute a ready operation. There is no admin afterwards. | -| `schedule(proposer, calls, predecessor, salt, delay) -> id` | Proposer-only. `calls` is a `Vec`; `delay >= min_delay`. The id is `sha256(xdr((calls, predecessor, salt)))` (see `hash_operation`). Rejects an id that's already pending or done. | -| `execute(executor, calls, predecessor, salt)` | Executor-only (unless open). Requires the operation ready and `predecessor` (if any) done. Runs every call in order via `invoke_contract`; any revert reverts the whole batch. | -| `cancel(canceller, id)` | Canceller-only. Removes a pending operation. | - -**Self-administration.** Soroban forbids re-entry, so a `Call` whose -`target` is the timelock itself is dispatched internally by `execute`: +| `initialize(min_delay, grace_period, proposers, executors, cancellers)` | One-time setup. Empty `executors` means anyone may execute a ready operation. There is no admin afterwards. `min_delay` may not be below the documented floor, and `grace_period` is how long a ready operation stays executable. | +| `schedule(proposer, targets, fns, args, predecessor, salt, delay) -> id` | Proposer-only. The batch is three parallel vectors — the target, the function and the arguments of each call. `delay >= min_delay`. The id is derived from the batch, the predecessor and the salt (see `hash_operation`). Rejects an id that's already pending or done. | +| `execute(executor, targets, fns, args, predecessor, salt)` | Executor-only (unless open). Requires the operation ready, not expired, and `predecessor` (if any) done. Runs every call in order via `invoke_contract`; any revert reverts the whole batch. | +| `cancel(canceller, id)` | Canceller-only. Removes a pending operation, so anything waiting on it as a predecessor is stranded for good. | + +**Three roles, no admin.** `Proposer` may schedule, `Executor` may execute, +`Canceller` may cancel. There is no fourth role and no owner: the timelock's +own configuration is changed the same way everything else is, by scheduling an +operation that targets the timelock itself. + +**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)`. These changes are therefore only reachable -through a delayed operation. +`set_open_executor(bool)` and `migrate(u32, u32)`. These changes 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_min_delay`, `has_role(role, account)`, -`is_open_executor`, `hash_operation`. +`is_operation_done`, `get_operation`, `get_min_delay`, `get_grace_period`, +`has_role(role, account)`, `is_open_executor`, `hash_operation`, plus the +migration framework's `schema_version`, `migration_state` and +`migrate_batch`. **Events:** `scheduled`, `executed`, `cancelled`, `min_delay_changed`, `role_changed`. **Errors:** 1 `AlreadyInitialized`, 2 `Unauthorized`, 3 `InsufficientDelay`, 4 `AlreadyScheduled`, 5 `NotReady`, 6 `PredecessorNotDone`, 7 `NotPending`, -8 `UnknownSelfCall`, 9 `EmptyOperation`. +8 `UnknownSelfCall`, 9 `EmptyOperation`, 10 `Expired`; 100–104 are the +schema-migration errors every Zenith contract shares. ### Operations guide 1. Deploy the timelock, then `transfer_admin(timelock)` on each contract. -2. A proposer schedules the change, e.g. `calls = [Call { target: options_market, function: "set_fee_rate", args: [25u32] }]`, with a unique `salt`, and announces the id. +2. A proposer schedules the change, e.g. `targets = [options_market]`, `fns = ["set_fee_rate"]`, `args = [[25u32]]`, with a unique `salt`, and announces the id `schedule` returns. 3. During the delay users can exit; a canceller can `cancel(id)`. -4. Once `is_operation_ready(id)`, an executor calls `execute` with the exact same `calls`/`predecessor`/`salt`. +4. Once `is_operation_ready(id)`, an executor calls `execute` with the exact same `targets`/`fns`/`args`/`predecessor`/`salt`. 5. Use `predecessor` to force ordering (e.g. a migration after an `upgrade`). ## `params` reference @@ -1112,3 +1120,4 @@ start, limit)` (paginated, `limit` capped at 50). Events: fall outside this pattern entirely — none of them are admin-gated to begin with, so there's no single-signature check for a `_via_multisig` variant to replace. + diff --git a/docs_governance-handover.md b/docs_governance-handover.md new file mode 100644 index 0000000..92d3b1c --- /dev/null +++ b/docs_governance-handover.md @@ -0,0 +1,104 @@ +# Governance Handover Specification + +How admin control of Zenith moves from a single deployer key, through a +multisig, to the timelock and governor. Every stage can be reversed by a +higher authority until the final lock-in. + +Covered by `integration_tests/tests/handover.rs`, which deploys every contract +and runs each stage and each rollback. Scripts are in `scripts/governance/`. + +## Actors + +- **Deployer**: the EOA that initialized the contracts. +- **Multisig account**: a native Stellar account with M-of-N signers (the + same signer set as the on-chain `multisig` contract). It holds admin in + stage 1 and is the timelock **proposer** and **canceller** from stage 2 on. + A native account is used because the `multisig` contract only counts + approvals and never makes calls, so it can't sign `admin.require_auth()`. +- **Multisig contract**: backs every `*_via_multisig` recovery path. It is + not moved by the handover. +- **Timelock**: `proposer`, `executor` and `canceller` role sets, `min_delay` + and `grace_period`. The multisig's two roles are revoked at lock-in. +- **Governor**: ZEN-holder voting. See [governance.md](governance.md). + +## Stages + +| Stage | options_market / price_oracle admin | Timelock proposer | Timelock canceller | Who can roll back | How | +|------:|-------------------------------------|-------------------|--------------------|-------------------|-----| +| 0 | Deployer | — | — | — | — | +| 1 | Multisig account | — | — | Multisig | `transfer_admin(deployer)`: `stage1_rollback.sh` | +| 2 | Timelock | Multisig account | Multisig account | Multisig | Schedules `transfer_admin(multisig)` through the timelock: `stage2_rollback.sh`. It can also cancel any pending op. | +| 3 | Timelock | Multisig account **and** the governor | Multisig account | Multisig | Cancels the governor's queued op and schedules the corrective one: `stage3_rollback.sh`. | +| 4 | Timelock | Governor | **none** | Governor only | Ordinary proposals. No emergency path. | + +Stage 3 *adds* the governor as a proposer rather than replacing the multisig: +the multisig keeps its proposer role until lock-in, which is what keeps the +stage reversible. A role can only be granted or revoked by a delayed +operation, so there is no instant path back to the multisig once the +governor holds the role alone. + +**Vault:** its admin is `options_market` at every stage and must stay that +way, because `options_market` moves escrow through it. The vault's only +governance concern is its multisig recovery path +(`transfer_admin_via_multisig`), which takes the multisig contract per call. +There is no on-chain state to migrate. The integration test checks the path +still works after lock-in. + +## Transitions + +| Transition | Script | Mechanism | +|------------|--------|-----------| +| 0 → 1 | `stage1_admin_to_multisig.sh` | The deployer calls `transfer_admin(multisig_account)` on options_market and price_oracle. | +| 1 → 2 | `stage2_admin_to_timelock.sh` | The multisig calls `transfer_admin(timelock)`. First confirm that the timelock's proposer and guardian are the multisig account. | +| 2 → 3 | `stage3_proposer_to_governor.sh` | The multisig schedules a timelock self-call `grant_role(Proposer, governor)`, then anyone executes it after `min_delay`. | +| 3 → 4 | `stage4_lock_in.sh` | A governor proposal calls the timelock's `revoke_role(Proposer, multisig)` and `revoke_role(Canceller, multisig)`. | + +### Lock-in conditions + +Run stage 4 only when all of the following are true: + +1. The governor has run at least one real parameter-change proposal end to + end in stage 3 (the integration test does this with `set_fee_rate` and + `set_max_staleness`). +2. ZEN voting power is distributed widely enough that quorum can't be met by + a single holder. +3. No queued timelock operation is pending review. +4. The multisig signers have signed off on removing their own timelock roles. + +After lock-in the multisig holds neither role, so it can neither cancel a +queued operation nor schedule one, for good. + +## Running the scripts + +Each script prints commands without sending them unless `DRY_RUN=0` is set. +Calls whose source is the multisig account run with `--build-only`. The +unsigned XDR is then signed by M signers (`stellar tx sign`) and submitted +(`stellar tx send`). + +```bash +export NETWORK=testnet MARKET_ID=C... ORACLE_ID=C... VAULT_ID=C... \ + TIMELOCK_ID=C... GOVERNOR_ID=C... DEPLOYER=deployer \ + MULTISIG_ACCOUNT=G... TL_DELAY=172800 + +./scripts/governance/stage1_admin_to_multisig.sh +./scripts/governance/stage2_admin_to_timelock.sh +PHASE=schedule ./scripts/governance/stage3_proposer_to_governor.sh +PHASE=execute ./scripts/governance/stage3_proposer_to_governor.sh # after TL_DELAY +PHASE=propose PROPOSER=alice ./scripts/governance/stage4_lock_in.sh +``` + +Example dry-run output (stage 1 → 2): + +``` +[dry-run] stellar contract invoke --network testnet --source GMULTISIG --build-only --id CMARKET -- transfer_admin --new_admin CTIMELOCK +[dry-run] stellar contract invoke --network testnet --source GMULTISIG --build-only --id CORACLE -- transfer_admin --new_admin CTIMELOCK +``` + +## Known caveat + +The `*_via_multisig` entrypoints on options_market, price_oracle and vault +accept any `multisig_contract` address from the caller. They don't pin one +at initialize, so they don't restrict who can act. This was already the case +before this handover and is out of scope here. It should be fixed, by pinning +the multisig at initialize, before mainnet. + diff --git a/docs_governance.md b/docs_governance.md new file mode 100644 index 0000000..f5a9db9 --- /dev/null +++ b/docs_governance.md @@ -0,0 +1,95 @@ +# Governance Handbook + +Zenith's admin powers (fees, listings, oracle config, pausing, upgrades) are +ultimately controlled by ZEN holders through the **governor** and executed by +the **timelock**. This handbook covers how that works and how to use it. The +path from today's single admin to this end state is in +[governance-handover.md](governance-handover.md). + +## Contracts + +| Contract | Role | +|------------|------| +| `governor` | Proposals, voting, quorum. Queues passed proposals into the timelock. | +| `timelock` | Admin of every protocol contract. Delays each call by `min_delay`; anyone may execute a ready operation. Holds a proposer, an executor and a canceller role, and no admin of its own. | +| ZEN token | Checkpointed voting token exposing `get_past_votes(account, ledger)` and `get_past_total_supply(ledger)`. The governor calls it through the `Votes` interface in `governor/src/interfaces.rs`. | + +## Proposal lifecycle + +``` +propose ──► Pending ──(voting_delay)──► Active ──(voting_period)──► Defeated + │ │ Succeeded ──queue──► Queued ──timelock.execute──► Executed + └──────── cancel ──────────┴────────────────────────────┴───────────────────┘ └─(grace elapsed)─► Expired + ▼ + Canceled +``` + +1. **Propose** — `propose(proposer, targets, fns, args, description_hash)`. + The proposer needs at least `proposal_threshold` votes as of the previous + ledger. The id is `sha256(xdr(targets, fns, args, description_hash))` + (`hash_proposal` computes it off-chain too); an identical proposal cannot + be submitted twice. At most `MAX_ACTIONS` (10) calls per proposal. +2. **Pending** until the snapshot ledger `propose_ledger + voting_delay`. +3. **Active** for `voting_period` ledgers after the snapshot. Vote with + `cast_vote(voter, id, support)` where `support` is `0` Against, `1` For, + `2` Abstain. Weight is the voter's power **at the snapshot ledger**. + - `cast_vote_by_sig(voter, id, support)` is the gasless variant: the voter + signs a Soroban authorization entry for exactly `(id, support)` and any + relayer submits it. The host checks the signature and a nonce, so it + can't be replayed. +4. **Succeeded / Defeated** — succeeded iff `for + abstain ≥ quorum` **and** + `for > against`. A tie is a defeat. Quorum is + `quorum_bps × past_total_supply(snapshot) / 10 000`. +5. **Queue** — anyone calls `queue(id)`; the governor schedules the batch in + the timelock under the proposal's own id as the salt, with the timelock's + `min_delay`. The timelock derives the operation id from the batch, the + predecessor and the salt, so the governor derives the same id back whenever + it needs to ask about the operation. +6. **Execute** — after the delay, anyone calls + `timelock.execute(executor, targets, fns, args, predecessor, salt)`. The + governor reports **Executed** once the timelock marks it done, or + **Expired** if `grace_period` passes first. + +### Cancelling + +- The proposer may cancel while the proposal is **Pending**. +- **Anyone** may cancel a Pending/Active/Succeeded/Queued proposal if the + proposer's current voting power has dropped below `proposal_threshold`. + Cancelling a queued proposal also removes it from the timelock. +- The timelock's **canceller** can cancel any queued operation. + +## Parameters + +Changed only by a passed proposal targeting the governor itself (the call +arrives from the timelock). Ledgers assume ~5 s. + +| Parameter | Setter | Bounds | +|----------------------|---------------------------|--------| +| `voting_delay` | `set_voting_delay` | 0 – 241 920 ledgers (~2 weeks) | +| `voting_period` | `set_voting_period` | 720 (~1 h) – 241 920 ledgers | +| `quorum_bps` | `set_quorum_bps` | 100 (1%) – 10 000 (100%) | +| `proposal_threshold` | `set_proposal_threshold` | ≥ 0 | + +Timelock settings are changed by a proposal whose target is the timelock +itself: `update_delay(u64)`, `grant_role(Role, Address)`, +`revoke_role(Role, Address)` or `set_open_executor(bool)`. There is no instant +path — every change to the timelock waits out the delay like any other +operation. + +## Attack resistance + +| Attack | Mitigation | +|--------|------------| +| Flash-loan / last-minute votes | Power is read at the snapshot ledger, which is already in the past when voting opens. | +| Double voting | One vote per address per proposal (`has_voted`). Moving tokens after the snapshot does not create new power. | +| Proposal spam | `proposal_threshold`, and anyone can cancel a proposal whose proposer has sold below it. | +| Quorum manipulation | Quorum uses total supply at the snapshot, not at vote time. | +| Malicious proposal passes | `min_delay` gives users time to exit; before lock-in a canceller can cancel it. | + +## Why the timelock executes, not the governor + +Soroban forbids a contract from being re-entered. If the governor called the +timelock and the timelock called back into the governor (for example, to change +`quorum_bps`), the call would fail. The timelock's `execute` is therefore +open to anyone, and the governor never sits in the execution call stack. + diff --git a/governor/src/interfaces.rs b/governor/src/interfaces.rs index 08aa8c2..b7d763b 100644 --- a/governor/src/interfaces.rs +++ b/governor/src/interfaces.rs @@ -13,19 +13,35 @@ pub trait Votes { } /// Subset of the Zenith timelock used by the governor. +/// +/// The timelock derives an operation's id from the batch it carries, the +/// predecessor it waits on and a salt, so the governor cannot name an +/// operation directly: it queues the proposal under the proposal's own id as +/// the salt and derives the operation id from the same inputs. #[allow(dead_code)] #[contractclient(name = "TimelockClient")] pub trait Timelock { fn schedule( env: Env, - op_id: BytesN<32>, + proposer: Address, targets: Vec
, fns: Vec, args: Vec>, + predecessor: Option>, + salt: BytesN<32>, delay: u64, - ) -> u64; - fn is_done(env: Env, op_id: BytesN<32>) -> bool; - fn cancel(env: Env, caller: Address, op_id: BytesN<32>); + ) -> BytesN<32>; + fn hash_operation( + env: Env, + targets: Vec
, + fns: Vec, + args: Vec>, + predecessor: Option>, + salt: BytesN<32>, + ) -> BytesN<32>; + fn is_operation_done(env: Env, id: BytesN<32>) -> bool; + fn cancel(env: Env, canceller: Address, id: BytesN<32>); fn get_min_delay(env: Env) -> u64; fn get_grace_period(env: Env) -> u64; + fn get_timestamp(env: Env, id: BytesN<32>) -> u64; } diff --git a/governor/src/lib.rs b/governor/src/lib.rs index 1b1d008..5013639 100644 --- a/governor/src/lib.rs +++ b/governor/src/lib.rs @@ -103,7 +103,7 @@ fn state_of(env: &Env, id: &BytesN<32>, p: &Proposal) -> ProposalState { if p.canceled { return ProposalState::Canceled; } - if p.eta != 0 && timelock(env).is_done(id) { + if p.eta != 0 && timelock(env).is_operation_done(&timelock_op_id(env, p, id)) { return ProposalState::Executed; } let seq = env.ledger().sequence(); @@ -127,6 +127,16 @@ fn state_of(env: &Env, id: &BytesN<32>, p: &Proposal) -> ProposalState { ProposalState::Queued } +/// The timelock operation a queued proposal maps to. +/// +/// The timelock derives an operation's id from the batch it carries, the +/// predecessor it waits on and a salt. The governor queues a proposal under +/// the proposal's own id as the salt, so the operation id is derived from +/// the same inputs and can be recomputed from the proposal at any time. +fn timelock_op_id(env: &Env, p: &Proposal, id: &BytesN<32>) -> BytesN<32> { + timelock(env).hash_operation(&p.targets, &p.fns, &p.args, &None, id) +} + fn cast(env: &Env, voter: Address, id: BytesN<32>, support: u32) -> i128 { migrations::require_current(env); let mut p = load(env, &id); @@ -261,6 +271,9 @@ impl Governor { } /// Hands a succeeded proposal to the timelock with its minimum delay. + /// + /// The proposal's own id is the salt, so the timelock's operation id is + /// derived from the proposal and can be recomputed from it later. pub fn queue(env: Env, id: BytesN<32>) -> u64 { migrations::require_current(&env); let mut p = load(&env, &id); @@ -268,11 +281,20 @@ impl Governor { panic_with_error!(&env, Error::InvalidState); } let tl = timelock(&env); - let eta = tl.schedule(&id, &p.targets, &p.fns, &p.args, &tl.get_min_delay()); - p.eta = eta; + let delay = tl.get_min_delay(); + let op = tl.schedule( + &env.current_contract_address(), + &p.targets, + &p.fns, + &p.args, + &None, + &id, + &delay, + ); + p.eta = tl.get_timestamp(&op); save(&env, &id, &p); - events::proposal_queued(&env, id, eta); - eta + events::proposal_queued(&env, id, p.eta); + p.eta } /// The proposer may cancel while Pending. Anyone may cancel a @@ -300,7 +322,8 @@ impl Governor { p.canceled = true; save(&env, &id, &p); if state == ProposalState::Queued { - timelock(&env).cancel(&env.current_contract_address(), &id); + let op = timelock_op_id(&env, &p, &id); + timelock(&env).cancel(&env.current_contract_address(), &op); } events::proposal_canceled(&env, id); } @@ -445,3 +468,4 @@ impl Governor { events::param_set(env, "proposal_threshold", value); } } + diff --git a/governor/src/test.rs b/governor/src/test.rs index 896a363..6ed234e 100644 --- a/governor/src/test.rs +++ b/governor/src/test.rs @@ -79,7 +79,14 @@ fn setup<'a>() -> Harness<'a> { let gov_id = env.register_contract(None, Governor); let tl = TimelockClient::new(&env, &env.register_contract(None, Timelock)); let guardian = Address::generate(&env); - tl.initialize(&gov_id, &guardian, &TL_DELAY, &TL_GRACE); + // The governor proposes and cancels; the guardian is the outside veto. + tl.initialize( + &TL_DELAY, + &TL_GRACE, + &vec![&env, gov_id.clone()], + &vec![&env], + &vec![&env, gov_id.clone(), guardian.clone()], + ); let gov = GovernorClient::new(&env, &gov_id); gov.initialize( &votes.address, @@ -134,6 +141,14 @@ impl Harness<'_> { ) } + /// The timelock operation a queued proposal maps to. The timelock derives + /// an operation's id from the batch, the predecessor and the salt, and + /// the governor queues a proposal under the proposal's own id as salt. + fn tl_op_id(&self, id: &BytesN<32>, bps: u32) -> BytesN<32> { + let (t, f, a) = self.quorum_action(bps); + self.tl.hash_operation(&t, &f, &a, &None, id) + } + fn quorum_action(&self, bps: u32) -> (Vec
, Vec, Vec>) { let e = &self.env; ( @@ -178,11 +193,11 @@ fn full_lifecycle_through_timelock() { let eta = h.gov.queue(&id); assert_eq!(eta, h.env.ledger().timestamp() + TL_DELAY); assert_eq!(h.gov.state(&id), ProposalState::Queued); - assert!(h.tl.try_execute(&id).is_err()); // delay not elapsed + let (t, f, a) = h.quorum_action(500); + assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); // delay not elapsed h.warp(TL_DELAY); - h.env.set_auths(&[]); // execution needs no signature at all - h.tl.execute(&id); + h.tl.execute(&h.alice, &t, &f, &a, &None, &id); assert_eq!(h.gov.state(&id), ProposalState::Executed); assert_eq!(h.gov.quorum_bps(), 500); } @@ -307,9 +322,11 @@ fn anyone_cancels_when_proposer_drops_below_threshold() { h.roll(1); h.gov.cancel(&stranger, &id); assert_eq!(h.gov.state(&id), ProposalState::Canceled); - assert!(h.tl.get_operation(&id).is_none()); + let op = h.tl_op_id(&id, 500); + assert!(h.tl.get_operation(&op).is_none()); h.warp(TL_DELAY); - assert!(h.tl.try_execute(&id).is_err()); + let (t, f, a) = h.quorum_action(500); + assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); } #[test] @@ -319,7 +336,8 @@ fn queued_proposal_expires() { h.gov.queue(&id); h.warp(TL_DELAY + TL_GRACE + 1); assert_eq!(h.gov.state(&id), ProposalState::Expired); - assert!(h.tl.try_execute(&id).is_err()); + let (t, f, a) = h.quorum_action(500); + assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); } #[test] @@ -327,9 +345,11 @@ fn guardian_can_veto_queued_proposal() { let h = setup(); let id = h.passed(); h.gov.queue(&id); - h.tl.cancel(&h.guardian, &id); + let op = h.tl_op_id(&id, 500); + h.tl.cancel(&h.guardian, &op); h.warp(TL_DELAY); - assert!(h.tl.try_execute(&id).is_err()); + let (t, f, a) = h.quorum_action(500); + assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); } #[test] @@ -349,3 +369,4 @@ fn params_only_via_governance_and_within_bounds() { h.gov.set_voting_period(&1_000); assert_eq!(h.gov.voting_period(), 1_000); } + diff --git a/integration_tests/tests/handover.rs b/integration_tests/tests/handover.rs index 013a450..df69294 100644 --- a/integration_tests/tests/handover.rs +++ b/integration_tests/tests/handover.rs @@ -2,9 +2,9 @@ //! //! Stage 0 deployer EOA is admin of options_market and price_oracle //! Stage 1 admin → multisig account -//! Stage 2 admin → timelock (proposer + guardian = multisig) -//! Stage 3 timelock proposer → governor (guardian still multisig) -//! Stage 4 lock-in: guardian removed, no more rollback +//! Stage 2 admin → timelock (proposer + canceller = multisig) +//! Stage 3 the governor joins the timelock's proposers +//! Stage 4 lock-in: the multisig's roles are revoked, no more rollback //! //! The vault's admin stays options_market throughout. //! @@ -17,11 +17,11 @@ use multisig::{Multisig, MultisigClient}; use options_market::{OptionsMarket, OptionsMarketClient}; use price_oracle::{PriceOracle, PriceOracleClient}; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, + contract, contractimpl, contracttype, testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, }; -use timelock::{Timelock, TimelockClient}; +use timelock::{Role, Timelock, TimelockClient}; use vault::{Vault, VaultClient}; const TL_DELAY: u64 = 2 * 86_400; @@ -78,7 +78,7 @@ struct World<'a> { env: Env, deployer: Address, /// The native Stellar multisig account (M-of-N signers) that holds - /// admin during stage 1 and acts as timelock proposer/guardian. + /// admin during stage 1 and acts as timelock proposer and canceller. ms_account: Address, ms_signers: [Address; 3], /// The on-chain Multisig contract backing every `*_via_multisig` @@ -132,9 +132,19 @@ fn deploy<'a>() -> World<'a> { let voter = Address::generate(&env); votes.mint(&voter, &1_000_000); + let gov_id = env.register_contract(None, Governor); let tl = TimelockClient::new(&env, &env.register_contract(None, Timelock)); - tl.initialize(&ms_account, &ms_account, &TL_DELAY, &TL_GRACE); - let gov = GovernorClient::new(&env, &env.register_contract(None, Governor)); + // The multisig proposes and vetoes, and the governor may veto too; an + // empty executor set means anyone may execute a ready operation. The + // governor only becomes a proposer at stage 3. + tl.initialize( + &TL_DELAY, + &TL_GRACE, + &vec![&env, ms_account.clone()], + &vec![&env], + &vec![&env, ms_account.clone(), gov_id.clone()], + ); + let gov = GovernorClient::new(&env, &gov_id); gov.initialize( &votes.address, &tl.address, @@ -164,6 +174,9 @@ fn deploy<'a>() -> World<'a> { w } +/// A batch of calls: the target of each, the function, and the arguments. +type Calls = (Vec
, Vec, Vec>); + impl World<'_> { fn roll(&self, ledgers: u32) { let seq = self.env.ledger().sequence(); @@ -188,6 +201,13 @@ impl World<'_> { }]); } + /// A fresh salt, so two otherwise identical batches get distinct ids. + fn salt(&self) -> BytesN<32> { + let n = self.op_nonce.get() + 1; + self.op_nonce.set(n); + BytesN::from_array(&self.env, &[n; 32]) + } + /// Can `who` exercise every admin function we check on both contracts? fn admin_can_act(&self, who: &Address) -> bool { let e = &self.env; @@ -229,10 +249,7 @@ impl World<'_> { self.oracle.transfer_admin(new_admin); } - fn transfer_both_calls( - &self, - new_admin: &Address, - ) -> (Vec
, Vec, Vec>) { + fn transfer_both_calls(&self, new_admin: &Address) -> Calls { let e = &self.env; let arg: Vec = vec![e, new_admin.into_val(e)]; ( @@ -246,27 +263,71 @@ impl World<'_> { ) } + /// The arguments of a `schedule` call, as the host sees them. + fn schedule_args(&self, calls: &Calls, salt: &BytesN<32>) -> Vec { + let e = &self.env; + let (t, f, a) = calls; + vec![ + e, + self.ms_account.clone().into_val(e), + t.clone().into_val(e), + f.clone().into_val(e), + a.clone().into_val(e), + None::>.into_val(e), + salt.clone().into_val(e), + TL_DELAY.into_val(e), + ] + } + + /// The arguments of an `execute` call, as the host sees them. + fn execute_args(&self, calls: &Calls, salt: &BytesN<32>) -> Vec { + let e = &self.env; + let (t, f, a) = calls; + vec![ + e, + self.ms_account.clone().into_val(e), + t.clone().into_val(e), + f.clone().into_val(e), + a.clone().into_val(e), + None::>.into_val(e), + salt.clone().into_val(e), + ] + } + /// Multisig (as timelock proposer) schedules a batch; returns its id. - fn ms_schedule(&self, calls: (Vec
, Vec, Vec>)) -> BytesN<32> { - let n = self.op_nonce.get() + 1; - self.op_nonce.set(n); - let id = BytesN::from_array(&self.env, &[n; 32]); + fn ms_schedule(&self, calls: Calls, salt: &BytesN<32>) -> BytesN<32> { let (t, f, a) = calls; - let args: Vec = - (id.clone(), t.clone(), f.clone(), a.clone(), TL_DELAY).into_val(&self.env); - self.sign(&self.ms_account, &self.tl.address, "schedule", args); - self.tl.schedule(&id, &t, &f, &a, &TL_DELAY); - id + self.sign( + &self.ms_account, + &self.tl.address, + "schedule", + self.schedule_args(&(t.clone(), f.clone(), a.clone()), salt), + ); + self.tl.schedule( + &self.ms_account, + &t, + &f, + &a, + &None, + salt, + &TL_DELAY, + ) } - fn execute_after_delay(&self, id: &BytesN<32>) { + fn execute_after_delay(&self, calls: &Calls, salt: &BytesN<32>) { self.warp(TL_DELAY); - self.env.set_auths(&[]); - self.tl.execute(id); + let (t, f, a) = calls; + self.sign( + &self.ms_account, + &self.tl.address, + "execute", + self.execute_args(calls, salt), + ); + self.tl.execute(&self.ms_account, &t, &f, &a, &None, salt); } /// Runs a full governor proposal through to timelock execution. - fn govern(&self, calls: (Vec
, Vec, Vec>)) -> BytesN<32> { + fn govern(&self, calls: Calls) -> BytesN<32> { let e = &self.env; let (t, f, a) = calls; let n = self.op_nonce.get() + 1; @@ -279,7 +340,9 @@ impl World<'_> { self.roll(VOTING_PERIOD); e.set_auths(&[]); self.gov.queue(&id); - self.execute_after_delay(&id); + // The governor queues its proposal under the proposal's own id as the + // salt, so the timelock's operation id is derived from it. + self.execute_after_delay(&(t.clone(), f.clone(), a.clone()), &id); assert_eq!(self.gov.state(&id), ProposalState::Executed); id } @@ -293,24 +356,35 @@ impl World<'_> { self.transfer_both(&self.ms_account.clone(), &self.tl.address.clone()); } + /// The governor joins the timelock's proposers. The multisig keeps its + /// own proposer role until lock-in, which is what makes stage 3 + /// reversible: it can still propose a corrective action. fn to_stage3(&self) { self.to_stage2(); let e = &self.env; - let id = self.ms_schedule(( + let salt = self.salt(); + let calls: Calls = ( vec![e, self.tl.address.clone()], - vec![e, symbol_short!("set_prop")], - vec![e, vec![e, self.gov.address.into_val(e)]], - )); - self.execute_after_delay(&id); + vec![e, Symbol::new(e, "grant_role")], + vec![e, vec![e, Role::Proposer.into_val(e), self.gov.address.clone().into_val(e)]], + ); + self.ms_schedule(calls.clone(), &salt); + self.execute_after_delay(&calls, &salt); + assert!(self.tl.has_role(&Role::Proposer, &self.gov.address)); } + /// Lock-in: the multisig's proposer and canceller roles are revoked, so + /// the rollback paths are gone for good. fn to_stage4(&self) { self.to_stage3(); let e = &self.env; self.govern(( - vec![e, self.tl.address.clone()], - vec![e, symbol_short!("lock_in")], - vec![e, Vec::::new(e)], + vec![e, self.tl.address.clone(), self.tl.address.clone()], + vec![e, Symbol::new(e, "revoke_role"), Symbol::new(e, "revoke_role")], + vec![e, + vec![e, Role::Proposer.into_val(e), self.ms_account.clone().into_val(e)], + vec![e, Role::Canceller.into_val(e), self.ms_account.clone().into_val(e)], + ], )); } } @@ -338,36 +412,22 @@ fn staged_handover_end_to_end() { w.transfer_both(&w.ms_account, &w.tl.address); assert!(!w.admin_can_act(&w.ms_account)); assert!(!w.admin_can_act(&w.deployer)); + let e = &w.env; - let id = w.ms_schedule(( + let salt = w.salt(); + let calls = ( vec![e, w.market.address.clone()], vec![e, Symbol::new(e, "set_fee_rate")], vec![e, vec![e, 120u32.into_val(e)]], - )); - w.execute_after_delay(&id); + ); + w.ms_schedule(calls.clone(), &salt); + w.execute_after_delay(&calls, &salt); assert_eq!(w.market.get_fee_rate(), 120); w.assert_vault_invariant(); // Stage 3 - let id = w.ms_schedule(( - vec![e, w.tl.address.clone()], - vec![e, symbol_short!("set_prop")], - vec![e, vec![e, w.gov.address.into_val(e)]], - )); - w.execute_after_delay(&id); - assert_eq!(w.tl.get_proposer(), w.gov.address); - // Multisig can no longer schedule. - let calls = w.transfer_both_calls(&w.ms_account); - let (t, f, a) = calls.clone(); - let bogus = BytesN::from_array(e, &[0xAA; 32]); - w.sign( - &w.ms_account, - &w.tl.address, - "schedule", - (bogus.clone(), t.clone(), f.clone(), a.clone(), TL_DELAY).into_val(e), - ); - assert!(w.tl.try_schedule(&bogus, &t, &f, &a, &TL_DELAY).is_err()); - // Governor can act on every admin function. + w.to_stage3(); + // The governor can now act on every admin function. w.govern(( vec![e, w.market.address.clone(), w.oracle.address.clone()], vec![ @@ -382,12 +442,9 @@ fn staged_handover_end_to_end() { w.assert_vault_invariant(); // Stage 4 - w.govern(( - vec![e, w.tl.address.clone()], - vec![e, symbol_short!("lock_in")], - vec![e, Vec::::new(e)], - )); - assert!(w.tl.is_locked_in()); + w.to_stage4(); + assert!(!w.tl.has_role(&Role::Proposer, &w.ms_account)); + assert!(!w.tl.has_role(&Role::Canceller, &w.ms_account)); assert_eq!(w.market.get_admin(), w.tl.address); assert_eq!(w.oracle.get_admin(), w.tl.address); w.assert_vault_invariant(); @@ -408,18 +465,22 @@ fn rollback_stage1_multisig_returns_admin() { fn rollback_stage2_multisig_reclaims_admin_through_timelock() { let w = deploy(); w.to_stage2(); - let id = w.ms_schedule(w.transfer_both_calls(&w.ms_account)); - w.execute_after_delay(&id); + let salt = w.salt(); + let calls = w.transfer_both_calls(&w.ms_account); + w.ms_schedule(calls.clone(), &salt); + w.execute_after_delay(&calls, &salt); assert!(w.admin_can_act(&w.ms_account)); w.assert_vault_invariant(); } #[test] -fn rollback_stage2_guardian_cancels_bad_handover() { +fn rollback_stage2_canceller_cancels_bad_handover() { let w = deploy(); w.to_stage2(); let attacker = Address::generate(&w.env); - let id = w.ms_schedule(w.transfer_both_calls(&attacker)); + let salt = w.salt(); + let calls = w.transfer_both_calls(&attacker); + let id = w.ms_schedule(calls.clone(), &salt); w.sign( &w.ms_account, &w.tl.address, @@ -428,12 +489,16 @@ fn rollback_stage2_guardian_cancels_bad_handover() { ); w.tl.cancel(&w.ms_account, &id); w.warp(TL_DELAY); - assert!(w.tl.try_execute(&id).is_err()); + let (t, f, a) = calls; + assert!(w + .tl + .try_execute(&w.ms_account, &t, &f, &a, &None, &salt) + .is_err()); assert_eq!(w.market.get_admin(), w.tl.address); } #[test] -fn rollback_stage3_guardian_restores_multisig_proposer() { +fn rollback_stage3_canceller_vetoes_a_governor_proposal() { let w = deploy(); w.to_stage3(); @@ -454,29 +519,28 @@ fn rollback_stage3_guardian_restores_multisig_proposer() { w.env.set_auths(&[]); w.gov.queue(&id); - // … the guardian vetoes it and rolls the proposer back to itself. + // … and the multisig vetoes it. + let op = w.tl.hash_operation(&t, &f, &a, &None, &id); w.sign( &w.ms_account, &w.tl.address, "cancel", - (w.ms_account.clone(), id.clone()).into_val(&w.env), + (w.ms_account.clone(), op.clone()).into_val(&w.env), ); - w.tl.cancel(&w.ms_account, &id); - w.sign( - &w.ms_account, - &w.tl.address, - "guardian_set_proposer", - (w.ms_account.clone(),).into_val(&w.env), - ); - w.tl.guardian_set_proposer(&w.ms_account); - assert_eq!(w.tl.get_proposer(), w.ms_account); + w.tl.cancel(&w.ms_account, &op); w.warp(TL_DELAY); - assert!(w.tl.try_execute(&id).is_err()); + assert!(w + .tl + .try_execute(&w.ms_account, &t, &f, &a, &None, &id) + .is_err()); assert_eq!(w.market.get_admin(), w.tl.address); - // The multisig is back in control of the timelock. - let fix = w.ms_schedule(w.transfer_both_calls(&w.ms_account)); - w.execute_after_delay(&fix); + // The multisig kept the proposer role until lock-in, so it can still + // schedule the corrective action. + let salt = w.salt(); + let fix = w.transfer_both_calls(&w.ms_account); + w.ms_schedule(fix.clone(), &salt); + w.execute_after_delay(&fix, &salt); assert!(w.admin_can_act(&w.ms_account)); } @@ -484,14 +548,8 @@ fn rollback_stage3_guardian_restores_multisig_proposer() { fn stage4_lock_in_removes_every_rollback() { let w = deploy(); w.to_stage4(); - w.sign( - &w.ms_account, - &w.tl.address, - "guardian_set_proposer", - (w.ms_account.clone(),).into_val(&w.env), - ); - assert!(w.tl.try_guardian_set_proposer(&w.ms_account).is_err()); - assert_eq!(w.tl.get_proposer(), w.gov.address); + assert!(!w.tl.has_role(&Role::Proposer, &w.ms_account)); + assert!(!w.tl.has_role(&Role::Canceller, &w.ms_account)); assert!(!w.admin_can_act(&w.ms_account)); assert!(!w.admin_can_act(&w.deployer)); } diff --git a/scripts/governance/common.sh b/scripts/governance/common.sh old mode 100755 new mode 100644 index 057d5b3..1c1c5e9 --- a/scripts/governance/common.sh +++ b/scripts/governance/common.sh @@ -64,19 +64,41 @@ show_admins() { } # Schedules a timelock batch as $1 (the current proposer). Remaining args: -# op_id targets_json fns_json args_json. +# targets_json fns_json args_json salt_hex. +# +# There is no operation id to pass in: the timelock derives it from the +# batch, the predecessor and the salt, and `schedule` returns it. tl_schedule() { - local proposer="$1" op_id="$2" targets="$3" fns="$4" args="$5" + local proposer="$1" targets="$2" fns="$3" args="$4" salt="$5" invoke "$TIMELOCK_ID" "$proposer" schedule \ - --op_id "$op_id" --targets "$targets" --fns "$fns" --args "$args" \ + --targets "$targets" --fns "$fns" --args "$args" \ + --predecessor null --salt "$salt" \ --delay "${TL_DELAY:?TL_DELAY must be set}" } +# Executes the batch a `tl_schedule` queued. The salt has to be the same +# one it was scheduled with: the id is derived from it. tl_execute() { - invoke "$TIMELOCK_ID" "${EXECUTOR:-$1}" execute --op_id "$2" + local executor="$1" targets="$2" fns="$3" args="$4" salt="$5" + invoke "$TIMELOCK_ID" "$executor" execute \ + --targets "$targets" --fns "$fns" --args "$args" \ + --predecessor null --salt "$salt" } -# 32-byte hex op id derived from a label, so reruns are deterministic. -op_id() { +# 32-byte hex salt derived from a label, so reruns are deterministic. The +# salt is what makes two otherwise identical batches distinct operations. +salt() { printf '%s' "$1" | sha256sum | cut -d' ' -f1 } + +# The timelock's roles are a contract-type enum: Proposer = 0, Executor = 1, +# Canceller = 2. +role() { + case "$1" in + proposer) echo 0 ;; + executor) echo 1 ;; + canceller) echo 2 ;; + *) echo "unknown role: $1" >&2; exit 1 ;; + esac +} + diff --git a/scripts/governance/stage1_admin_to_multisig.sh b/scripts/governance/stage1_admin_to_multisig.sh old mode 100755 new mode 100644 index 10086e6..2fd51a6 --- a/scripts/governance/stage1_admin_to_multisig.sh +++ b/scripts/governance/stage1_admin_to_multisig.sh @@ -8,3 +8,4 @@ show_admins invoke "$MARKET_ID" "$DEPLOYER" transfer_admin --new_admin "$MULTISIG_ACCOUNT" invoke "$ORACLE_ID" "$DEPLOYER" transfer_admin --new_admin "$MULTISIG_ACCOUNT" show_admins + diff --git a/scripts/governance/stage1_rollback.sh b/scripts/governance/stage1_rollback.sh old mode 100755 new mode 100644 index 74d29c2..ea00412 --- a/scripts/governance/stage1_rollback.sh +++ b/scripts/governance/stage1_rollback.sh @@ -6,3 +6,4 @@ require DEPLOYER MULTISIG_ACCOUNT invoke "$MARKET_ID" "$MULTISIG_ACCOUNT" transfer_admin --new_admin "$DEPLOYER" invoke "$ORACLE_ID" "$MULTISIG_ACCOUNT" transfer_admin --new_admin "$DEPLOYER" show_admins + diff --git a/scripts/governance/stage2_admin_to_timelock.sh b/scripts/governance/stage2_admin_to_timelock.sh old mode 100755 new mode 100644 index b7a7554..4ab1873 --- a/scripts/governance/stage2_admin_to_timelock.sh +++ b/scripts/governance/stage2_admin_to_timelock.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # Stage 1 → 2: the multisig account hands admin to the timelock (whose -# proposer and guardian are the multisig account). +# proposer and canceller are the multisig account). # Rollback: stage2_rollback.sh. source "$(dirname "$0")/common.sh" require MULTISIG_ACCOUNT -echo "timelock proposer: $(view "$TIMELOCK_ID" get_proposer) (expect $MULTISIG_ACCOUNT)" -echo "timelock guardian: $(view "$TIMELOCK_ID" get_guardian) (expect $MULTISIG_ACCOUNT)" +echo "timelock proposer: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$MULTISIG_ACCOUNT") (expect true)" +echo "timelock canceller: $(view "$TIMELOCK_ID" has_role --role "$(role canceller)" --account "$MULTISIG_ACCOUNT") (expect true)" invoke "$MARKET_ID" "$MULTISIG_ACCOUNT" transfer_admin --new_admin "$TIMELOCK_ID" invoke "$ORACLE_ID" "$MULTISIG_ACCOUNT" transfer_admin --new_admin "$TIMELOCK_ID" show_admins + diff --git a/scripts/governance/stage2_rollback.sh b/scripts/governance/stage2_rollback.sh old mode 100755 new mode 100644 index d4a175a..655ccbf --- a/scripts/governance/stage2_rollback.sh +++ b/scripts/governance/stage2_rollback.sh @@ -5,15 +5,17 @@ source "$(dirname "$0")/common.sh" require MULTISIG_ACCOUNT PHASE -ID=$(op_id "rollback-stage2-$MARKET_ID") +SALT=$(salt "rollback-stage2-$MARKET_ID") ARG="[{\"address\":\"$MULTISIG_ACCOUNT\"}]" case "$PHASE" in schedule) - tl_schedule "$MULTISIG_ACCOUNT" "$ID" \ - "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" + tl_schedule "$MULTISIG_ACCOUNT" \ + "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" "$SALT" ;; execute) - tl_execute "$MULTISIG_ACCOUNT" "$ID" + tl_execute "$MULTISIG_ACCOUNT" \ + "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" "$SALT" show_admins ;; esac + diff --git a/scripts/governance/stage3_proposer_to_governor.sh b/scripts/governance/stage3_proposer_to_governor.sh old mode 100755 new mode 100644 index ef40394..35dd5fa --- a/scripts/governance/stage3_proposer_to_governor.sh +++ b/scripts/governance/stage3_proposer_to_governor.sh @@ -1,18 +1,22 @@ #!/usr/bin/env bash -# Stage 2 → 3: the multisig schedules a timelock self-call making the -# governor the only proposer. Run with PHASE=schedule, then after TL_DELAY -# with PHASE=execute. Rollback: stage3_rollback.sh (immediate, guardian). +# Stage 2 → 3: the multisig schedules a timelock self-call adding the +# governor as a proposer. Run with PHASE=schedule, then after TL_DELAY with +# PHASE=execute. Rollback: stage3_rollback.sh (the multisig keeps its +# proposer role until lock-in, so it can cancel and re-propose). source "$(dirname "$0")/common.sh" require MULTISIG_ACCOUNT PHASE -ID=$(op_id "stage3-$TIMELOCK_ID") +SALT=$(salt "stage3-$TIMELOCK_ID") +TARGETS="[\"$TIMELOCK_ID\"]" +FNS='["grant_role"]' +ARGS="[[$(role proposer),\"$GOVERNOR_ID\"]]" case "$PHASE" in schedule) - tl_schedule "$MULTISIG_ACCOUNT" "$ID" \ - "[\"$TIMELOCK_ID\"]" '["set_prop"]' "[[{\"address\":\"$GOVERNOR_ID\"}]]" + tl_schedule "$MULTISIG_ACCOUNT" "$TARGETS" "$FNS" "$ARGS" "$SALT" ;; execute) - tl_execute "$MULTISIG_ACCOUNT" "$ID" - echo "timelock proposer: $(view "$TIMELOCK_ID" get_proposer) (expect $GOVERNOR_ID)" + tl_execute "$MULTISIG_ACCOUNT" "$TARGETS" "$FNS" "$ARGS" "$SALT" + echo "timelock governor is proposer: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$GOVERNOR_ID") (expect true)" ;; esac + diff --git a/scripts/governance/stage3_rollback.sh b/scripts/governance/stage3_rollback.sh old mode 100755 new mode 100644 index 00f911c..7609d6a --- a/scripts/governance/stage3_rollback.sh +++ b/scripts/governance/stage3_rollback.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# Rollback stage 3: the guardian (multisig) cancels any queued governor -# operation given as CANCEL_OP_ID and immediately restores itself as the -# timelock proposer. Unavailable after stage 4. +# Rollback stage 3: the multisig is the timelock's canceller, so it can +# cancel the queued governor operation given as CANCEL_OP_ID. It keeps the +# proposer role until lock-in, so it can also schedule the corrective +# operation itself. After stage 4 it holds neither role and can do neither. source "$(dirname "$0")/common.sh" require MULTISIG_ACCOUNT if [[ -n "${CANCEL_OP_ID:-}" ]]; then invoke "$TIMELOCK_ID" "$MULTISIG_ACCOUNT" cancel \ - --caller "$MULTISIG_ACCOUNT" --op_id "$CANCEL_OP_ID" + --canceller "$MULTISIG_ACCOUNT" --id "$CANCEL_OP_ID" fi -invoke "$TIMELOCK_ID" "$MULTISIG_ACCOUNT" guardian_set_proposer \ - --new_proposer "$MULTISIG_ACCOUNT" -echo "timelock proposer: $(view "$TIMELOCK_ID" get_proposer)" +echo "timelock multisig is proposer: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$MULTISIG_ACCOUNT")" +echo "timelock multisig is canceller: $(view "$TIMELOCK_ID" has_role --role "$(role canceller)" --account "$MULTISIG_ACCOUNT")" diff --git a/scripts/governance/stage4_lock_in.sh b/scripts/governance/stage4_lock_in.sh old mode 100755 new mode 100644 index be7a1ec..41bd5a4 --- a/scripts/governance/stage4_lock_in.sh +++ b/scripts/governance/stage4_lock_in.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# Stage 3 → 4: a governor proposal calling the timelock's lock_in, which -# permanently removes the guardian. IRREVERSIBLE. +# Stage 3 → 4: a governor proposal revoking the multisig's timelock roles, +# which permanently removes the rollback paths. IRREVERSIBLE. # PHASE=propose → vote (VOTER, SUPPORT=1) → queue → execute. source "$(dirname "$0")/common.sh" require PHASE -DESC=$(op_id "Lock in governance handover") -TARGETS="[\"$TIMELOCK_ID\"]" -FNS='["lock_in"]' -ARGS='[[]]' +DESC=$(salt "Lock in governance handover") +TARGETS="[\"$TIMELOCK_ID\",\"$TIMELOCK_ID\"]" +FNS='["revoke_role","revoke_role"]' +ARGS="[[$(role proposer),\"$MULTISIG_ACCOUNT\"],[$(role canceller),\"$MULTISIG_ACCOUNT\"]]" case "$PHASE" in propose) require PROPOSER @@ -26,7 +26,8 @@ case "$PHASE" in ;; execute) require PROPOSAL_ID EXECUTOR - tl_execute "$EXECUTOR" "$PROPOSAL_ID" - echo "locked in: $(view "$TIMELOCK_ID" is_locked_in)" + tl_execute "$EXECUTOR" "$TARGETS" "$FNS" "$ARGS" "$PROPOSAL_ID" + echo "locked in: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$MULTISIG_ACCOUNT") (expect false)" ;; esac + diff --git a/timelock/Cargo.toml b/timelock/Cargo.toml index 55d45d6..3d5a842 100644 --- a/timelock/Cargo.toml +++ b/timelock/Cargo.toml @@ -9,6 +9,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } +# The storage-schema migration framework every Zenith contract uses. +zenith-common = { path = "../common" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/timelock/src/error.rs b/timelock/src/error.rs index e69de29..8f2502c 100644 --- a/timelock/src/error.rs +++ b/timelock/src/error.rs @@ -0,0 +1,37 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// `initialize` was called twice. The timelock has no admin afterwards, + /// so its setup is one-shot. + AlreadyInitialized = 1, + /// The caller does not hold the role the entrypoint requires. + Unauthorized = 2, + /// The delay is shorter than `min_delay`, or `min_delay` itself is below + /// the documented floor. + InsufficientDelay = 3, + /// An operation with this id is already scheduled (or already done). + AlreadyScheduled = 4, + /// The operation is not ready yet: the delay has not elapsed. + NotReady = 5, + /// The operation's predecessor has not executed. + PredecessorNotDone = 6, + /// There is no pending operation with this id: it was never scheduled, + /// it was cancelled, or it already executed. + NotPending = 7, + /// A self-call named a function this timelock does not implement. + UnknownSelfCall = 8, + /// The operation carries no calls, or its call vectors disagree in length. + EmptyOperation = 9, + /// The operation was ready, but its grace period has passed. + Expired = 10, + // Schema migrations (zenith_common::migrations) — same codes in + // every Zenith contract. See docs/migrations.md. + SchemaVersionMismatch = 100, + InvalidMigrationTarget = 101, + MigrationInProgress = 102, + NoMigrationInProgress = 103, + InvalidMigrationBatch = 104, +} diff --git a/timelock/src/events.rs b/timelock/src/events.rs index e69de29..71d6de6 100644 --- a/timelock/src/events.rs +++ b/timelock/src/events.rs @@ -0,0 +1,36 @@ +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::Role; + +/// A batch was scheduled. `ready_at` is the ledger timestamp from which the +/// operation may be executed. +pub fn scheduled(env: &Env, id: BytesN<32>, proposer: Address, ready_at: u64) { + env.events() + .publish((Symbol::new(env, "scheduled"), id), (proposer, ready_at)); +} + +/// A batch executed. Every call in it ran, in order. +pub fn executed(env: &Env, id: BytesN<32>) { + env.events().publish((Symbol::new(env, "executed"), id), ()); +} + +/// A pending operation was cancelled and will never run. +pub fn cancelled(env: &Env, id: BytesN<32>, canceller: Address) { + env.events() + .publish((Symbol::new(env, "cancelled"), id), canceller); +} + +/// The shortest schedulable delay changed. +pub fn min_delay_changed(env: &Env, old: u64, new: u64) { + env.events() + .publish((Symbol::new(env, "min_delay_changed"),), (old, new)); +} + +/// A role was granted or revoked. Both directions are announced: a role +/// quietly disappearing is exactly the thing an operator has to notice. +pub fn role_changed(env: &Env, role: Role, account: Address, granted: bool) { + env.events().publish( + (Symbol::new(env, "role_changed"),), + (role, account, granted), + ); +} diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index e69de29..26ce25b 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -0,0 +1,429 @@ +#![no_std] + +//! Zenith Timelock — a delayed-execution admin. +//! +//! The timelock is meant to hold the `admin` role on every other Zenith +//! contract, so any parameter or code change is visible on-chain for at least +//! `min_delay` seconds before it can run. Users get an exit window; a +//! canceller gets a veto. Modelled on OpenZeppelin's TimelockController. +//! +//! Three roles, and no admin: +//! +//! Proposer may schedule an operation. +//! Executor may execute a ready operation (anyone may, if the executor +//! set is empty — see `is_open_executor`). +//! Canceller may cancel a pending operation. +//! +//! There is deliberately no fourth role and no owner: the timelock's own +//! configuration is changed the same way everything else is, by scheduling an +//! operation whose target is the timelock itself. Soroban forbids a contract +//! re-entering itself, so `execute` recognises those calls and applies them +//! in place instead of cross-calling. +//! +//! An operation is a batch of calls plus a predecessor and a salt. Its id is +//! `sha256(xdr((targets, fns, args, predecessor, salt)))` — it commits to +//! exactly what will run, and the salt is what makes two otherwise identical +//! batches distinct. `execute` recomputes the id from the batch it is handed, +//! so it can only ever run the batch that was scheduled. +//! +//! A predecessor orders operations against each other: an operation whose +//! predecessor has not executed cannot execute. Cancelling a predecessor +//! therefore leaves its dependants permanently unexecutable, which is the +//! point: the batch that was going to run second never runs at all. + +use soroban_sdk::{ + contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, + TryFromVal, Val, Vec, +}; + +#[cfg(test)] +mod test; + +mod error; +mod events; +mod migrations; +mod types; + +use error::Error; +pub use migrations::CURRENT_SCHEMA_VERSION; +use types::DataKey; +pub use types::{Operation, OperationSpec, Role}; + +/// The shortest delay the timelock will ever accept, in seconds. +/// +/// A delay of zero is not a delay: the operation would be executable in the +/// same ledger it was scheduled in, and the exit window this contract exists +/// to create would not exist at all. One minute is the floor — long enough +/// that the operation is a separate, announced event, short enough that it +/// does not stand in the way of a test or a testnet rehearsal. +pub const MIN_MIN_DELAY: u64 = 60; + +#[contract] +pub struct Timelock; + +// ── internals ──────────────────────────────────────────────────────────────── + +fn min_delay(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::MinDelay) + .unwrap_or(MIN_MIN_DELAY) +} + +fn role_key(role: Role, account: &Address) -> DataKey { + match role { + Role::Proposer => DataKey::Proposer(account.clone()), + Role::Executor => DataKey::Executor(account.clone()), + Role::Canceller => DataKey::Canceller(account.clone()), + } +} + +fn holds_role(env: &Env, role: Role, account: &Address) -> bool { + env.storage().instance().has(&role_key(role, account)) +} + +/// Panics unless `account` holds `role`. +fn require_role(env: &Env, role: Role, account: &Address) { + if !holds_role(env, role, account) { + panic_with_error!(env, Error::Unauthorized); + } +} + +fn set_role(env: &Env, role: Role, account: &Address, granted: bool) { + let key = role_key(role, account); + if granted { + env.storage().instance().set(&key, &true); + } else { + env.storage().instance().remove(&key); + } + events::role_changed(env, role, account.clone(), granted); +} + +fn load(env: &Env, id: &BytesN<32>) -> Option { + env.storage().persistent().get(&DataKey::Operation(id.clone())) +} + +/// The `i`th argument of a self-call, decoded. +fn arg>(env: &Env, args: &Vec, i: u32) -> T { + args.get(i) + .and_then(|v| T::try_from_val(env, &v).ok()) + .unwrap_or_else(|| panic_with_error!(env, Error::UnknownSelfCall)) +} + +/// Applies a call whose target is the timelock itself. +/// +/// These are the only way to change the timelock's configuration, and they +/// are only reachable through a delayed operation — so widening the delay or +/// granting yourself a role takes as long to take effect as any other +/// operation. +fn self_call(env: &Env, func: &Symbol, args: &Vec) { + if *func == Symbol::new(env, "update_delay") { + let delay: u64 = arg(env, args, 0); + if delay < MIN_MIN_DELAY { + panic_with_error!(env, Error::InsufficientDelay); + } + let old = min_delay(env); + env.storage().instance().set(&DataKey::MinDelay, &delay); + events::min_delay_changed(env, old, delay); + } else if *func == Symbol::new(env, "grant_role") { + let role: Role = arg(env, args, 0); + let account: Address = arg(env, args, 1); + set_role(env, role, &account, true); + } else if *func == Symbol::new(env, "revoke_role") { + let role: Role = arg(env, args, 0); + let account: Address = arg(env, args, 1); + set_role(env, role, &account, false); + } else if *func == Symbol::new(env, "set_open_executor") { + let open: bool = arg(env, args, 0); + env.storage().instance().set(&DataKey::OpenExecutor, &open); + } else if *func == Symbol::new(env, "migrate") { + let from: u32 = arg(env, args, 0); + let to: u32 = arg(env, args, 1); + migrations::self_migrate(env, from, to); + } else { + panic_with_error!(env, Error::UnknownSelfCall); + } +} + +#[contractimpl] +impl Timelock { + /// One-time setup. There is no admin afterwards: the three role sets are + /// the whole configuration, and they can only be changed by a delayed + /// operation. + /// + /// An empty `executors` list means anyone may execute a ready operation. + /// + /// `grace_period` is how long a ready operation stays executable. Past + /// it the operation is dead and has to be scheduled again: without that + /// bound an operation announced months ago could still land tomorrow, and + /// the exit window it was reviewed for would have long since closed. + pub fn initialize( + env: Env, + min_delay: u64, + grace_period: u64, + proposers: Vec
, + executors: Vec
, + cancellers: Vec
, + ) { + if env.storage().instance().has(&DataKey::Initialized) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + if min_delay < MIN_MIN_DELAY { + panic_with_error!(&env, Error::InsufficientDelay); + } + env.storage().instance().set(&DataKey::MinDelay, &min_delay); + env.storage().instance().set(&DataKey::GracePeriod, &grace_period); + for p in proposers.iter() { + env.storage().instance().set(&DataKey::Proposer(p.clone()), &true); + } + for e in executors.iter() { + env.storage().instance().set(&DataKey::Executor(e.clone()), &true); + } + for c in cancellers.iter() { + env.storage() + .instance() + .set(&DataKey::Canceller(c.clone()), &true); + } + env.storage() + .instance() + .set(&DataKey::OpenExecutor, &executors.is_empty()); + env.storage().instance().set(&DataKey::Initialized, &true); + zenith_common::migrations::init_schema(&env, CURRENT_SCHEMA_VERSION); + } + + /// Schedules a batch of calls for execution no earlier than + /// `now + delay`. Proposer-only, and `delay` may not be shorter than + /// `min_delay`. + /// + /// Returns the operation id, which is derived from the batch itself: the + /// same batch with the same predecessor and the same salt is the same + /// operation, and scheduling it twice is refused. + pub fn schedule( + env: Env, + proposer: Address, + targets: Vec
, + fns: Vec, + args: Vec>, + predecessor: Option>, + salt: BytesN<32>, + delay: u64, + ) -> BytesN<32> { + migrations::require_current(&env); + proposer.require_auth(); + require_role(&env, Role::Proposer, &proposer); + + let n = targets.len(); + if n == 0 || n != fns.len() || n != args.len() { + panic_with_error!(&env, Error::EmptyOperation); + } + if delay < Self::get_min_delay(env.clone()) { + panic_with_error!(&env, Error::InsufficientDelay); + } + + let id = Self::hash_operation( + env.clone(), + targets.clone(), + fns.clone(), + args.clone(), + predecessor.clone(), + salt.clone(), + ); + let key = DataKey::Operation(id.clone()); + if env.storage().persistent().has(&key) { + panic_with_error!(&env, Error::AlreadyScheduled); + } + + let ready_at = env.ledger().timestamp() + delay; + env.storage().persistent().set( + &key, + &Operation { + targets, + fns, + args, + predecessor, + salt, + ready_at, + done: false, + }, + ); + events::scheduled(&env, id.clone(), proposer, ready_at); + id + } + + /// Executes a ready operation. The batch, the predecessor and the salt + /// are passed again because the id is derived from them: only the batch + /// that was scheduled can be executed. + /// + /// Every call runs, in order. A call that reverts reverts the whole + /// operation — there is no partial application. + pub fn execute( + env: Env, + executor: Address, + targets: Vec
, + fns: Vec, + args: Vec>, + predecessor: Option>, + salt: BytesN<32>, + ) { + migrations::require_current(&env); + executor.require_auth(); + if !Self::is_open_executor(env.clone()) { + require_role(&env, Role::Executor, &executor); + } + + let id = Self::hash_operation( + env.clone(), + targets, + fns, + args, + predecessor.clone(), + salt.clone(), + ); + let key = DataKey::Operation(id.clone()); + let mut op: Operation = load(&env, &id).unwrap_or_else(|| { + panic_with_error!(&env, Error::NotPending) + }); + if op.done { + panic_with_error!(&env, Error::NotPending); + } + + let now = env.ledger().timestamp(); + if now < op.ready_at { + panic_with_error!(&env, Error::NotReady); + } + if now > op.ready_at + Self::get_grace_period(env.clone()) { + panic_with_error!(&env, Error::Expired); + } + + // The predecessor must have executed. A cancelled predecessor is + // gone from storage, so it reads as "not done" here: the dependant + // stays unexecutable rather than running out of order. + if let Some(pred) = op.predecessor.clone() { + let done = load(&env, &pred).is_some_and(|p| p.done); + if !done { + panic_with_error!(&env, Error::PredecessorNotDone); + } + } + + op.done = true; + env.storage().persistent().set(&key, &op); + + let this = env.current_contract_address(); + let n = op.targets.len(); + for i in 0..n { + let target = op.targets.get(i).unwrap(); + let func = op.fns.get(i).unwrap(); + let call_args = op.args.get(i).unwrap(); + if target == this { + self_call(&env, &func, &call_args); + } else { + env.invoke_contract::(&target, &func, call_args); + } + } + events::executed(&env, id); + } + + /// Cancels a pending operation. Canceller-only. + /// + /// The operation's record is removed, so it can never be executed and + /// anything waiting on it as a predecessor is stuck for good. + pub fn cancel(env: Env, canceller: Address, id: BytesN<32>) { + migrations::require_current(&env); + canceller.require_auth(); + require_role(&env, Role::Canceller, &canceller); + + let key = DataKey::Operation(id.clone()); + let op: Operation = + load(&env, &id).unwrap_or_else(|| panic_with_error!(&env, Error::NotPending)); + if op.done { + panic_with_error!(&env, Error::NotPending); + } + env.storage().persistent().remove(&key); + events::cancelled(&env, id, canceller); + } + + // ── Views ──────────────────────────────────────────────────────────────── + + /// The shortest delay an operation may be scheduled with, in seconds. + pub fn get_min_delay(env: Env) -> u64 { + min_delay(&env) + } + + /// How long a ready operation stays executable, in seconds. + pub fn get_grace_period(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::GracePeriod) + .unwrap_or(0) + } + + /// True when anyone may execute a ready operation. + pub fn is_open_executor(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::OpenExecutor) + .unwrap_or(false) + } + + /// True when `account` holds `role`. + pub fn has_role(env: Env, role: Role, account: Address) -> bool { + holds_role(&env, role, &account) + } + + /// `0` for an operation that does not exist, `1` for one that has + /// executed, and otherwise the timestamp it becomes ready at. + pub fn get_timestamp(env: Env, id: BytesN<32>) -> u64 { + match load(&env, &id) { + None => 0, + Some(op) if op.done => 1, + Some(op) => op.ready_at, + } + } + + /// True for any operation that exists, pending or done. + pub fn is_operation(env: Env, id: BytesN<32>) -> bool { + env.storage().persistent().has(&DataKey::Operation(id)) + } + + /// True for an operation that exists and has not executed. + pub fn is_operation_pending(env: Env, id: BytesN<32>) -> bool { + load(&env, &id).is_some_and(|op| !op.done) + } + + /// True for an operation whose delay has elapsed. + pub fn is_operation_ready(env: Env, id: BytesN<32>) -> bool { + load(&env, &id).is_some_and(|op| !op.done && env.ledger().timestamp() >= op.ready_at) + } + + /// True for an operation that has executed. + pub fn is_operation_done(env: Env, id: BytesN<32>) -> bool { + load(&env, &id).is_some_and(|op| op.done) + } + + /// The stored operation, if there is one. + pub fn get_operation(env: Env, id: BytesN<32>) -> Option { + load(&env, &id) + } + + /// The id of the operation a batch, a predecessor and a salt describe. + /// + /// The same formula `schedule` and `execute` use, exposed so an + /// integrator can compute an id off-chain or in another contract. + pub fn hash_operation( + env: Env, + targets: Vec
, + fns: Vec, + args: Vec>, + predecessor: Option>, + salt: BytesN<32>, + ) -> BytesN<32> { + let spec = OperationSpec { + targets, + fns, + args, + predecessor, + salt, + }; + env.crypto().sha256(&spec.clone().to_xdr(&env)).into() + } +} diff --git a/timelock/src/migrations.rs b/timelock/src/migrations.rs index d129bf7..1b2f2dc 100644 --- a/timelock/src/migrations.rs +++ b/timelock/src/migrations.rs @@ -4,26 +4,15 @@ //! docs/migrations.md for how to author one. //! //! The timelock administers itself: there is no `migrate` entrypoint. -//! Instead `execute` dispatches a `Call` targeting the timelock with +//! Instead `execute` dispatches a call targeting the timelock with //! function `migrate` and args `(from: u32, to: u32)` to `self_migrate` -//! below, the same way it handles `set_prop` / `set_delay` / `lock_in`. +//! below, the same way it handles `update_delay` / `grant_role` / +//! `revoke_role` / `set_open_executor`. //! A migration is therefore always a delayed, cancellable operation. It //! must be its own operation, after the one that installs the new wasm: //! a wasm swap only takes effect once the invocation that made it //! returns, so a `migrate` self-call in the same `execute` would still run //! the old code. -//! -//! NOTE: this crate's lib.rs, error.rs, events.rs, types.rs and -//! Cargo.toml are empty on main (emptied by #148). Once they're restored: -//! add `zenith-common = { path = "../common" }` to Cargo.toml, -//! `mod migrations;` to lib.rs, the migration error variants (codes -//! 100–104) to `Error`, `init_schema` to `initialize`, and in `self_call`: -//! -//! ```ignore -//! } else if *func == symbol_short!("migrate") { -//! migrations::self_migrate(env, arg(env, args, 0), arg(env, args, 1)); -//! } -//! ``` use soroban_sdk::{contractimpl, panic_with_error, Env}; use zenith_common::migrations::{self, MigrationErrors, Step}; @@ -89,3 +78,4 @@ impl Timelock { migrations::migration_state(&env) } } + diff --git a/timelock/src/test.rs b/timelock/src/test.rs index e69de29..b4cdbb6 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -0,0 +1,671 @@ +#![cfg(test)] + +use crate::{OperationSpec, Role, Timelock, TimelockClient}; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, + vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, +}; + +const DELAY: u64 = 86_400; +const GRACE: u64 = 7 * 86_400; + +/// Stand-in for any admin-gated protocol contract. +#[contract] +struct Target; + +#[contractimpl] +impl Target { + pub fn init(env: Env, admin: Address) { + env.storage().instance().set(&0u32, &admin); + } + pub fn set_value(env: Env, value: u32) { + let admin: Address = env.storage().instance().get(&0u32).unwrap(); + admin.require_auth(); + env.storage().instance().set(&1u32, &value); + } + pub fn value(env: Env) -> u32 { + env.storage().instance().get(&1u32).unwrap_or(0) + } +} + +struct Harness<'a> { + env: Env, + client: TimelockClient<'a>, + target: TargetClient<'a>, + proposer: Address, + executor: Address, + canceller: Address, + /// Holds none of the three roles. + outsider: Address, +} + +fn setup<'a>() -> Harness<'a> { + let env = Env::default(); + env.mock_all_auths(); + let proposer = Address::generate(&env); + let executor = Address::generate(&env); + let canceller = Address::generate(&env); + let outsider = Address::generate(&env); + let client = TimelockClient::new(&env, &env.register_contract(None, Timelock)); + client.initialize( + &DELAY, + &GRACE, + &vec![&env, proposer.clone()], + &vec![&env, executor.clone()], + &vec![&env, canceller.clone()], + ); + let target = TargetClient::new(&env, &env.register_contract(None, Target)); + target.init(&client.address); + Harness { + env, + client, + target, + proposer, + executor, + canceller, + outsider, + } +} + +impl Harness<'_> { + fn salt(&self, n: u8) -> BytesN<32> { + BytesN::from_array(&self.env, &[n; 32]) + } + + fn target_targets(&self) -> Vec
{ + vec![&self.env, self.target.address.clone()] + } + + fn set_value_fns(&self) -> Vec { + vec![&self.env, Symbol::new(&self.env, "set_value")] + } + + fn set_value_args(&self, value: u32) -> Vec> { + vec![&self.env, vec![&self.env, value.into_val(&self.env)]] + } + + /// The arguments of the `execute` call, as the host sees them. + fn execute_args(&self, n: u8, value: u32) -> Vec { + vec![ + &self.env, + self.executor.clone().into_val(&self.env), + self.target_targets().into_val(&self.env), + self.set_value_fns().into_val(&self.env), + self.set_value_args(value).into_val(&self.env), + None::>.into_val(&self.env), + self.salt(n).into_val(&self.env), + ] + } + + fn schedule_set_value(&self, n: u8, value: u32) -> BytesN<32> { + self.client.schedule( + &self.proposer, + &self.target_targets(), + &self.set_value_fns(), + &self.set_value_args(value), + &None, + &self.salt(n), + &DELAY, + ) + } + + /// Schedules a call whose target is the timelock itself. + fn schedule_self(&self, n: u8, func: &str, args: Vec) -> BytesN<32> { + self.client.schedule( + &self.proposer, + &vec![&self.env, self.client.address.clone()], + &vec![&self.env, Symbol::new(&self.env, func)], + &vec![&self.env, args], + &None, + &self.salt(n), + &DELAY, + ) + } + + fn execute_set_value(&self, n: u8, value: u32) { + self.client.execute( + &self.executor, + &self.target_targets(), + &self.set_value_fns(), + &self.set_value_args(value), + &None, + &self.salt(n), + ); + } + + fn warp(&self, dt: u64) { + let t = self.env.ledger().timestamp(); + self.env.ledger().set_timestamp(t + dt); + } +} + +// ── initialization ─────────────────────────────────────────────────────────── + +#[test] +fn initialize_is_one_shot() { + let h = setup(); + let e = &h.env; + assert!(h + .client + .try_initialize( + &DELAY, + &GRACE, + &vec![e, h.proposer.clone()], + &vec![e, h.executor.clone()], + &vec![e, h.canceller.clone()], + ) + .is_err()); +} + +#[test] +fn initialize_refuses_a_delay_below_the_documented_floor() { + let e = Env::default(); + e.mock_all_auths(); + let client = TimelockClient::new(&e, &e.register_contract(None, Timelock)); + let a = Address::generate(&e); + assert!(client + .try_initialize(&1, &GRACE, &vec![&e, a.clone()], &vec![&e], &vec![&e]) + .is_err()); +} + +#[test] +fn an_empty_executor_set_means_anyone_may_execute() { + let h = setup(); + assert!(!h.client.is_open_executor()); + let e = &h.env; + let client = TimelockClient::new(&e, &e.register_contract(None, Timelock)); + client.initialize( + &DELAY, + &GRACE, + &vec![&e, h.proposer.clone()], + &vec![&e], + &vec![&e], + ); + assert!(client.is_open_executor()); +} + +// ── role gating ────────────────────────────────────────────────────────────── + +#[test] +fn only_a_proposer_may_schedule() { + let h = setup(); + assert!(h + .client + .try_schedule( + &h.outsider, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(1), + &None, + &h.salt(1), + &DELAY, + ) + .is_err()); + assert!(h + .client + .try_schedule( + &h.canceller, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(1), + &None, + &h.salt(1), + &DELAY, + ) + .is_err()); +} + +#[test] +fn only_an_executor_may_execute() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + h.warp(DELAY); + assert!(h + .client + .try_execute( + &h.outsider, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ) + .is_err()); + // The operation is still pending: the refused attempt changed nothing. + assert!(h.client.is_operation_pending(&id)); +} + +#[test] +fn only_a_canceller_may_cancel() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + assert!(h.client.try_cancel(&h.outsider, &id).is_err()); + assert!(h.client.try_cancel(&h.proposer, &id).is_err()); + assert!(h.client.is_operation_pending(&id)); + h.client.cancel(&h.canceller, &id); + assert!(!h.client.is_operation(&id)); +} + +// ── the delay ──────────────────────────────────────────────────────────────── + +#[test] +fn a_delay_shorter_than_min_delay_is_refused() { + let h = setup(); + assert!(h + .client + .try_schedule( + &h.proposer, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(1), + &None, + &h.salt(1), + &(DELAY - 1), + ) + .is_err()); +} + +#[test] +fn execution_is_refused_just_before_the_delay_and_allowed_exactly_at_it() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + assert_eq!(h.client.get_timestamp(&id), DELAY); + + h.warp(DELAY - 1); + assert!(!h.client.is_operation_ready(&id)); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ) + .is_err()); + + h.warp(1); + assert!(h.client.is_operation_ready(&id)); + h.execute_set_value(1, 42); + assert_eq!(h.target.value(), 42); +} + +// ── lifecycle ──────────────────────────────────────────────────────────────── + +#[test] +fn an_operation_cannot_execute_twice() { + let h = setup(); + h.schedule_set_value(1, 42); + h.warp(DELAY); + h.execute_set_value(1, 42); + assert!(h.client.is_operation_done(&h.salt(1))); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ) + .is_err()); +} + +#[test] +fn cancelling_before_execution_stops_it() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + h.client.cancel(&h.canceller, &id); + h.warp(DELAY); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ) + .is_err()); + assert_eq!(h.target.value(), 0); +} + +#[test] +fn executing_after_a_cancel_is_refused() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + h.client.cancel(&h.canceller, &id); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ) + .is_err()); +} + +#[test] +fn cancelling_an_already_executed_operation_is_refused() { + let h = setup(); + h.schedule_set_value(1, 42); + h.warp(DELAY); + h.execute_set_value(1, 42); + assert!(h.client.try_cancel(&h.canceller, &h.salt(1)).is_err()); +} + +#[test] +fn cancelling_an_operation_that_never_existed_is_refused() { + let h = setup(); + assert!(h.client.try_cancel(&h.canceller, &h.salt(9)).is_err()); +} + +#[test] +fn scheduling_the_same_batch_twice_is_refused() { + let h = setup(); + h.schedule_set_value(1, 42); + assert!(h + .client + .try_schedule( + &h.proposer, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + &DELAY, + ) + .is_err()); +} + +#[test] +fn an_empty_batch_is_refused() { + let h = setup(); + let e = &h.env; + assert!(h + .client + .try_schedule( + &h.proposer, + &vec![e], + &vec![e], + &vec![e], + &None, + &h.salt(1), + &DELAY, + ) + .is_err()); +} + +// ── predecessors ───────────────────────────────────────────────────────────── + +#[test] +fn a_dependant_waits_for_its_predecessor() { + let h = setup(); + let first = h.schedule_set_value(1, 11); + let second = h.client.schedule( + &h.proposer, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(22), + &Some(first.clone()), + &h.salt(2), + &DELAY, + ); + h.warp(DELAY); + + // The dependant is ready in time, but its predecessor has not run. + assert!(h.client.is_operation_ready(&second)); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(22), + &Some(first.clone()), + &h.salt(2), + ) + .is_err()); + assert_eq!(h.target.value(), 0); + + h.execute_set_value(1, 11); + h.client.execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(22), + &Some(first.clone()), + &h.salt(2), + ); + assert_eq!(h.target.value(), 22); +} + +#[test] +fn cancelling_a_predecessor_strands_its_dependant() { + let h = setup(); + let first = h.schedule_set_value(1, 11); + h.client.schedule( + &h.proposer, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(22), + &Some(first.clone()), + &h.salt(2), + &DELAY, + ); + h.client.cancel(&h.canceller, &first); + h.warp(DELAY); + assert!(h + .client + .try_execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(22), + &Some(first.clone()), + &h.salt(2), + ) + .is_err()); + assert_eq!(h.target.value(), 0); +} + +// ── self-administration ────────────────────────────────────────────────────── + +#[test] +fn self_calls_reconfigure_the_timelock() { + let h = setup(); + let new_proposer = Address::generate(&h.env); + let e = &h.env; + + let delay_args = vec![&e, (2 * DELAY).into_val(&e)]; + let grant_args = vec![ + &e, + Role::Proposer.into_val(&e), + new_proposer.clone().into_val(&e), + ]; + let revoke_args = vec![ + &e, + Role::Proposer.into_val(&e), + h.proposer.clone().into_val(&e), + ]; + let open_args = vec![&e, true.into_val(&e)]; + + let delay_op = h.schedule_self(1, "update_delay", delay_args.clone()); + let grant_op = h.schedule_self(2, "grant_role", grant_args.clone()); + let revoke_op = h.schedule_self(3, "revoke_role", revoke_args.clone()); + let open_op = h.schedule_self(4, "set_open_executor", open_args.clone()); + + h.warp(DELAY); + h.client.execute( + &h.executor, + &vec![&e, h.client.address.clone()], + &vec![&e, Symbol::new(&e, "update_delay")], + &vec![&e, delay_args.clone()], + &None, + &h.salt(1), + ); + assert_eq!(h.client.get_min_delay(), 2 * DELAY); + + h.client.execute( + &h.executor, + &vec![&e, h.client.address.clone()], + &vec![&e, Symbol::new(&e, "grant_role")], + &vec![&e, grant_args.clone()], + &None, + &h.salt(2), + ); + assert!(h.client.has_role(&Role::Proposer, &new_proposer)); + + h.client.execute( + &h.executor, + &vec![&e, h.client.address.clone()], + &vec![&e, Symbol::new(&e, "revoke_role")], + &vec![&e, revoke_args.clone()], + &None, + &h.salt(3), + ); + assert!(!h.client.has_role(&Role::Proposer, &h.proposer)); + + h.client.execute( + &h.executor, + &vec![&e, h.client.address.clone()], + &vec![&e, Symbol::new(&e, "set_open_executor")], + &vec![&e, open_args.clone()], + &None, + &h.salt(4), + ); + assert!(h.client.is_open_executor()); + + // Every one of those operations ran. + assert!(h.client.is_operation_done(&delay_op)); + assert!(h.client.is_operation_done(&grant_op)); + assert!(h.client.is_operation_done(&revoke_op)); + assert!(h.client.is_operation_done(&open_op)); +} + +#[test] +fn a_self_call_the_timelock_does_not_implement_is_refused() { + let h = setup(); + let e = &h.env; + h.schedule_self(1, "not_a_function", vec![&e]); + h.warp(DELAY); + assert!(h + .client + .try_execute( + &h.executor, + &vec![&e, h.client.address.clone()], + &vec![&e, Symbol::new(&e, "not_a_function")], + &vec![&e, vec![&e]], + &None, + &h.salt(1), + ) + .is_err()); +} + +// ── the id ─────────────────────────────────────────────────────────────────── + +#[test] +fn the_operation_id_commits_to_the_batch_the_predecessor_and_the_salt() { + let h = setup(); + let id = h.client.hash_operation( + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ); + assert_eq!(id, h.schedule_set_value(1, 42)); + + // A different payload or a different salt gives a different id. + let other = h.client.hash_operation( + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(43), + &None, + &h.salt(1), + ); + assert_ne!(id, other); + let salted = h.client.hash_operation( + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(2), + ); + assert_ne!(id, salted); + let preceded = h.client.hash_operation( + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &Some(h.salt(3)), + &h.salt(1), + ); + assert_ne!(id, preceded); +} + +#[test] +fn the_stored_operation_is_the_spec_it_was_scheduled_with() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + let op = h.client.get_operation(&id).unwrap(); + assert_eq!(op.targets.len(), 1); + assert_eq!(op.targets.get(0), Some(h.target.address.clone())); + assert_eq!(op.fns.len(), 1); + assert_eq!(op.fns.get(0), Some(Symbol::new(&h.env, "set_value"))); + assert_eq!(op.args.len(), 1); + assert_eq!(op.salt, h.salt(1)); + assert!(!op.done); + assert_eq!(op.ready_at, DELAY); + // The spec the id was derived from is the same one that was stored. + let spec = OperationSpec { + targets: op.targets.clone(), + fns: op.fns.clone(), + args: op.args.clone(), + predecessor: op.predecessor.clone(), + salt: op.salt.clone(), + }; + assert_eq!(h.client.hash_operation( + &spec.targets, + &spec.fns, + &spec.args, + &spec.predecessor, + &spec.salt, + ), id); +} + +// ── the timelock as the admin of another contract ──────────────────────────── + +#[test] +fn the_timelock_is_the_admin_of_the_contract_it_acts_on() { + let h = setup(); + h.schedule_set_value(1, 42); + h.warp(DELAY); + // Only the executor's own authorization is supplied. The target's admin + // is the timelock, and the timelock is its direct invoker, so the + // target's own admin check passes without a second signature. + h.env.mock_auths(&[MockAuth { + address: &h.executor, + invoke: &MockAuthInvoke { + contract: &h.client.address, + fn_name: "execute", + args: h.execute_args(1, 42), + sub_invokes: &[], + }, + }]); + h.client.execute( + &h.executor, + &h.target_targets(), + &h.set_value_fns(), + &h.set_value_args(42), + &None, + &h.salt(1), + ); + assert_eq!(h.target.value(), 42); +} diff --git a/timelock/src/types.rs b/timelock/src/types.rs index e69de29..455d69a 100644 --- a/timelock/src/types.rs +++ b/timelock/src/types.rs @@ -0,0 +1,78 @@ +use soroban_sdk::{contracttype, Address, BytesN, Symbol, Val, Vec}; + +/// What a participant in the timelock is allowed to do. +/// +/// Three roles, and no admin: the timelock has no owner, so every change to +/// it — including these role sets — is itself a delayed operation. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Role { + /// May schedule operations. + Proposer = 0, + /// May execute a ready operation. + Executor = 1, + /// May cancel a pending operation. + Canceller = 2, +} + +/// A scheduled operation, stored under its derived id. +/// +/// The three parallel vectors describe the batch: `targets[i]` is the contract +/// call `i` goes to, `fns[i]` the function it calls, and `args[i]` the +/// arguments it is called with. They are always the same length. +#[contracttype] +#[derive(Clone)] +pub struct Operation { + pub targets: Vec
, + pub fns: Vec, + pub args: Vec>, + /// The operation that must have executed before this one may execute. + pub predecessor: Option>, + /// The nonce that makes this operation's id unique. + pub salt: BytesN<32>, + /// Ledger timestamp from which the operation may be executed. + pub ready_at: u64, + /// Set once the operation has executed; the record is kept so a caller + /// can still tell "done" from "never existed". + pub done: bool, +} + +/// The preimage an operation id is derived from. +/// +/// The id commits to exactly what will run: the calls, the predecessor they +/// wait on, and the salt that makes two otherwise identical batches +/// distinct. The parts of an operation that change over its life — when it +/// became ready, whether it has run — are deliberately not part of it, so the +/// id can be computed before the operation is scheduled. +#[contracttype] +#[derive(Clone)] +pub struct OperationSpec { + pub targets: Vec
, + pub fns: Vec, + pub args: Vec>, + pub predecessor: Option>, + pub salt: BytesN<32>, +} + +/// Storage keys. +/// +/// The role sets are one key per (role, address) pair, so a membership check +/// is a single storage read and a revocation is a single removal. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Set once `initialize` has run. The timelock has no admin afterwards. + Initialized, + /// The shortest delay any operation may be scheduled with, in seconds. + MinDelay, + /// How long a ready operation stays executable, in seconds. + GracePeriod, + /// True when anyone may execute a ready operation. + OpenExecutor, + Proposer(Address), + Executor(Address), + Canceller(Address), + /// A scheduled operation, keyed by its derived id. + Operation(BytesN<32>), +} From 1a056a9b32c8f765cb7534a9c05e784cc97a1771 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Mon, 19 Oct 2026 18:17:22 +0100 Subject: [PATCH 02/17] docs: put the governance docs under docs/ --- docs/governance-handover.md | 28 ++++++---- docs/governance.md | 20 ++++--- docs_governance-handover.md | 104 ------------------------------------ docs_governance.md | 95 -------------------------------- 4 files changed, 32 insertions(+), 215 deletions(-) delete mode 100644 docs_governance-handover.md delete mode 100644 docs_governance.md diff --git a/docs/governance-handover.md b/docs/governance-handover.md index 5e3cb55..92d3b1c 100644 --- a/docs/governance-handover.md +++ b/docs/governance-handover.md @@ -12,25 +12,31 @@ and runs each stage and each rollback. Scripts are in `scripts/governance/`. - **Deployer**: the EOA that initialized the contracts. - **Multisig account**: a native Stellar account with M-of-N signers (the same signer set as the on-chain `multisig` contract). It holds admin in - stage 1 and is the timelock **proposer** and **guardian** from stage 2 on. + stage 1 and is the timelock **proposer** and **canceller** from stage 2 on. A native account is used because the `multisig` contract only counts approvals and never makes calls, so it can't sign `admin.require_auth()`. - **Multisig contract**: backs every `*_via_multisig` recovery path. It is not moved by the handover. -- **Timelock**: `proposer`, `guardian` (removed at lock-in), `min_delay`, - `grace_period`. +- **Timelock**: `proposer`, `executor` and `canceller` role sets, `min_delay` + and `grace_period`. The multisig's two roles are revoked at lock-in. - **Governor**: ZEN-holder voting. See [governance.md](governance.md). ## Stages -| Stage | options_market / price_oracle admin | Timelock proposer | Guardian | Who can roll back | How | -|------:|-------------------------------------|-------------------|----------|-------------------|-----| +| Stage | options_market / price_oracle admin | Timelock proposer | Timelock canceller | Who can roll back | How | +|------:|-------------------------------------|-------------------|--------------------|-------------------|-----| | 0 | Deployer | — | — | — | — | | 1 | Multisig account | — | — | Multisig | `transfer_admin(deployer)`: `stage1_rollback.sh` | | 2 | Timelock | Multisig account | Multisig account | Multisig | Schedules `transfer_admin(multisig)` through the timelock: `stage2_rollback.sh`. It can also cancel any pending op. | -| 3 | Timelock | Governor | Multisig account | Guardian (multisig) | `cancel` + `guardian_set_proposer(multisig)`, which takes effect immediately: `stage3_rollback.sh` | +| 3 | Timelock | Multisig account **and** the governor | Multisig account | Multisig | Cancels the governor's queued op and schedules the corrective one: `stage3_rollback.sh`. | | 4 | Timelock | Governor | **none** | Governor only | Ordinary proposals. No emergency path. | +Stage 3 *adds* the governor as a proposer rather than replacing the multisig: +the multisig keeps its proposer role until lock-in, which is what keeps the +stage reversible. A role can only be granted or revoked by a delayed +operation, so there is no instant path back to the multisig once the +governor holds the role alone. + **Vault:** its admin is `options_market` at every stage and must stay that way, because `options_market` moves escrow through it. The vault's only governance concern is its multisig recovery path @@ -44,8 +50,8 @@ still works after lock-in. |------------|--------|-----------| | 0 → 1 | `stage1_admin_to_multisig.sh` | The deployer calls `transfer_admin(multisig_account)` on options_market and price_oracle. | | 1 → 2 | `stage2_admin_to_timelock.sh` | The multisig calls `transfer_admin(timelock)`. First confirm that the timelock's proposer and guardian are the multisig account. | -| 2 → 3 | `stage3_proposer_to_governor.sh` | The multisig schedules a timelock self-call `set_prop(governor)`, then anyone executes it after `min_delay`. | -| 3 → 4 | `stage4_lock_in.sh` | A governor proposal calls the timelock's `lock_in()`. | +| 2 → 3 | `stage3_proposer_to_governor.sh` | The multisig schedules a timelock self-call `grant_role(Proposer, governor)`, then anyone executes it after `min_delay`. | +| 3 → 4 | `stage4_lock_in.sh` | A governor proposal calls the timelock's `revoke_role(Proposer, multisig)` and `revoke_role(Canceller, multisig)`. | ### Lock-in conditions @@ -57,9 +63,10 @@ Run stage 4 only when all of the following are true: 2. ZEN voting power is distributed widely enough that quorum can't be met by a single holder. 3. No queued timelock operation is pending review. -4. The multisig signers have signed off on removing their own guardian role. +4. The multisig signers have signed off on removing their own timelock roles. -After lock-in, `guardian_set_proposer` and guardian `cancel` fail for good. +After lock-in the multisig holds neither role, so it can neither cancel a +queued operation nor schedule one, for good. ## Running the scripts @@ -94,3 +101,4 @@ accept any `multisig_contract` address from the caller. They don't pin one at initialize, so they don't restrict who can act. This was already the case before this handover and is out of scope here. It should be fixed, by pinning the multisig at initialize, before mainnet. + diff --git a/docs/governance.md b/docs/governance.md index f6b1176..f5a9db9 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -11,7 +11,7 @@ path from today's single admin to this end state is in | Contract | Role | |------------|------| | `governor` | Proposals, voting, quorum. Queues passed proposals into the timelock. | -| `timelock` | Admin of every protocol contract. Delays each call by `min_delay`; anyone may execute a ready operation. | +| `timelock` | Admin of every protocol contract. Delays each call by `min_delay`; anyone may execute a ready operation. Holds a proposer, an executor and a canceller role, and no admin of its own. | | ZEN token | Checkpointed voting token exposing `get_past_votes(account, ledger)` and `get_past_total_supply(ledger)`. The governor calls it through the `Votes` interface in `governor/src/interfaces.rs`. | ## Proposal lifecycle @@ -41,8 +41,12 @@ propose ──► Pending ──(voting_delay)──► Active ──(voting_per `for > against`. A tie is a defeat. Quorum is `quorum_bps × past_total_supply(snapshot) / 10 000`. 5. **Queue** — anyone calls `queue(id)`; the governor schedules the batch in - the timelock under the same id with the timelock's `min_delay`. -6. **Execute** — after the delay, anyone calls `timelock.execute(id)`. The + the timelock under the proposal's own id as the salt, with the timelock's + `min_delay`. The timelock derives the operation id from the batch, the + predecessor and the salt, so the governor derives the same id back whenever + it needs to ask about the operation. +6. **Execute** — after the delay, anyone calls + `timelock.execute(executor, targets, fns, args, predecessor, salt)`. The governor reports **Executed** once the timelock marks it done, or **Expired** if `grace_period` passes first. @@ -52,7 +56,7 @@ propose ──► Pending ──(voting_delay)──► Active ──(voting_per - **Anyone** may cancel a Pending/Active/Succeeded/Queued proposal if the proposer's current voting power has dropped below `proposal_threshold`. Cancelling a queued proposal also removes it from the timelock. -- Until lock-in, the timelock **guardian** can cancel any queued operation. +- The timelock's **canceller** can cancel any queued operation. ## Parameters @@ -67,7 +71,10 @@ arrives from the timelock). Ledgers assume ~5 s. | `proposal_threshold` | `set_proposal_threshold` | ≥ 0 | Timelock settings are changed by a proposal whose target is the timelock -itself with `set_delay(u64)`, `set_prop(Address)` or `lock_in()`. +itself: `update_delay(u64)`, `grant_role(Role, Address)`, +`revoke_role(Role, Address)` or `set_open_executor(bool)`. There is no instant +path — every change to the timelock waits out the delay like any other +operation. ## Attack resistance @@ -77,7 +84,7 @@ itself with `set_delay(u64)`, `set_prop(Address)` or `lock_in()`. | Double voting | One vote per address per proposal (`has_voted`). Moving tokens after the snapshot does not create new power. | | Proposal spam | `proposal_threshold`, and anyone can cancel a proposal whose proposer has sold below it. | | Quorum manipulation | Quorum uses total supply at the snapshot, not at vote time. | -| Malicious proposal passes | `min_delay` gives users time to exit; before lock-in the guardian can cancel. | +| Malicious proposal passes | `min_delay` gives users time to exit; before lock-in a canceller can cancel it. | ## Why the timelock executes, not the governor @@ -85,3 +92,4 @@ Soroban forbids a contract from being re-entered. If the governor called the timelock and the timelock called back into the governor (for example, to change `quorum_bps`), the call would fail. The timelock's `execute` is therefore open to anyone, and the governor never sits in the execution call stack. + diff --git a/docs_governance-handover.md b/docs_governance-handover.md deleted file mode 100644 index 92d3b1c..0000000 --- a/docs_governance-handover.md +++ /dev/null @@ -1,104 +0,0 @@ -# Governance Handover Specification - -How admin control of Zenith moves from a single deployer key, through a -multisig, to the timelock and governor. Every stage can be reversed by a -higher authority until the final lock-in. - -Covered by `integration_tests/tests/handover.rs`, which deploys every contract -and runs each stage and each rollback. Scripts are in `scripts/governance/`. - -## Actors - -- **Deployer**: the EOA that initialized the contracts. -- **Multisig account**: a native Stellar account with M-of-N signers (the - same signer set as the on-chain `multisig` contract). It holds admin in - stage 1 and is the timelock **proposer** and **canceller** from stage 2 on. - A native account is used because the `multisig` contract only counts - approvals and never makes calls, so it can't sign `admin.require_auth()`. -- **Multisig contract**: backs every `*_via_multisig` recovery path. It is - not moved by the handover. -- **Timelock**: `proposer`, `executor` and `canceller` role sets, `min_delay` - and `grace_period`. The multisig's two roles are revoked at lock-in. -- **Governor**: ZEN-holder voting. See [governance.md](governance.md). - -## Stages - -| Stage | options_market / price_oracle admin | Timelock proposer | Timelock canceller | Who can roll back | How | -|------:|-------------------------------------|-------------------|--------------------|-------------------|-----| -| 0 | Deployer | — | — | — | — | -| 1 | Multisig account | — | — | Multisig | `transfer_admin(deployer)`: `stage1_rollback.sh` | -| 2 | Timelock | Multisig account | Multisig account | Multisig | Schedules `transfer_admin(multisig)` through the timelock: `stage2_rollback.sh`. It can also cancel any pending op. | -| 3 | Timelock | Multisig account **and** the governor | Multisig account | Multisig | Cancels the governor's queued op and schedules the corrective one: `stage3_rollback.sh`. | -| 4 | Timelock | Governor | **none** | Governor only | Ordinary proposals. No emergency path. | - -Stage 3 *adds* the governor as a proposer rather than replacing the multisig: -the multisig keeps its proposer role until lock-in, which is what keeps the -stage reversible. A role can only be granted or revoked by a delayed -operation, so there is no instant path back to the multisig once the -governor holds the role alone. - -**Vault:** its admin is `options_market` at every stage and must stay that -way, because `options_market` moves escrow through it. The vault's only -governance concern is its multisig recovery path -(`transfer_admin_via_multisig`), which takes the multisig contract per call. -There is no on-chain state to migrate. The integration test checks the path -still works after lock-in. - -## Transitions - -| Transition | Script | Mechanism | -|------------|--------|-----------| -| 0 → 1 | `stage1_admin_to_multisig.sh` | The deployer calls `transfer_admin(multisig_account)` on options_market and price_oracle. | -| 1 → 2 | `stage2_admin_to_timelock.sh` | The multisig calls `transfer_admin(timelock)`. First confirm that the timelock's proposer and guardian are the multisig account. | -| 2 → 3 | `stage3_proposer_to_governor.sh` | The multisig schedules a timelock self-call `grant_role(Proposer, governor)`, then anyone executes it after `min_delay`. | -| 3 → 4 | `stage4_lock_in.sh` | A governor proposal calls the timelock's `revoke_role(Proposer, multisig)` and `revoke_role(Canceller, multisig)`. | - -### Lock-in conditions - -Run stage 4 only when all of the following are true: - -1. The governor has run at least one real parameter-change proposal end to - end in stage 3 (the integration test does this with `set_fee_rate` and - `set_max_staleness`). -2. ZEN voting power is distributed widely enough that quorum can't be met by - a single holder. -3. No queued timelock operation is pending review. -4. The multisig signers have signed off on removing their own timelock roles. - -After lock-in the multisig holds neither role, so it can neither cancel a -queued operation nor schedule one, for good. - -## Running the scripts - -Each script prints commands without sending them unless `DRY_RUN=0` is set. -Calls whose source is the multisig account run with `--build-only`. The -unsigned XDR is then signed by M signers (`stellar tx sign`) and submitted -(`stellar tx send`). - -```bash -export NETWORK=testnet MARKET_ID=C... ORACLE_ID=C... VAULT_ID=C... \ - TIMELOCK_ID=C... GOVERNOR_ID=C... DEPLOYER=deployer \ - MULTISIG_ACCOUNT=G... TL_DELAY=172800 - -./scripts/governance/stage1_admin_to_multisig.sh -./scripts/governance/stage2_admin_to_timelock.sh -PHASE=schedule ./scripts/governance/stage3_proposer_to_governor.sh -PHASE=execute ./scripts/governance/stage3_proposer_to_governor.sh # after TL_DELAY -PHASE=propose PROPOSER=alice ./scripts/governance/stage4_lock_in.sh -``` - -Example dry-run output (stage 1 → 2): - -``` -[dry-run] stellar contract invoke --network testnet --source GMULTISIG --build-only --id CMARKET -- transfer_admin --new_admin CTIMELOCK -[dry-run] stellar contract invoke --network testnet --source GMULTISIG --build-only --id CORACLE -- transfer_admin --new_admin CTIMELOCK -``` - -## Known caveat - -The `*_via_multisig` entrypoints on options_market, price_oracle and vault -accept any `multisig_contract` address from the caller. They don't pin one -at initialize, so they don't restrict who can act. This was already the case -before this handover and is out of scope here. It should be fixed, by pinning -the multisig at initialize, before mainnet. - diff --git a/docs_governance.md b/docs_governance.md deleted file mode 100644 index f5a9db9..0000000 --- a/docs_governance.md +++ /dev/null @@ -1,95 +0,0 @@ -# Governance Handbook - -Zenith's admin powers (fees, listings, oracle config, pausing, upgrades) are -ultimately controlled by ZEN holders through the **governor** and executed by -the **timelock**. This handbook covers how that works and how to use it. The -path from today's single admin to this end state is in -[governance-handover.md](governance-handover.md). - -## Contracts - -| Contract | Role | -|------------|------| -| `governor` | Proposals, voting, quorum. Queues passed proposals into the timelock. | -| `timelock` | Admin of every protocol contract. Delays each call by `min_delay`; anyone may execute a ready operation. Holds a proposer, an executor and a canceller role, and no admin of its own. | -| ZEN token | Checkpointed voting token exposing `get_past_votes(account, ledger)` and `get_past_total_supply(ledger)`. The governor calls it through the `Votes` interface in `governor/src/interfaces.rs`. | - -## Proposal lifecycle - -``` -propose ──► Pending ──(voting_delay)──► Active ──(voting_period)──► Defeated - │ │ Succeeded ──queue──► Queued ──timelock.execute──► Executed - └──────── cancel ──────────┴────────────────────────────┴───────────────────┘ └─(grace elapsed)─► Expired - ▼ - Canceled -``` - -1. **Propose** — `propose(proposer, targets, fns, args, description_hash)`. - The proposer needs at least `proposal_threshold` votes as of the previous - ledger. The id is `sha256(xdr(targets, fns, args, description_hash))` - (`hash_proposal` computes it off-chain too); an identical proposal cannot - be submitted twice. At most `MAX_ACTIONS` (10) calls per proposal. -2. **Pending** until the snapshot ledger `propose_ledger + voting_delay`. -3. **Active** for `voting_period` ledgers after the snapshot. Vote with - `cast_vote(voter, id, support)` where `support` is `0` Against, `1` For, - `2` Abstain. Weight is the voter's power **at the snapshot ledger**. - - `cast_vote_by_sig(voter, id, support)` is the gasless variant: the voter - signs a Soroban authorization entry for exactly `(id, support)` and any - relayer submits it. The host checks the signature and a nonce, so it - can't be replayed. -4. **Succeeded / Defeated** — succeeded iff `for + abstain ≥ quorum` **and** - `for > against`. A tie is a defeat. Quorum is - `quorum_bps × past_total_supply(snapshot) / 10 000`. -5. **Queue** — anyone calls `queue(id)`; the governor schedules the batch in - the timelock under the proposal's own id as the salt, with the timelock's - `min_delay`. The timelock derives the operation id from the batch, the - predecessor and the salt, so the governor derives the same id back whenever - it needs to ask about the operation. -6. **Execute** — after the delay, anyone calls - `timelock.execute(executor, targets, fns, args, predecessor, salt)`. The - governor reports **Executed** once the timelock marks it done, or - **Expired** if `grace_period` passes first. - -### Cancelling - -- The proposer may cancel while the proposal is **Pending**. -- **Anyone** may cancel a Pending/Active/Succeeded/Queued proposal if the - proposer's current voting power has dropped below `proposal_threshold`. - Cancelling a queued proposal also removes it from the timelock. -- The timelock's **canceller** can cancel any queued operation. - -## Parameters - -Changed only by a passed proposal targeting the governor itself (the call -arrives from the timelock). Ledgers assume ~5 s. - -| Parameter | Setter | Bounds | -|----------------------|---------------------------|--------| -| `voting_delay` | `set_voting_delay` | 0 – 241 920 ledgers (~2 weeks) | -| `voting_period` | `set_voting_period` | 720 (~1 h) – 241 920 ledgers | -| `quorum_bps` | `set_quorum_bps` | 100 (1%) – 10 000 (100%) | -| `proposal_threshold` | `set_proposal_threshold` | ≥ 0 | - -Timelock settings are changed by a proposal whose target is the timelock -itself: `update_delay(u64)`, `grant_role(Role, Address)`, -`revoke_role(Role, Address)` or `set_open_executor(bool)`. There is no instant -path — every change to the timelock waits out the delay like any other -operation. - -## Attack resistance - -| Attack | Mitigation | -|--------|------------| -| Flash-loan / last-minute votes | Power is read at the snapshot ledger, which is already in the past when voting opens. | -| Double voting | One vote per address per proposal (`has_voted`). Moving tokens after the snapshot does not create new power. | -| Proposal spam | `proposal_threshold`, and anyone can cancel a proposal whose proposer has sold below it. | -| Quorum manipulation | Quorum uses total supply at the snapshot, not at vote time. | -| Malicious proposal passes | `min_delay` gives users time to exit; before lock-in a canceller can cancel it. | - -## Why the timelock executes, not the governor - -Soroban forbids a contract from being re-entered. If the governor called the -timelock and the timelock called back into the governor (for example, to change -`quorum_bps`), the call would fail. The timelock's `execute` is therefore -open to anyone, and the governor never sits in the execution call stack. - From 77af25998f67e3966d269efab4d47e0bbdadf93b Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Mon, 19 Oct 2026 18:20:29 +0100 Subject: [PATCH 03/17] feat(timelock): reconstruct the crate - three roles, predecessors, derived ids See the PR description for the full change list. From 5b852a624d56f6de824ca985ce38ac22171ab565 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Mon, 19 Oct 2026 20:40:14 +0100 Subject: [PATCH 04/17] feat(timelock): reconstruct the crate - three roles, predecessors, derived 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. --- common/src/migrations.rs | 19 ++- common/src/upgrade.rs | 7 +- governor/src/lib.rs | 1 - governor/src/test.rs | 1 - integration_tests/tests/handover.rs | 41 +++-- options_market/Cargo.toml | 2 +- options_market/src/multisig_client.rs | 2 +- price_oracle/src/lib.rs | 1 + test_adversaries/src/lib.rs | 1 + test_adversaries/src/multisig.rs | 126 ++++++++++---- test_adversaries/src/oracle.rs | 133 +++++++++++---- test_adversaries/src/tokens.rs | 227 ++++++++++++++++++++------ test_adversaries/src/vault.rs | 109 ++++++++++--- timelock/src/lib.rs | 25 ++- timelock/src/migrations.rs | 1 - timelock/src/test.rs | 17 +- vault/src/lib.rs | 1 + vault/src/test_adversaries.rs | 37 +---- vault/src/token_validation.rs | 5 +- 19 files changed, 542 insertions(+), 214 deletions(-) diff --git a/common/src/migrations.rs b/common/src/migrations.rs index f8b60c2..12332d1 100644 --- a/common/src/migrations.rs +++ b/common/src/migrations.rs @@ -158,7 +158,13 @@ where /// Continues an in-progress migration from `cursor`, processing up to /// `limit` items of the current step. `cursor` must be exactly where the /// previous call stopped. Returns the schema version reached. -pub fn migrate_batch(env: &Env, cursor: u32, limit: u32, step: StepFn, errs: &MigrationErrors) -> u32 +pub fn migrate_batch( + env: &Env, + cursor: u32, + limit: u32, + step: StepFn, + errs: &MigrationErrors, +) -> u32 where E: Copy + Into, { @@ -166,7 +172,15 @@ where if cursor != state.cursor || limit == 0 || limit > MAX_MIGRATION_BATCH { panic_with_error!(env, errs.invalid_batch); } - run(env, schema_version(env), state.target, cursor, limit, step, errs) + run( + env, + schema_version(env), + state.target, + cursor, + limit, + step, + errs, + ) } fn run( @@ -217,3 +231,4 @@ where fn load_state(env: &Env) -> Option { env.storage().instance().get(&MigrationKey::MigrationState) } + diff --git a/common/src/upgrade.rs b/common/src/upgrade.rs index bedebb8..d24aa44 100644 --- a/common/src/upgrade.rs +++ b/common/src/upgrade.rs @@ -40,8 +40,10 @@ pub fn pin_multisig(env: &Env, multisig: &Address) { env.storage() .instance() .set(&UpgradeKey::PinnedMultisig, multisig); - env.events() - .publish((Symbol::new(env, "upgrade_multisig_pinned"),), multisig.clone()); + env.events().publish( + (Symbol::new(env, "upgrade_multisig_pinned"),), + multisig.clone(), + ); } pub fn pinned_multisig(env: &Env) -> Option
{ @@ -91,3 +93,4 @@ pub fn upgrade( env.events() .publish((Symbol::new(env, "upgraded"),), new_wasm_hash); } + diff --git a/governor/src/lib.rs b/governor/src/lib.rs index 5013639..3171bc5 100644 --- a/governor/src/lib.rs +++ b/governor/src/lib.rs @@ -468,4 +468,3 @@ impl Governor { events::param_set(env, "proposal_threshold", value); } } - diff --git a/governor/src/test.rs b/governor/src/test.rs index 6ed234e..5258e43 100644 --- a/governor/src/test.rs +++ b/governor/src/test.rs @@ -369,4 +369,3 @@ fn params_only_via_governance_and_within_bounds() { h.gov.set_voting_period(&1_000); assert_eq!(h.gov.voting_period(), 1_000); } - diff --git a/integration_tests/tests/handover.rs b/integration_tests/tests/handover.rs index df69294..e650934 100644 --- a/integration_tests/tests/handover.rs +++ b/integration_tests/tests/handover.rs @@ -303,15 +303,8 @@ impl World<'_> { "schedule", self.schedule_args(&(t.clone(), f.clone(), a.clone()), salt), ); - self.tl.schedule( - &self.ms_account, - &t, - &f, - &a, - &None, - salt, - &TL_DELAY, - ) + self.tl + .schedule(&self.ms_account, &t, &f, &a, &None, salt, &TL_DELAY) } fn execute_after_delay(&self, calls: &Calls, salt: &BytesN<32>) { @@ -366,7 +359,14 @@ impl World<'_> { let calls: Calls = ( vec![e, self.tl.address.clone()], vec![e, Symbol::new(e, "grant_role")], - vec![e, vec![e, Role::Proposer.into_val(e), self.gov.address.clone().into_val(e)]], + vec![ + e, + vec![ + e, + Role::Proposer.into_val(e), + self.gov.address.clone().into_val(e), + ], + ], ); self.ms_schedule(calls.clone(), &salt); self.execute_after_delay(&calls, &salt); @@ -380,10 +380,23 @@ impl World<'_> { let e = &self.env; self.govern(( vec![e, self.tl.address.clone(), self.tl.address.clone()], - vec![e, Symbol::new(e, "revoke_role"), Symbol::new(e, "revoke_role")], - vec![e, - vec![e, Role::Proposer.into_val(e), self.ms_account.clone().into_val(e)], - vec![e, Role::Canceller.into_val(e), self.ms_account.clone().into_val(e)], + vec![ + e, + Symbol::new(e, "revoke_role"), + Symbol::new(e, "revoke_role"), + ], + vec![ + e, + vec![ + e, + Role::Proposer.into_val(e), + self.ms_account.clone().into_val(e), + ], + vec![ + e, + Role::Canceller.into_val(e), + self.ms_account.clone().into_val(e), + ], ], )); } diff --git a/options_market/Cargo.toml b/options_market/Cargo.toml index a603bd9..e0eb881 100644 --- a/options_market/Cargo.toml +++ b/options_market/Cargo.toml @@ -62,4 +62,4 @@ resource-wasm = [] # in dependency order, into the shared workspace target/ directory; # `cargo xtask check-imports` fails if a contractimport! path isn't listed. [package.metadata.zenith] -wasm-deps = ["zenith-price-oracle", "zenith-vault", "zenith-params"] +wasm-deps = ["zenith-price-oracle", "zenith-vault", "zenith-params", "zenith-multisig"] diff --git a/options_market/src/multisig_client.rs b/options_market/src/multisig_client.rs index 9854c84..a3397dc 100644 --- a/options_market/src/multisig_client.rs +++ b/options_market/src/multisig_client.rs @@ -9,5 +9,5 @@ //! crate can compile at all — see the repo README for the build order //! this implies. soroban_sdk::contractimport!( - file = "../multisig/target/wasm32-unknown-unknown/release/zenith_multisig.wasm" + file = "../target/wasm32-unknown-unknown/release/zenith_multisig.wasm" ); diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index e69de29..8b13789 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -0,0 +1 @@ + diff --git a/test_adversaries/src/lib.rs b/test_adversaries/src/lib.rs index 0271c09..811fb3a 100644 --- a/test_adversaries/src/lib.rs +++ b/test_adversaries/src/lib.rs @@ -24,3 +24,4 @@ pub mod multisig; pub mod oracle; pub mod tokens; pub mod vault; + diff --git a/test_adversaries/src/multisig.rs b/test_adversaries/src/multisig.rs index a31dfa2..9c62b3e 100644 --- a/test_adversaries/src/multisig.rs +++ b/test_adversaries/src/multisig.rs @@ -14,9 +14,7 @@ //! | [`ThresholdZeroMultisig`] | Initialized with threshold 0; models misconfigurations that should be rejected at `initialize`. | //! | [`SingleSignerMultisig`] | Only one signer and threshold 1; the weakest legitimate multisig. | -use soroban_sdk::{ - contract, contractimpl, contracttype, Address, BytesN, Env, Vec, -}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Vec}; // ─── ActionClass mirrors zenith-common's enum (same u32 repr) ────────────── @@ -50,7 +48,9 @@ pub struct AlwaysApproveMultisig; #[contractimpl] impl AlwaysApproveMultisig { pub fn initialize(env: Env, signers: Vec
, _threshold: u32, _approval_ttl: u64) { - env.storage().instance().set(&symbol_multisig("INIT"), &signers); + env.storage() + .instance() + .set(&symbol_multisig("INIT"), &signers); } /// Always returns `true`. @@ -58,15 +58,29 @@ impl AlwaysApproveMultisig { true } - pub fn is_approved(_env: Env, _action_id: u64) -> bool { true } + pub fn is_approved(_env: Env, _action_id: u64) -> bool { + true + } pub fn approve(_env: Env, _signer: Address, _action_id: u64) {} pub fn revoke(_env: Env, _signer: Address, _action_id: u64) {} - pub fn is_signer(_env: Env, _address: Address) -> bool { true } - pub fn get_signer_count(_env: Env) -> u32 { 1 } - pub fn get_threshold(_env: Env) -> u32 { 1 } - pub fn get_approval_ttl(_env: Env) -> u64 { 0 } - pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { true } - pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { u32::MAX } + pub fn is_signer(_env: Env, _address: Address) -> bool { + true + } + pub fn get_signer_count(_env: Env) -> u32 { + 1 + } + pub fn get_threshold(_env: Env) -> u32 { + 1 + } + pub fn get_approval_ttl(_env: Env) -> u64 { + 0 + } + pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { + true + } + pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { + u32::MAX + } } // ─── NeverApproveMultisig ──────────────────────────────────────────────────── @@ -83,7 +97,9 @@ pub struct NeverApproveMultisig; #[contractimpl] impl NeverApproveMultisig { pub fn initialize(env: Env, signers: Vec
, _threshold: u32, _approval_ttl: u64) { - env.storage().instance().set(&symbol_multisig("INIT"), &signers); + env.storage() + .instance() + .set(&symbol_multisig("INIT"), &signers); } /// Always returns `false`. @@ -91,15 +107,29 @@ impl NeverApproveMultisig { false } - pub fn is_approved(_env: Env, _action_id: u64) -> bool { false } + pub fn is_approved(_env: Env, _action_id: u64) -> bool { + false + } pub fn approve(_env: Env, _signer: Address, _action_id: u64) {} pub fn revoke(_env: Env, _signer: Address, _action_id: u64) {} - pub fn is_signer(_env: Env, _address: Address) -> bool { false } - pub fn get_signer_count(_env: Env) -> u32 { 0 } - pub fn get_threshold(_env: Env) -> u32 { u32::MAX } - pub fn get_approval_ttl(_env: Env) -> u64 { 0 } - pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { false } - pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { 0 } + pub fn is_signer(_env: Env, _address: Address) -> bool { + false + } + pub fn get_signer_count(_env: Env) -> u32 { + 0 + } + pub fn get_threshold(_env: Env) -> u32 { + u32::MAX + } + pub fn get_approval_ttl(_env: Env) -> u64 { + 0 + } + pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { + false + } + pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { + 0 + } } // ─── SingleSignerMultisig ──────────────────────────────────────────────────── @@ -173,9 +203,15 @@ impl SingleSignerMultisig { stored.map(|s| s == address).unwrap_or(false) } - pub fn get_signer_count(_env: Env) -> u32 { 1 } - pub fn get_threshold(_env: Env) -> u32 { 1 } - pub fn get_approval_ttl(_env: Env) -> u64 { 0 } + pub fn get_signer_count(_env: Env) -> u32 { + 1 + } + pub fn get_threshold(_env: Env) -> u32 { + 1 + } + pub fn get_approval_ttl(_env: Env) -> u64 { + 0 + } pub fn has_approved(env: Env, action_id: u64, _signer: Address) -> bool { env.storage() @@ -190,7 +226,11 @@ impl SingleSignerMultisig { .instance() .get(&SingleSignerKey::Approved(action_id)) .unwrap_or(false); - if approved { 1 } else { 0 } + if approved { + 1 + } else { + 0 + } } } @@ -233,19 +273,34 @@ impl ExpiredApprovalMultisig { false // always expired } - pub fn is_approved(_env: Env, _action_id: u64) -> bool { false } + pub fn is_approved(_env: Env, _action_id: u64) -> bool { + false + } pub fn approve(env: Env, _signer: Address, action_id: u64) { - env.storage() - .instance() - .set(&ExpiredKey::ApprovalTime(action_id), &env.ledger().timestamp()); + env.storage().instance().set( + &ExpiredKey::ApprovalTime(action_id), + &env.ledger().timestamp(), + ); } pub fn revoke(_env: Env, _signer: Address, _action_id: u64) {} - pub fn is_signer(_env: Env, _address: Address) -> bool { true } - pub fn get_signer_count(_env: Env) -> u32 { 3 } - pub fn get_threshold(_env: Env) -> u32 { 2 } - pub fn get_approval_ttl(_env: Env) -> u64 { 1 } // 1 second → immediately expires - pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { false } - pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { 0 } + pub fn is_signer(_env: Env, _address: Address) -> bool { + true + } + pub fn get_signer_count(_env: Env) -> u32 { + 3 + } + pub fn get_threshold(_env: Env) -> u32 { + 2 + } + pub fn get_approval_ttl(_env: Env) -> u64 { + 1 + } // 1 second → immediately expires + pub fn has_approved(_env: Env, _action_id: u64, _signer: Address) -> bool { + false + } + pub fn get_approval_count(_env: Env, _action_id: u64) -> u32 { + 0 + } } // ─── helper ────────────────────────────────────────────────────────────────── @@ -257,8 +312,8 @@ fn symbol_multisig(s: &str) -> soroban_sdk::Symbol { // in a short symbol. The actual value is never read back — it's just used // to mark initialization. let _ = s; // suppress unused-parameter warning in this shim - // return a BytesN<32> or use a unit type instead — - // for initialization we just store a bool flag. + // return a BytesN<32> or use a unit type instead — + // for initialization we just store a bool flag. soroban_sdk::Symbol::short("INIT") } @@ -273,3 +328,4 @@ pub fn action_id_from_u64(env: &Env, discriminant: u64) -> BytesN<32> { bytes[..8].copy_from_slice(&discriminant.to_le_bytes()); BytesN::from_array(env, &bytes) } + diff --git a/test_adversaries/src/oracle.rs b/test_adversaries/src/oracle.rs index db77189..17c7e68 100644 --- a/test_adversaries/src/oracle.rs +++ b/test_adversaries/src/oracle.rs @@ -17,7 +17,9 @@ //! | [`StaleOracle`] | Reports a price but marks it as extremely old; any staleness check should reject it. | //! | [`NegativePriceOracle`] | `get_price()` returns `-1` — invalid price for settlement. | -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol, +}; // ─── shared storage ────────────────────────────────────────────────────────── @@ -64,12 +66,24 @@ impl AlwaysNoneOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 0 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } - pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { None } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 0 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } + pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { + None + } } // ─── ExtremeHighOracle ─────────────────────────────────────────────────────── @@ -100,11 +114,21 @@ impl ExtremeHighOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 1 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 1 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { Some((i128::MAX, 0)) } @@ -136,11 +160,21 @@ impl ExtremeLowOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 1 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 1 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { Some((1, 0)) } @@ -173,12 +207,24 @@ impl RevertingOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 0 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } - pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { None } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 0 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } + pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { + None + } } // ─── StaleOracle ───────────────────────────────────────────────────────────── @@ -221,11 +267,21 @@ impl StaleOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 1 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 1 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } } // ─── NegativePriceOracle ───────────────────────────────────────────────────── @@ -258,12 +314,23 @@ impl NegativePriceOracle { pub fn add_feeder(_env: Env, _feeder: Address) {} pub fn remove_feeder(_env: Env, _feeder: Address) {} pub fn report_price(_env: Env, _feeder: Address, _symbol: Symbol, _price: i128) {} - pub fn is_feeder(_env: Env, _feeder: Address) -> bool { false } - pub fn get_feeder_count(_env: Env) -> u32 { 1 } - pub fn get_max_staleness(_env: Env) -> u64 { 3600 } - pub fn get_min_reports(_env: Env) -> u32 { 1 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_feeder(_env: Env, _feeder: Address) -> bool { + false + } + pub fn get_feeder_count(_env: Env) -> u32 { + 1 + } + pub fn get_max_staleness(_env: Env) -> u64 { + 3600 + } + pub fn get_min_reports(_env: Env) -> u32 { + 1 + } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_latest_report(_env: Env, _symbol: Symbol, _feeder: Address) -> Option<(i128, u64)> { Some((-1, 0)) } } + diff --git a/test_adversaries/src/tokens.rs b/test_adversaries/src/tokens.rs index a603a92..f062608 100644 --- a/test_adversaries/src/tokens.rs +++ b/test_adversaries/src/tokens.rs @@ -89,7 +89,13 @@ impl LyingBalanceToken { i128::MAX } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -103,14 +109,29 @@ impl LyingBalanceToken { .unwrap_or(0i128) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "LyingBalanceToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "LIE") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "LyingBalanceToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "LIE") + } pub fn burn(_env: Env, _from: Address, _amount: i128) {} pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } // ─── FeeOnTransferToken ─────────────────────────────────────────────────────── @@ -151,17 +172,21 @@ impl FeeOnTransferToken { set_balance(&env, &from, from_bal - amount); set_balance(&env, &to, to_bal + delivered); // fee is burned (neither credited nor stored) - env.events().publish( - (symbol_short!("fee_burn"),), - (from.clone(), amount, fee), - ); + env.events() + .publish((symbol_short!("fee_burn"),), (from.clone(), amount, fee)); } pub fn balance(env: Env, id: Address) -> i128 { get_balance(&env, &id) } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -175,18 +200,33 @@ impl FeeOnTransferToken { .unwrap_or(0i128) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "FeeOnTransferToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "FOT") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "FeeOnTransferToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "FOT") + } pub fn burn(env: Env, from: Address, amount: i128) { from.require_auth(); let bal = get_balance(&env, &from); set_balance(&env, &from, bal - amount); } pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } // ─── RevertOnTransferToken ─────────────────────────────────────────────────── @@ -222,7 +262,13 @@ impl RevertOnTransferToken { get_balance(&env, &id) } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -236,14 +282,29 @@ impl RevertOnTransferToken { .unwrap_or(0i128) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "RevertOnTransferToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "REVT") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "RevertOnTransferToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "REVT") + } pub fn burn(_env: Env, _from: Address, _amount: i128) {} pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } // ─── ReentrantToken ────────────────────────────────────────────────────────── @@ -273,9 +334,10 @@ enum ReentrantKey { impl ReentrantToken { pub fn initialize(env: Env, admin: Address) { admin.require_auth(); - env.storage() - .instance() - .set(&ReentrantKey::Balances(admin.clone()), &1_000_000_000_000i128); + env.storage().instance().set( + &ReentrantKey::Balances(admin.clone()), + &1_000_000_000_000i128, + ); } pub fn mint(env: Env, to: Address, amount: i128) { @@ -339,11 +401,8 @@ impl ReentrantToken { // best-effort try_invoke_contract pattern — in testutils // the panic propagates, so tests should wrap this in // should_panic or use try_* client methods. - let _: Result<(), _> = env.try_invoke_contract( - &target, - &func, - soroban_sdk::vec![&env], - ); + let _: Result<(), _> = + env.try_invoke_contract(&target, &func, soroban_sdk::vec![&env]); } } } @@ -355,7 +414,13 @@ impl ReentrantToken { .unwrap_or(0i128) } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -369,14 +434,29 @@ impl ReentrantToken { .unwrap_or(0i128) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "ReentrantToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "REENT") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "ReentrantToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "REENT") + } pub fn burn(_env: Env, _from: Address, _amount: i128) {} pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } // ─── ZeroBalanceToken ───────────────────────────────────────────────────────── @@ -413,9 +493,17 @@ impl ZeroBalanceToken { } /// Always returns 0, regardless of actual balance. - pub fn balance(_env: Env, _id: Address) -> i128 { 0 } + pub fn balance(_env: Env, _id: Address) -> i128 { + 0 + } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -429,14 +517,29 @@ impl ZeroBalanceToken { .unwrap_or(0i128) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "ZeroBalanceToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "ZERO") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "ZeroBalanceToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "ZERO") + } pub fn burn(_env: Env, _from: Address, _amount: i128) {} pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } // ─── OverflowToken ──────────────────────────────────────────────────────────── @@ -472,9 +575,17 @@ impl OverflowToken { } /// Always returns `i128::MAX`. - pub fn balance(_env: Env, _id: Address) -> i128 { i128::MAX } + pub fn balance(_env: Env, _id: Address) -> i128 { + i128::MAX + } - pub fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + _expiration_ledger: u32, + ) { from.require_auth(); env.storage() .instance() @@ -488,12 +599,28 @@ impl OverflowToken { .unwrap_or(i128::MAX) } - pub fn decimals(_env: Env) -> u32 { 7 } - pub fn name(env: Env) -> String { String::from_str(&env, "OverflowToken") } - pub fn symbol(env: Env) -> String { String::from_str(&env, "OVF") } + pub fn decimals(_env: Env) -> u32 { + 7 + } + pub fn name(env: Env) -> String { + String::from_str(&env, "OverflowToken") + } + pub fn symbol(env: Env) -> String { + String::from_str(&env, "OVF") + } pub fn burn(_env: Env, _from: Address, _amount: i128) {} pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) {} - pub fn transfer_from(_env: Env, _spender: Address, _from: Address, _to: Address, _amount: i128) {} + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + } pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} - pub fn authorized(_env: Env, _id: Address) -> bool { true } + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } } + diff --git a/test_adversaries/src/vault.rs b/test_adversaries/src/vault.rs index 7fdbf96..fa25156 100644 --- a/test_adversaries/src/vault.rs +++ b/test_adversaries/src/vault.rs @@ -76,23 +76,39 @@ impl NoOpVault { /// No-op: does NOT push any tokens to `to`. pub fn withdraw(_env: Env, _token: Address, _tag: Tag, _to: Address, _amount: i128) {} - pub fn balance_of(_env: Env, _token: Address, _tag: Tag) -> i128 { 0 } - pub fn get_total_escrowed(_env: Env, _token: Address) -> i128 { 0 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn balance_of(_env: Env, _token: Address, _tag: Tag) -> i128 { + 0 + } + pub fn get_total_escrowed(_env: Env, _token: Address) -> i128 { + 0 + } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&VaultKey::Admin).unwrap() } - pub fn is_token_allowed(_env: Env, _token: Address) -> bool { true } - pub fn is_integrator(_env: Env, _addr: Address) -> bool { true } + pub fn is_token_allowed(_env: Env, _token: Address) -> bool { + true + } + pub fn is_integrator(_env: Env, _addr: Address) -> bool { + true + } pub fn set_token_allowed(_env: Env, _token: Address, _allowed: bool) {} pub fn set_integrator(_env: Env, _integrator: Address, _allowed: bool) {} pub fn transfer_tag(_env: Env, _token: Address, _from: Tag, _to: Tag, _amount: i128) {} - pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ None } + pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ + None + } // Legacy wrappers - pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { 0 } + pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { + 0 + } pub fn withdraw_legacy(_env: Env, _id: u64, _to: Address, _amount: i128) {} - pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { 0 } + pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { + 0 + } pub fn legacy_tag(env: Env, id: u64) -> Tag { Tag { owner: env.storage().instance().get(&VaultKey::Admin).unwrap(), @@ -156,19 +172,31 @@ impl WrongBalanceVault { .unwrap_or(0) } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&VaultKey::Admin).unwrap() } - pub fn is_token_allowed(_env: Env, _token: Address) -> bool { true } - pub fn is_integrator(_env: Env, _addr: Address) -> bool { true } + pub fn is_token_allowed(_env: Env, _token: Address) -> bool { + true + } + pub fn is_integrator(_env: Env, _addr: Address) -> bool { + true + } pub fn set_token_allowed(_env: Env, _token: Address, _allowed: bool) {} pub fn set_integrator(_env: Env, _integrator: Address, _allowed: bool) {} pub fn transfer_tag(_env: Env, _token: Address, _from: Tag, _to: Tag, _amount: i128) {} - pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ None } - pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { 0 } + pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ + None + } + pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { + 0 + } pub fn withdraw_legacy(_env: Env, _id: u64, _to: Address, _amount: i128) {} - pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { 0 } + pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { + 0 + } pub fn legacy_tag(env: Env, id: u64) -> Tag { Tag { owner: env.storage().instance().get(&VaultKey::Admin).unwrap(), @@ -218,20 +246,34 @@ impl RevertOnWithdrawVault { .unwrap_or(0) } - pub fn get_total_escrowed(_env: Env, _token: Address) -> i128 { 0 } - pub fn is_paused(_env: Env) -> bool { false } + pub fn get_total_escrowed(_env: Env, _token: Address) -> i128 { + 0 + } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&VaultKey::Admin).unwrap() } - pub fn is_token_allowed(_env: Env, _token: Address) -> bool { true } - pub fn is_integrator(_env: Env, _addr: Address) -> bool { true } + pub fn is_token_allowed(_env: Env, _token: Address) -> bool { + true + } + pub fn is_integrator(_env: Env, _addr: Address) -> bool { + true + } pub fn set_token_allowed(_env: Env, _token: Address, _allowed: bool) {} pub fn set_integrator(_env: Env, _integrator: Address, _allowed: bool) {} pub fn transfer_tag(_env: Env, _token: Address, _from: Tag, _to: Tag, _amount: i128) {} - pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ None } - pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { 0 } + pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ + None + } + pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { + 0 + } pub fn withdraw_legacy(_env: Env, _id: u64, _to: Address, _amount: i128) {} - pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { 0 } + pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { + 0 + } pub fn legacy_tag(env: Env, id: u64) -> Tag { Tag { owner: env.storage().instance().get(&VaultKey::Admin).unwrap(), @@ -302,19 +344,31 @@ impl DrainVault { .unwrap_or(0) } - pub fn is_paused(_env: Env) -> bool { false } + pub fn is_paused(_env: Env) -> bool { + false + } pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&VaultKey::Admin).unwrap() } - pub fn is_token_allowed(_env: Env, _token: Address) -> bool { true } - pub fn is_integrator(_env: Env, _addr: Address) -> bool { true } + pub fn is_token_allowed(_env: Env, _token: Address) -> bool { + true + } + pub fn is_integrator(_env: Env, _addr: Address) -> bool { + true + } pub fn set_token_allowed(_env: Env, _token: Address, _allowed: bool) {} pub fn set_integrator(_env: Env, _integrator: Address, _allowed: bool) {} pub fn transfer_tag(_env: Env, _token: Address, _from: Tag, _to: Tag, _amount: i128) {} - pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ None } - pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { 0 } + pub fn get_tag_owner(_env: Env, _tag: Tag) -> Option
{ + None + } + pub fn deposit_legacy(_env: Env, _from: Address, _id: u64, _amount: i128) -> i128 { + 0 + } pub fn withdraw_legacy(_env: Env, _id: u64, _to: Address, _amount: i128) {} - pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { 0 } + pub fn balance_of_legacy(_env: Env, _id: u64) -> i128 { + 0 + } pub fn legacy_tag(env: Env, id: u64) -> Tag { Tag { owner: env.storage().instance().get(&VaultKey::Admin).unwrap(), @@ -323,3 +377,4 @@ impl DrainVault { } } } + diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index 26ce25b..97b1296 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -32,8 +32,8 @@ //! point: the batch that was going to run second never runs at all. use soroban_sdk::{ - contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, - TryFromVal, Val, Vec, + contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, TryFromVal, + Val, Vec, }; #[cfg(test)] @@ -100,7 +100,9 @@ fn set_role(env: &Env, role: Role, account: &Address, granted: bool) { } fn load(env: &Env, id: &BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey::Operation(id.clone())) + env.storage() + .persistent() + .get(&DataKey::Operation(id.clone())) } /// The `i`th argument of a self-call, decoded. @@ -172,12 +174,18 @@ impl Timelock { panic_with_error!(&env, Error::InsufficientDelay); } env.storage().instance().set(&DataKey::MinDelay, &min_delay); - env.storage().instance().set(&DataKey::GracePeriod, &grace_period); + env.storage() + .instance() + .set(&DataKey::GracePeriod, &grace_period); for p in proposers.iter() { - env.storage().instance().set(&DataKey::Proposer(p.clone()), &true); + env.storage() + .instance() + .set(&DataKey::Proposer(p.clone()), &true); } for e in executors.iter() { - env.storage().instance().set(&DataKey::Executor(e.clone()), &true); + env.storage() + .instance() + .set(&DataKey::Executor(e.clone()), &true); } for c in cancellers.iter() { env.storage() @@ -280,9 +288,8 @@ impl Timelock { salt.clone(), ); let key = DataKey::Operation(id.clone()); - let mut op: Operation = load(&env, &id).unwrap_or_else(|| { - panic_with_error!(&env, Error::NotPending) - }); + let mut op: Operation = + load(&env, &id).unwrap_or_else(|| panic_with_error!(&env, Error::NotPending)); if op.done { panic_with_error!(&env, Error::NotPending); } diff --git a/timelock/src/migrations.rs b/timelock/src/migrations.rs index 1b2f2dc..5b5008e 100644 --- a/timelock/src/migrations.rs +++ b/timelock/src/migrations.rs @@ -78,4 +78,3 @@ impl Timelock { migrations::migration_state(&env) } } - diff --git a/timelock/src/test.rs b/timelock/src/test.rs index b4cdbb6..cc87b7c 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -631,13 +631,16 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { predecessor: op.predecessor.clone(), salt: op.salt.clone(), }; - assert_eq!(h.client.hash_operation( - &spec.targets, - &spec.fns, - &spec.args, - &spec.predecessor, - &spec.salt, - ), id); + assert_eq!( + h.client.hash_operation( + &spec.targets, + &spec.fns, + &spec.args, + &spec.predecessor, + &spec.salt, + ), + id + ); } // ── the timelock as the admin of another contract ──────────────────────────── diff --git a/vault/src/lib.rs b/vault/src/lib.rs index 622c1e2..615f886 100644 --- a/vault/src/lib.rs +++ b/vault/src/lib.rs @@ -6,3 +6,4 @@ pub mod token_validation; #[cfg(test)] mod test_adversaries; + diff --git a/vault/src/test_adversaries.rs b/vault/src/test_adversaries.rs index d2d29ef..d17fb6d 100644 --- a/vault/src/test_adversaries.rs +++ b/vault/src/test_adversaries.rs @@ -62,11 +62,7 @@ fn fee_on_transfer_token_deposit_credits_only_received_delta() { env.invoke_contract::<()>( &token_id, &Symbol::new(&env, "mint"), - soroban_sdk::vec![ - &env, - depositor.clone().into(), - 1_000_000i128.into(), - ], + soroban_sdk::vec![&env, depositor.clone().into(), 1_000_000i128.into(),], ); // When vault is implemented: @@ -300,21 +296,12 @@ fn safe_inbound_transfer_with_fee_on_transfer_token_returns_delta_mismatch() { env.invoke_contract::<()>( &token_id, &Symbol::new(&env, "mint"), - soroban_sdk::vec![ - &env, - sender.clone().into(), - 1_000_000i128.into(), - ], + soroban_sdk::vec![&env, sender.clone().into(), 1_000_000i128.into(),], ); // Ask for 1_000_000 but only 900_000 will arrive (10% fee). - let result = TokenTransferGuard::safe_inbound_transfer( - &env, - &token_id, - &sender, - &recipient, - 1_000_000, - ); + let result = + TokenTransferGuard::safe_inbound_transfer(&env, &token_id, &sender, &recipient, 1_000_000); // 900_000 < 1_000_000 → DeltaMismatch assert_eq!(result, Err(TokenError::DeltaMismatch)); } @@ -341,19 +328,11 @@ fn safe_inbound_transfer_with_revert_token_panics() { env.invoke_contract::<()>( &token_id, &Symbol::new(&env, "mint"), - soroban_sdk::vec![ - &env, - sender.clone().into(), - 1_000_000i128.into(), - ], + soroban_sdk::vec![&env, sender.clone().into(), 1_000_000i128.into(),], ); // RevertOnTransferToken.transfer() always panics — should propagate. - let _ = TokenTransferGuard::safe_inbound_transfer( - &env, - &token_id, - &sender, - &recipient, - 1_000_000, - ); + let _ = + TokenTransferGuard::safe_inbound_transfer(&env, &token_id, &sender, &recipient, 1_000_000); } + diff --git a/vault/src/token_validation.rs b/vault/src/token_validation.rs index 77f20aa..c25c4cd 100644 --- a/vault/src/token_validation.rs +++ b/vault/src/token_validation.rs @@ -31,7 +31,9 @@ impl TokenTransferGuard { client.transfer(from, recipient, &amount); let after_bal = client.balance(recipient); - let received = after_bal.checked_sub(before_bal).ok_or(TokenError::DeltaMismatch)?; + let received = after_bal + .checked_sub(before_bal) + .ok_or(TokenError::DeltaMismatch)?; if received < amount { return Err(TokenError::DeltaMismatch); @@ -40,3 +42,4 @@ impl TokenTransferGuard { Ok(received) } } + From 1381d59d2b43bf5e6ae7486a6b02c05e5a102438 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 10:48:08 +0100 Subject: [PATCH 05/17] fix(build): unblock the workspace build 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. --- common/src/migrations.rs | 1 - common/src/upgrade.rs | 1 - multisig/Cargo.toml | 2 ++ options_market/src/lib.rs | 32 +------------------------------- params/Cargo.toml | 2 ++ price_oracle/src/lib.rs | 1 - staking/Cargo.toml | 2 ++ streams/Cargo.toml | 2 ++ test_adversaries/src/lib.rs | 1 - test_adversaries/src/multisig.rs | 1 - test_adversaries/src/oracle.rs | 1 - test_adversaries/src/tokens.rs | 1 - test_adversaries/src/vault.rs | 1 - vault/src/lib.rs | 3 ++- vault/src/test_adversaries.rs | 1 - vault/src/token_validation.rs | 1 - 16 files changed, 11 insertions(+), 42 deletions(-) diff --git a/common/src/migrations.rs b/common/src/migrations.rs index 12332d1..a9cb608 100644 --- a/common/src/migrations.rs +++ b/common/src/migrations.rs @@ -231,4 +231,3 @@ where fn load_state(env: &Env) -> Option { env.storage().instance().get(&MigrationKey::MigrationState) } - diff --git a/common/src/upgrade.rs b/common/src/upgrade.rs index d24aa44..8e16ff0 100644 --- a/common/src/upgrade.rs +++ b/common/src/upgrade.rs @@ -93,4 +93,3 @@ pub fn upgrade( env.events() .publish((Symbol::new(env, "upgraded"),), new_wasm_hash); } - diff --git a/multisig/Cargo.toml b/multisig/Cargo.toml index 610ee7d..5b23860 100644 --- a/multisig/Cargo.toml +++ b/multisig/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } +# The storage-schema migration framework every Zenith contract uses. +zenith-common = { path = "../common" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/options_market/src/lib.rs b/options_market/src/lib.rs index 17bf010..a5382e2 100644 --- a/options_market/src/lib.rs +++ b/options_market/src/lib.rs @@ -27,37 +27,7 @@ use math::{ }; use storage::{ add_user_position, fee_rate_bps, next_position_id, require_active_series, require_not_paused, -} +}; #[cfg(test)] mod test; - -//! Zenith Protocol — Decentralized Options Market on Stellar Soroban -//! -//! Supports European-style put and call options on XLM, BTC, ETH, and SOL. -//! Premium is set by the admin (computed off-chain via Black-Scholes). -//! Writers lock collateral; buyers pay premium. Settlement at expiry via oracle. - -use soroban_sdk::{ - contract, contractimpl, panic_with_error, token, Address, BytesN, Env, Symbol, Vec, -}; - -mod error; -mod events; -mod math; -mod multisig_client; -mod price_oracle_client; -mod storage; -mod types; -mod vault_client; - -use error::Error; -use math::{ - calc_fee, calc_payout, DEFAULT_FEE_RATE_BPS, MAX_BATCH_SIZE, MAX_FEE_RATE_BPS, - MAX_SERIES_PER_UNDERLYING, MIN_COLLATERAL_RATIO, PRICE_PRECISION, RATE_PRECISION, - SETTLEMENT_WINDOW, -}; -use storage::{ - add_user_position, fee_rate_bps, next_position_id, require_active_series, require_not_paused, -}; -use types::{DataKey, OptionPosition, OptionSeries, OptionType, PositionSide, SeriesState}; diff --git a/params/Cargo.toml b/params/Cargo.toml index d9d4727..49829f4 100644 --- a/params/Cargo.toml +++ b/params/Cargo.toml @@ -11,6 +11,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } +# The storage-schema migration framework every Zenith contract uses. +zenith-common = { path = "../common" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index 8b13789..e69de29 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -1 +0,0 @@ - diff --git a/staking/Cargo.toml b/staking/Cargo.toml index ec6d003..2777957 100644 --- a/staking/Cargo.toml +++ b/staking/Cargo.toml @@ -9,6 +9,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } +# The storage-schema migration framework every Zenith contract uses. +zenith-common = { path = "../common" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/streams/Cargo.toml b/streams/Cargo.toml index 62ec21c..52c1baa 100644 --- a/streams/Cargo.toml +++ b/streams/Cargo.toml @@ -18,6 +18,8 @@ codegen-units = 1 [dependencies] soroban-sdk = { version = "21.0.0" } +# The storage-schema migration framework every Zenith contract uses. +zenith-common = { path = "../common" } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/test_adversaries/src/lib.rs b/test_adversaries/src/lib.rs index 811fb3a..0271c09 100644 --- a/test_adversaries/src/lib.rs +++ b/test_adversaries/src/lib.rs @@ -24,4 +24,3 @@ pub mod multisig; pub mod oracle; pub mod tokens; pub mod vault; - diff --git a/test_adversaries/src/multisig.rs b/test_adversaries/src/multisig.rs index 9c62b3e..d915afe 100644 --- a/test_adversaries/src/multisig.rs +++ b/test_adversaries/src/multisig.rs @@ -328,4 +328,3 @@ pub fn action_id_from_u64(env: &Env, discriminant: u64) -> BytesN<32> { bytes[..8].copy_from_slice(&discriminant.to_le_bytes()); BytesN::from_array(env, &bytes) } - diff --git a/test_adversaries/src/oracle.rs b/test_adversaries/src/oracle.rs index 17c7e68..87a184c 100644 --- a/test_adversaries/src/oracle.rs +++ b/test_adversaries/src/oracle.rs @@ -333,4 +333,3 @@ impl NegativePriceOracle { Some((-1, 0)) } } - diff --git a/test_adversaries/src/tokens.rs b/test_adversaries/src/tokens.rs index f062608..75254d8 100644 --- a/test_adversaries/src/tokens.rs +++ b/test_adversaries/src/tokens.rs @@ -623,4 +623,3 @@ impl OverflowToken { true } } - diff --git a/test_adversaries/src/vault.rs b/test_adversaries/src/vault.rs index fa25156..f70d786 100644 --- a/test_adversaries/src/vault.rs +++ b/test_adversaries/src/vault.rs @@ -377,4 +377,3 @@ impl DrainVault { } } } - diff --git a/vault/src/lib.rs b/vault/src/lib.rs index 615f886..c62e640 100644 --- a/vault/src/lib.rs +++ b/vault/src/lib.rs @@ -1,3 +1,5 @@ +#![no_std] + // vault lib.rs — implementation stub. // The token_validation module is fully implemented (used in tests); // test modules are declared here. @@ -6,4 +8,3 @@ pub mod token_validation; #[cfg(test)] mod test_adversaries; - diff --git a/vault/src/test_adversaries.rs b/vault/src/test_adversaries.rs index d17fb6d..450c139 100644 --- a/vault/src/test_adversaries.rs +++ b/vault/src/test_adversaries.rs @@ -335,4 +335,3 @@ fn safe_inbound_transfer_with_revert_token_panics() { let _ = TokenTransferGuard::safe_inbound_transfer(&env, &token_id, &sender, &recipient, 1_000_000); } - diff --git a/vault/src/token_validation.rs b/vault/src/token_validation.rs index c25c4cd..f1420ef 100644 --- a/vault/src/token_validation.rs +++ b/vault/src/token_validation.rs @@ -42,4 +42,3 @@ impl TokenTransferGuard { Ok(received) } } - From 680325361539b18a2e337f6778cbcb18148b8937 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 11:10:12 +0100 Subject: [PATCH 06/17] fix(build): unblock the workspace build and the fmt gate 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. --- Cargo.toml | 4 +++- options_market/src/events.rs | 6 ++---- options_market/src/test.rs | 15 +++++++++++---- options_market/src/vault_client.rs | 4 +--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f007a45..47ccbc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,9 @@ publish = false [workspace.dependencies] soroban-sdk = { version = "21.7.7" } -ed25519-dalek = "2" +# Pinned: ed25519-dalek 3 moved to rand_core 0.9, which soroban-env-host's +# rand_chacha (rand_core 0.6) cannot satisfy, so the host fails to compile. +ed25519-dalek = "=2.2.0" # Internal crates. Contracts that call each other on-chain use # `contractimport!` against the compiled wasm (see diff --git a/options_market/src/events.rs b/options_market/src/events.rs index 959645a..207b202 100644 --- a/options_market/src/events.rs +++ b/options_market/src/events.rs @@ -116,10 +116,8 @@ pub fn series_escrowed_to_vault(env: &Env, series_id: u64, amount: i128) { } pub fn vault_updated(env: &Env, old_vault: Option
, new_vault: Address) { - env.events().publish( - (Symbol::new(env, "vault_updated"),), - (old_vault, new_vault), - ); + env.events() + .publish((Symbol::new(env, "vault_updated"),), (old_vault, new_vault)); } pub fn option_auto_exercised( env: &Env, diff --git a/options_market/src/test.rs b/options_market/src/test.rs index e079ac3..c52bde2 100644 --- a/options_market/src/test.rs +++ b/options_market/src/test.rs @@ -1221,7 +1221,8 @@ fn escrow_series_to_vault_rejects_unauthorized_vault() { let series_id = make_series(&h, OptionType::Call, 700_000_000, 40_000_000); let buyer = Address::generate(&h.env); mint(&h, &buyer, 1_000 * USDC_DECIMALS); - h.client.buy_option(&buyer, &series_id, &USDC_DECIMALS, &(50 * USDC_DECIMALS)); + h.client + .buy_option(&buyer, &series_id, &USDC_DECIMALS, &(50 * USDC_DECIMALS)); h.client.cancel_series(&series_id); // Passing fake_vault must fail with UnauthorizedVault @@ -1236,11 +1237,16 @@ fn rotating_vault_after_escrow_still_pays_refund_from_original_vault() { let series_id = make_series(&h, OptionType::Call, 700_000_000, 40_000_000); let buyer = Address::generate(&h.env); mint(&h, &buyer, 1_000 * USDC_DECIMALS); - let pos_id = h.client.buy_option(&buyer, &series_id, &USDC_DECIMALS, &(50 * USDC_DECIMALS)); + let pos_id = h + .client + .buy_option(&buyer, &series_id, &USDC_DECIMALS, &(50 * USDC_DECIMALS)); h.client.cancel_series(&series_id); h.client.escrow_series_to_vault(&original_vault, &series_id); - assert_eq!(h.client.get_series_vault(&series_id), Some(original_vault.clone())); + assert_eq!( + h.client.get_series_vault(&series_id), + Some(original_vault.clone()) + ); // Rotate pinned vault to a new vault let new_vault = setup_vault(&h); @@ -1248,7 +1254,8 @@ fn rotating_vault_after_escrow_still_pays_refund_from_original_vault() { // Claim refund still succeeds against the original vault recorded in SeriesVault let before = balance(&h, &buyer); - h.client.claim_refund_from_vault(&original_vault, &buyer, &pos_id); + h.client + .claim_refund_from_vault(&original_vault, &buyer, &pos_id); assert_eq!(balance(&h, &buyer) - before, 39_800_000); assert!(h.client.get_position(&pos_id).unwrap().is_settled); } diff --git a/options_market/src/vault_client.rs b/options_market/src/vault_client.rs index d8cb89a..166729f 100644 --- a/options_market/src/vault_client.rs +++ b/options_market/src/vault_client.rs @@ -7,6 +7,4 @@ //! Requires vault's wasm in the shared workspace target/ before this crate //! can compile at all; `cargo xtask build` handles that (see //! `[package.metadata.zenith] wasm-deps` in ../Cargo.toml). -soroban_sdk::contractimport!( - file = "../target/wasm32-unknown-unknown/release/zenith_vault.wasm" -); +soroban_sdk::contractimport!(file = "../target/wasm32-unknown-unknown/release/zenith_vault.wasm"); From 9345637a2029393940ee5aa22e219b62e048f59d Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 11:16:17 +0100 Subject: [PATCH 07/17] fix(build): pin the dependency resolution and the remaining workspace 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. --- .github/workflows/ci.yml | 19 +- Cargo.lock | 1771 +++++++++++++++++++++++++++++++++ Cargo.toml | 6 +- options_market/src/math.rs | 2 + options_market/src/storage.rs | 2 + price_oracle/src/lib.rs | 2 + 6 files changed, 1795 insertions(+), 7 deletions(-) create mode 100644 Cargo.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df1dd11..2702eb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,14 @@ env: # multisig build step above runs before the vault build step, not just # before options_market's. # + crate: [options_market, price_oracle, vault, multisig, streams, staking, timelock, governor, lp_token, lp_pool, integration_tests] jobs: fmt: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-snapshot] + crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-sna steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -52,6 +54,7 @@ jobs: clippy: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-snapshot] steps: @@ -67,6 +70,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/resource-snapshot] steps: @@ -80,6 +84,7 @@ jobs: wasm: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: crate: [options_market, price_oracle, vault, multisig, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests] steps: @@ -90,7 +95,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - build: + wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -99,9 +104,16 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - run: cargo xtask build + # Fails if soroban-sdk/testutils + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - check-wasm: + wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -184,4 +196,3 @@ jobs: python3 scripts/resource-diff/resource_diff.py ${{ github.event_name == 'pull_request' && format('--base-ref origin/{0}', github.base_ref) || '' }} options_market - diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ff0950f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1771 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes-lit" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "21.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f57a68ef8777e28e274de0f3a88ad9a5a41d9a2eb461b4dd800b086f0e83b80" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "21.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1c89463835fe6da996318156d39f424b4f167c725ec692e5a7a2d4e694b3d" +dependencies = [ + "arbitrary", + "crate-git-revision", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "21.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bfb2536811045d5cd0c656a324cbe9ce4467eb734c7946b74410d90dea5d0ce" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "21.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a32c28f281c423189f1298960194f0e0fc4eeb72378028171e556d8cd6160" +dependencies = [ + "backtrace", + "curve25519-dalek", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "21.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "242926fe5e0d922f12d3796cd7cd02dd824e5ef1caa088f45fce20b618309f64" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "21.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6edf92749fd8399b417192d301c11f710b9cdce15789a3d157785ea971576fa" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-sdk" +version = "21.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcdf04484af7cc731a7a48ad1d9f5f940370edeea84734434ceaf398a6b862e" +dependencies = [ + "arbitrary", + "bytes-lit", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey", +] + +[[package]] +name = "soroban-sdk-macros" +version = "21.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0974e413731aeff2443f2305b344578b3f1ffd18335a7ba0f0b5d2eb4e94c9ce" +dependencies = [ + "crate-git-revision", + "darling 0.20.11", + "itertools", + "proc-macro2", + "quote", + "rustc_version", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "21.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2c70b20e68cae3ef700b8fa3ae29db1c6a294b311fba66918f90cb8f9fd0a1a" +dependencies = [ + "base64 0.13.1", + "stellar-xdr", + "thiserror 1.0.69", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "21.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2dafbde981b141b191c6c036abc86097070ddd6eaaa33b273701449501e43d3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +dependencies = [ + "base32", + "crate-git-revision", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-xdr" +version = "21.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50" +dependencies = [ + "arbitrary", + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "serde", + "serde_with", + "stellar-strkey", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zenith-lp-pool" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 47ccbc3..b4d2454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "governor", "grants_escrow", "integration_tests", + "lp_pool", + "lp_token", "multisig", "options_market", "params", @@ -28,9 +30,7 @@ publish = false [workspace.dependencies] soroban-sdk = { version = "21.7.7" } -# Pinned: ed25519-dalek 3 moved to rand_core 0.9, which soroban-env-host's -# rand_chacha (rand_core 0.6) cannot satisfy, so the host fails to compile. -ed25519-dalek = "=2.2.0" +ed25519-dalek = "2" # Internal crates. Contracts that call each other on-chain use # `contractimport!` against the compiled wasm (see diff --git a/options_market/src/math.rs b/options_market/src/math.rs index e69de29..139597f 100644 --- a/options_market/src/math.rs +++ b/options_market/src/math.rs @@ -0,0 +1,2 @@ + + diff --git a/options_market/src/storage.rs b/options_market/src/storage.rs index e69de29..139597f 100644 --- a/options_market/src/storage.rs +++ b/options_market/src/storage.rs @@ -0,0 +1,2 @@ + + diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index e69de29..139597f 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -0,0 +1,2 @@ + + From 0f35a0b81e12f4cac0fbf296d93e237a51d57e9d Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 13:16:03 +0100 Subject: [PATCH 08/17] fix(ci): restore the repaired workflow and the dependency pin 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. --- .github/workflows/ci.yml | 15 ++++----------- Cargo.toml | 4 +++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2702eb2..b538b2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,13 @@ env: # multisig build step above runs before the vault build step, not just # before options_market's. # - crate: [options_market, price_oracle, vault, multisig, streams, staking, timelock, governor, lp_token, lp_pool, integration_tests] jobs: fmt: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-sna + crate: [options_market, price_oracle, vault, multisig, common, streams, staking, timelock, governor, params, grants_escrow, lp_token, lp_pool, integration_tests, tools/spec-diff, tools/resource-snapshot] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -95,7 +94,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - wasm: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -104,16 +103,9 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - run: cargo xtask build - # Fails if soroban-sdk/testutils - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - run: cargo xtask test - wasm: + check-wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -196,3 +188,4 @@ jobs: python3 scripts/resource-diff/resource_diff.py ${{ github.event_name == 'pull_request' && format('--base-ref origin/{0}', github.base_ref) || '' }} options_market + diff --git a/Cargo.toml b/Cargo.toml index b4d2454..0082484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,9 @@ publish = false [workspace.dependencies] soroban-sdk = { version = "21.7.7" } -ed25519-dalek = "2" +# Pinned: ed25519-dalek 3 moved to rand_core 0.9, which soroban-env-host's +# rand_chacha (rand_core 0.6) cannot satisfy, so the host fails to compile. +ed25519-dalek = "=2.2.0" # Internal crates. Contracts that call each other on-chain use # `contractimport!` against the compiled wasm (see From 747bdb683b0a5ca2aa962c51c8880fa78311ac57 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 13:30:08 +0100 Subject: [PATCH 09/17] fix(timelock): the predecessor cannot be an Option in a contracttype field Option> 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. --- options_market/src/math.rs | 1 - options_market/src/storage.rs | 1 - price_oracle/src/lib.rs | 1 - timelock/src/lib.rs | 20 +++++++++++++++++--- timelock/src/migrations.rs | 4 ++++ timelock/src/test.rs | 19 ++++++------------- timelock/src/types.rs | 12 +++++++++--- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/options_market/src/math.rs b/options_market/src/math.rs index 139597f..8b13789 100644 --- a/options_market/src/math.rs +++ b/options_market/src/math.rs @@ -1,2 +1 @@ - diff --git a/options_market/src/storage.rs b/options_market/src/storage.rs index 139597f..8b13789 100644 --- a/options_market/src/storage.rs +++ b/options_market/src/storage.rs @@ -1,2 +1 @@ - diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index 139597f..8b13789 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -1,2 +1 @@ - diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index 97b1296..3645a88 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -99,6 +99,19 @@ fn set_role(env: &Env, role: Role, account: &Address, granted: bool) { events::role_changed(env, role, account.clone(), granted); } +/// The zero id, which stands for "no predecessor" in the stored form. +fn no_predecessor(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0u8; 32]) +} + +/// The stored form of a predecessor: the id, or the zero id for none. +fn predecessor_id(env: &Env, predecessor: Option>) -> BytesN<32> { + match predecessor { + Some(id) => id, + None => no_predecessor(env), + } +} + fn load(env: &Env, id: &BytesN<32>) -> Option { env.storage() .persistent() @@ -248,7 +261,7 @@ impl Timelock { targets, fns, args, - predecessor, + predecessor: predecessor_id(&env, predecessor), salt, ready_at, done: false, @@ -305,7 +318,8 @@ impl Timelock { // The predecessor must have executed. A cancelled predecessor is // gone from storage, so it reads as "not done" here: the dependant // stays unexecutable rather than running out of order. - if let Some(pred) = op.predecessor.clone() { + let pred = op.predecessor.clone(); + if pred != no_predecessor(&env) { let done = load(&env, &pred).is_some_and(|p| p.done); if !done { panic_with_error!(&env, Error::PredecessorNotDone); @@ -428,7 +442,7 @@ impl Timelock { targets, fns, args, - predecessor, + predecessor: predecessor_id(&env, predecessor), salt, }; env.crypto().sha256(&spec.clone().to_xdr(&env)).into() diff --git a/timelock/src/migrations.rs b/timelock/src/migrations.rs index 5b5008e..f758941 100644 --- a/timelock/src/migrations.rs +++ b/timelock/src/migrations.rs @@ -19,6 +19,10 @@ use zenith_common::migrations::{self, MigrationErrors, Step}; use crate::error::Error; use crate::Timelock; +// The client is generated by the `#[contract]` macro where the contract is +// declared (the crate root), so the second `#[contractimpl]` block below +// needs it in scope. +use crate::TimelockClient; /// The layout this wasm reads and writes. Raise it by one in the same PR /// that adds the matching arm to `step`. diff --git a/timelock/src/test.rs b/timelock/src/test.rs index cc87b7c..8c21543 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -1,6 +1,6 @@ #![cfg(test)] -use crate::{OperationSpec, Role, Timelock, TimelockClient}; +use crate::{Role, Timelock, TimelockClient}; use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, @@ -624,20 +624,13 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { assert!(!op.done); assert_eq!(op.ready_at, DELAY); // The spec the id was derived from is the same one that was stored. - let spec = OperationSpec { - targets: op.targets.clone(), - fns: op.fns.clone(), - args: op.args.clone(), - predecessor: op.predecessor.clone(), - salt: op.salt.clone(), - }; assert_eq!( h.client.hash_operation( - &spec.targets, - &spec.fns, - &spec.args, - &spec.predecessor, - &spec.salt, + &op.targets, + &op.fns, + &op.args, + &None, + &op.salt, ), id ); diff --git a/timelock/src/types.rs b/timelock/src/types.rs index 455d69a..bb15e47 100644 --- a/timelock/src/types.rs +++ b/timelock/src/types.rs @@ -27,8 +27,13 @@ pub struct Operation { pub targets: Vec
, pub fns: Vec, pub args: Vec>, - /// The operation that must have executed before this one may execute. - pub predecessor: Option>, + /// The operation that must have executed before this one may execute, + /// or the zero id when there is none. + /// + /// `Option>` cannot be a `#[contracttype]` field — the SDK's + /// XDR conversion has no `From>` for it — so the stored form + /// is the id itself, with the zero id standing for "no predecessor". + pub predecessor: BytesN<32>, /// The nonce that makes this operation's id unique. pub salt: BytesN<32>, /// Ledger timestamp from which the operation may be executed. @@ -51,7 +56,8 @@ pub struct OperationSpec { pub targets: Vec
, pub fns: Vec, pub args: Vec>, - pub predecessor: Option>, + /// The predecessor they wait on, or the zero id when there is none. + pub predecessor: BytesN<32>, pub salt: BytesN<32>, } From 274994f2688f6627682dca1d4c187a2da392c319 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:00:08 +0100 Subject: [PATCH 10/17] fix(ci): give each matrix job the crate it names 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. --- .github/workflows/ci.yml | 5 +++-- timelock/src/test.rs | 9 ++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b538b2b..f3ed67e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - run: cargo xtask check-imports - - run: cargo xtask clippy + - run: cargo xtask clippy -p ${{ matrix.crate }} test: runs-on: ubuntu-latest @@ -79,6 +79,7 @@ jobs: components: rustfmt - run: cargo fmt --check working-directory: ${{ matrix.crate }} + - run: cargo xtask test -p ${{ matrix.crate }} wasm: runs-on: ubuntu-latest @@ -92,7 +93,7 @@ jobs: with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - - run: cargo xtask test + - run: cargo xtask build -p ${{ matrix.crate }} build: runs-on: ubuntu-latest diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 8c21543..a372450 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -625,13 +625,8 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { assert_eq!(op.ready_at, DELAY); // The spec the id was derived from is the same one that was stored. assert_eq!( - h.client.hash_operation( - &op.targets, - &op.fns, - &op.args, - &None, - &op.salt, - ), + h.client + .hash_operation(&op.targets, &op.fns, &op.args, &None, &op.salt,), id ); } From 3816f8eeda546010c14f533b6a03bd686620f00a Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:20:01 +0100 Subject: [PATCH 11/17] feat(timelock): the three-role timelock, with derived operation ids 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. --- README.md | 11 +- docs/governance-handover.md | 2 +- governor/src/interfaces.rs | 3 +- governor/src/lib.rs | 1 - governor/src/test.rs | 15 +- integration_tests/tests/handover.rs | 105 ++--- scripts/governance/common.sh | 22 +- scripts/governance/stage2_rollback.sh | 4 +- .../governance/stage3_proposer_to_governor.sh | 2 +- scripts/governance/stage4_lock_in.sh | 2 +- timelock/src/lib.rs | 218 ++++++----- timelock/src/test.rs | 369 ++++++------------ timelock/src/types.rs | 18 +- 13 files changed, 317 insertions(+), 455 deletions(-) diff --git a/README.md b/README.md index 626ce0e..04335af 100644 --- a/README.md +++ b/README.md @@ -958,9 +958,10 @@ seconds before it can run. Modelled on OpenZeppelin's TimelockController. | Function | Description | |---|---| -| `initialize(min_delay, grace_period, proposers, executors, cancellers)` | One-time setup. Empty `executors` means anyone may execute a ready operation. There is no admin afterwards. `min_delay` may not be below the documented floor, and `grace_period` is how long a ready operation stays executable. | -| `schedule(proposer, targets, fns, args, predecessor, salt, delay) -> id` | Proposer-only. The batch is three parallel vectors — the target, the function and the arguments of each call. `delay >= min_delay`. The id is derived from the batch, the predecessor and the salt (see `hash_operation`). Rejects an id that's already pending or done. | -| `execute(executor, targets, fns, args, predecessor, salt)` | Executor-only (unless open). Requires the operation ready, not expired, and `predecessor` (if any) done. Runs every call in order via `invoke_contract`; any revert reverts the whole batch. | +| `initialize(min_delay, grace_period, proposers, executors, cancellers)` | One-time setup. Empty `executors` means anyone may execute a ready operation. There is no admin afterwards. `min_delay` may not be below the documented floor (60 s), and `grace_period` is how long a ready operation stays executable. | +| `schedule(proposer, targets, fns, args, salt, delay) -> id` | Proposer-only. The batch is three parallel vectors — the target, the function and the arguments of each call. `delay >= min_delay`. The id is derived from the batch and the salt (see `hash_operation`). Rejects an id that's already pending or done. | +| `schedule_after(proposer, targets, fns, args, predecessor, salt) -> id` | Proposer-only. As `schedule`, but the operation may not execute until `predecessor` has. The delay is the timelock's minimum. | +| `execute(executor, id)` | Executor-only (unless open). Requires the operation ready, not expired, and its predecessor (if any) done. Runs every call in order via `invoke_contract`; any revert reverts the whole batch. | | `cancel(canceller, id)` | Canceller-only. Removes a pending operation, so anything waiting on it as a predecessor is stranded for good. | **Three roles, no admin.** `Proposer` may schedule, `Executor` may execute, @@ -993,8 +994,8 @@ schema-migration errors every Zenith contract shares. 1. Deploy the timelock, then `transfer_admin(timelock)` on each contract. 2. A proposer schedules the change, e.g. `targets = [options_market]`, `fns = ["set_fee_rate"]`, `args = [[25u32]]`, with a unique `salt`, and announces the id `schedule` returns. 3. During the delay users can exit; a canceller can `cancel(id)`. -4. Once `is_operation_ready(id)`, an executor calls `execute` with the exact same `targets`/`fns`/`args`/`predecessor`/`salt`. -5. Use `predecessor` to force ordering (e.g. a migration after an `upgrade`). +4. Once `is_operation_ready(id)`, an executor calls `execute(executor, id)`. +5. Use `schedule_after` to force ordering (e.g. a migration after an `upgrade`). ## `params` reference diff --git a/docs/governance-handover.md b/docs/governance-handover.md index 92d3b1c..f8068c1 100644 --- a/docs/governance-handover.md +++ b/docs/governance-handover.md @@ -49,7 +49,7 @@ still works after lock-in. | Transition | Script | Mechanism | |------------|--------|-----------| | 0 → 1 | `stage1_admin_to_multisig.sh` | The deployer calls `transfer_admin(multisig_account)` on options_market and price_oracle. | -| 1 → 2 | `stage2_admin_to_timelock.sh` | The multisig calls `transfer_admin(timelock)`. First confirm that the timelock's proposer and guardian are the multisig account. | +| 1 → 2 | `stage2_admin_to_timelock.sh` | The multisig calls `transfer_admin(timelock)`. First confirm that the timelock's proposer and canceller are the multisig account. | | 2 → 3 | `stage3_proposer_to_governor.sh` | The multisig schedules a timelock self-call `grant_role(Proposer, governor)`, then anyone executes it after `min_delay`. | | 3 → 4 | `stage4_lock_in.sh` | A governor proposal calls the timelock's `revoke_role(Proposer, multisig)` and `revoke_role(Canceller, multisig)`. | diff --git a/governor/src/interfaces.rs b/governor/src/interfaces.rs index b7d763b..3f62d1d 100644 --- a/governor/src/interfaces.rs +++ b/governor/src/interfaces.rs @@ -16,7 +16,7 @@ pub trait Votes { /// /// The timelock derives an operation's id from the batch it carries, the /// predecessor it waits on and a salt, so the governor cannot name an -/// operation directly: it queues the proposal under the proposal's own id as +/// operation directly: it queues a proposal under the proposal's own id as /// the salt and derives the operation id from the same inputs. #[allow(dead_code)] #[contractclient(name = "TimelockClient")] @@ -27,7 +27,6 @@ pub trait Timelock { targets: Vec
, fns: Vec, args: Vec>, - predecessor: Option>, salt: BytesN<32>, delay: u64, ) -> BytesN<32>; diff --git a/governor/src/lib.rs b/governor/src/lib.rs index 3171bc5..1a75fc7 100644 --- a/governor/src/lib.rs +++ b/governor/src/lib.rs @@ -287,7 +287,6 @@ impl Governor { &p.targets, &p.fns, &p.args, - &None, &id, &delay, ); diff --git a/governor/src/test.rs b/governor/src/test.rs index 5258e43..9527425 100644 --- a/governor/src/test.rs +++ b/governor/src/test.rs @@ -194,10 +194,11 @@ fn full_lifecycle_through_timelock() { assert_eq!(eta, h.env.ledger().timestamp() + TL_DELAY); assert_eq!(h.gov.state(&id), ProposalState::Queued); let (t, f, a) = h.quorum_action(500); - assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); // delay not elapsed + let op = h.tl.hash_operation(&t, &f, &a, &None, &id); + assert!(h.tl.try_execute(&h.alice, &op).is_err()); // delay not elapsed h.warp(TL_DELAY); - h.tl.execute(&h.alice, &t, &f, &a, &None, &id); + h.tl.execute(&h.alice, &op); assert_eq!(h.gov.state(&id), ProposalState::Executed); assert_eq!(h.gov.quorum_bps(), 500); } @@ -325,8 +326,7 @@ fn anyone_cancels_when_proposer_drops_below_threshold() { let op = h.tl_op_id(&id, 500); assert!(h.tl.get_operation(&op).is_none()); h.warp(TL_DELAY); - let (t, f, a) = h.quorum_action(500); - assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); + assert!(h.tl.try_execute(&h.alice, &op).is_err()); } #[test] @@ -336,8 +336,8 @@ fn queued_proposal_expires() { h.gov.queue(&id); h.warp(TL_DELAY + TL_GRACE + 1); assert_eq!(h.gov.state(&id), ProposalState::Expired); - let (t, f, a) = h.quorum_action(500); - assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); + let op = h.tl_op_id(&id, 500); + assert!(h.tl.try_execute(&h.alice, &op).is_err()); } #[test] @@ -348,8 +348,7 @@ fn guardian_can_veto_queued_proposal() { let op = h.tl_op_id(&id, 500); h.tl.cancel(&h.guardian, &op); h.warp(TL_DELAY); - let (t, f, a) = h.quorum_action(500); - assert!(h.tl.try_execute(&h.alice, &t, &f, &a, &None, &id).is_err()); + assert!(h.tl.try_execute(&h.alice, &op).is_err()); } #[test] diff --git a/integration_tests/tests/handover.rs b/integration_tests/tests/handover.rs index e650934..a159735 100644 --- a/integration_tests/tests/handover.rs +++ b/integration_tests/tests/handover.rs @@ -29,6 +29,9 @@ const TL_GRACE: u64 = 7 * 86_400; const VOTING_DELAY: u32 = 10; const VOTING_PERIOD: u32 = 720; +/// A batch of calls: the target of each, the function, and the arguments. +type Calls = (Vec
, Vec, Vec>); + // ── Minimal checkpointed voting token (stand-in for gov_token) ────────────── #[contracttype] @@ -134,9 +137,9 @@ fn deploy<'a>() -> World<'a> { let gov_id = env.register_contract(None, Governor); let tl = TimelockClient::new(&env, &env.register_contract(None, Timelock)); - // The multisig proposes and vetoes, and the governor may veto too; an - // empty executor set means anyone may execute a ready operation. The - // governor only becomes a proposer at stage 3. + // The multisig proposes and vetoes; the governor may veto too but does + // not propose until stage 3. An empty executor set means anyone may + // execute a ready operation. tl.initialize( &TL_DELAY, &TL_GRACE, @@ -174,9 +177,6 @@ fn deploy<'a>() -> World<'a> { w } -/// A batch of calls: the target of each, the function, and the arguments. -type Calls = (Vec
, Vec, Vec>); - impl World<'_> { fn roll(&self, ledgers: u32) { let seq = self.env.ledger().sequence(); @@ -273,24 +273,18 @@ impl World<'_> { t.clone().into_val(e), f.clone().into_val(e), a.clone().into_val(e), - None::>.into_val(e), salt.clone().into_val(e), TL_DELAY.into_val(e), ] } /// The arguments of an `execute` call, as the host sees them. - fn execute_args(&self, calls: &Calls, salt: &BytesN<32>) -> Vec { + fn execute_args(&self, id: &BytesN<32>) -> Vec { let e = &self.env; - let (t, f, a) = calls; vec![ e, self.ms_account.clone().into_val(e), - t.clone().into_val(e), - f.clone().into_val(e), - a.clone().into_val(e), - None::>.into_val(e), - salt.clone().into_val(e), + id.clone().into_val(e), ] } @@ -304,19 +298,25 @@ impl World<'_> { self.schedule_args(&(t.clone(), f.clone(), a.clone()), salt), ); self.tl - .schedule(&self.ms_account, &t, &f, &a, &None, salt, &TL_DELAY) + .schedule(&self.ms_account, &t, &f, &a, salt, &TL_DELAY) } - fn execute_after_delay(&self, calls: &Calls, salt: &BytesN<32>) { + /// Executes a ready operation once its delay has elapsed. + fn execute_after_delay(&self, id: &BytesN<32>) { self.warp(TL_DELAY); - let (t, f, a) = calls; self.sign( &self.ms_account, &self.tl.address, "execute", - self.execute_args(calls, salt), + self.execute_args(id), ); - self.tl.execute(&self.ms_account, &t, &f, &a, &None, salt); + self.tl.execute(&self.ms_account, id); + } + + /// The timelock operation a governor proposal queues. + fn gov_op_id(&self, id: &BytesN<32>, calls: &Calls) -> BytesN<32> { + let (t, f, a) = calls; + self.tl.hash_operation(&t, &f, &a, &None, id) } /// Runs a full governor proposal through to timelock execution. @@ -335,7 +335,8 @@ impl World<'_> { self.gov.queue(&id); // The governor queues its proposal under the proposal's own id as the // salt, so the timelock's operation id is derived from it. - self.execute_after_delay(&(t.clone(), f.clone(), a.clone()), &id); + let op = self.gov_op_id(&id, &(t.clone(), f.clone(), a.clone())); + self.execute_after_delay(&op); assert_eq!(self.gov.state(&id), ProposalState::Executed); id } @@ -359,17 +360,10 @@ impl World<'_> { let calls: Calls = ( vec![e, self.tl.address.clone()], vec![e, Symbol::new(e, "grant_role")], - vec![ - e, - vec![ - e, - Role::Proposer.into_val(e), - self.gov.address.clone().into_val(e), - ], - ], + vec![e, vec![e, Role::Proposer.into_val(e), self.gov.address.clone().into_val(e)]], ); - self.ms_schedule(calls.clone(), &salt); - self.execute_after_delay(&calls, &salt); + let id = self.ms_schedule(calls, &salt); + self.execute_after_delay(&id); assert!(self.tl.has_role(&Role::Proposer, &self.gov.address)); } @@ -380,23 +374,10 @@ impl World<'_> { let e = &self.env; self.govern(( vec![e, self.tl.address.clone(), self.tl.address.clone()], - vec![ - e, - Symbol::new(e, "revoke_role"), - Symbol::new(e, "revoke_role"), - ], - vec![ - e, - vec![ - e, - Role::Proposer.into_val(e), - self.ms_account.clone().into_val(e), - ], - vec![ - e, - Role::Canceller.into_val(e), - self.ms_account.clone().into_val(e), - ], + vec![e, Symbol::new(e, "revoke_role"), Symbol::new(e, "revoke_role")], + vec![e, + vec![e, Role::Proposer.into_val(e), self.ms_account.clone().into_val(e)], + vec![e, Role::Canceller.into_val(e), self.ms_account.clone().into_val(e)], ], )); } @@ -433,8 +414,8 @@ fn staged_handover_end_to_end() { vec![e, Symbol::new(e, "set_fee_rate")], vec![e, vec![e, 120u32.into_val(e)]], ); - w.ms_schedule(calls.clone(), &salt); - w.execute_after_delay(&calls, &salt); + let id = w.ms_schedule(calls, &salt); + w.execute_after_delay(&id); assert_eq!(w.market.get_fee_rate(), 120); w.assert_vault_invariant(); @@ -480,8 +461,8 @@ fn rollback_stage2_multisig_reclaims_admin_through_timelock() { w.to_stage2(); let salt = w.salt(); let calls = w.transfer_both_calls(&w.ms_account); - w.ms_schedule(calls.clone(), &salt); - w.execute_after_delay(&calls, &salt); + let id = w.ms_schedule(calls, &salt); + w.execute_after_delay(&id); assert!(w.admin_can_act(&w.ms_account)); w.assert_vault_invariant(); } @@ -493,7 +474,7 @@ fn rollback_stage2_canceller_cancels_bad_handover() { let attacker = Address::generate(&w.env); let salt = w.salt(); let calls = w.transfer_both_calls(&attacker); - let id = w.ms_schedule(calls.clone(), &salt); + let id = w.ms_schedule(calls, &salt); w.sign( &w.ms_account, &w.tl.address, @@ -502,11 +483,7 @@ fn rollback_stage2_canceller_cancels_bad_handover() { ); w.tl.cancel(&w.ms_account, &id); w.warp(TL_DELAY); - let (t, f, a) = calls; - assert!(w - .tl - .try_execute(&w.ms_account, &t, &f, &a, &None, &salt) - .is_err()); + assert!(w.tl.try_execute(&w.ms_account, &id).is_err()); assert_eq!(w.market.get_admin(), w.tl.address); } @@ -517,7 +494,8 @@ fn rollback_stage3_canceller_vetoes_a_governor_proposal() { // A malicious governor proposal gets queued … let attacker = Address::generate(&w.env); - let (t, f, a) = w.transfer_both_calls(&attacker); + let calls = w.transfer_both_calls(&attacker); + let (t, f, a) = calls.clone(); w.env.mock_all_auths(); let id = w.gov.propose( &w.voter, @@ -533,7 +511,7 @@ fn rollback_stage3_canceller_vetoes_a_governor_proposal() { w.gov.queue(&id); // … and the multisig vetoes it. - let op = w.tl.hash_operation(&t, &f, &a, &None, &id); + let op = w.gov_op_id(&id, &calls); w.sign( &w.ms_account, &w.tl.address, @@ -542,18 +520,15 @@ fn rollback_stage3_canceller_vetoes_a_governor_proposal() { ); w.tl.cancel(&w.ms_account, &op); w.warp(TL_DELAY); - assert!(w - .tl - .try_execute(&w.ms_account, &t, &f, &a, &None, &id) - .is_err()); + assert!(w.tl.try_execute(&w.ms_account, &op).is_err()); assert_eq!(w.market.get_admin(), w.tl.address); // The multisig kept the proposer role until lock-in, so it can still // schedule the corrective action. let salt = w.salt(); let fix = w.transfer_both_calls(&w.ms_account); - w.ms_schedule(fix.clone(), &salt); - w.execute_after_delay(&fix, &salt); + let fix_id = w.ms_schedule(fix, &salt); + w.execute_after_delay(&fix_id); assert!(w.admin_can_act(&w.ms_account)); } diff --git a/scripts/governance/common.sh b/scripts/governance/common.sh index 1c1c5e9..7ff4215 100644 --- a/scripts/governance/common.sh +++ b/scripts/governance/common.sh @@ -67,24 +67,32 @@ show_admins() { # targets_json fns_json args_json salt_hex. # # There is no operation id to pass in: the timelock derives it from the -# batch, the predecessor and the salt, and `schedule` returns it. +# batch and the salt, and `schedule` returns it. tl_schedule() { local proposer="$1" targets="$2" fns="$3" args="$4" salt="$5" invoke "$TIMELOCK_ID" "$proposer" schedule \ --targets "$targets" --fns "$fns" --args "$args" \ - --predecessor null --salt "$salt" \ + --salt "$salt" \ --delay "${TL_DELAY:?TL_DELAY must be set}" } -# Executes the batch a `tl_schedule` queued. The salt has to be the same -# one it was scheduled with: the id is derived from it. -tl_execute() { - local executor="$1" targets="$2" fns="$3" args="$4" salt="$5" - invoke "$TIMELOCK_ID" "$executor" execute \ +# The id of the operation a batch and a salt describe. The timelock derives +# it the same way, so this is what `execute` takes. +tl_op_id() { + local targets="$1" fns="$2" args="$3" salt="$4" + view "$TIMELOCK_ID" hash_operation \ --targets "$targets" --fns "$fns" --args "$args" \ --predecessor null --salt "$salt" } +# Executes a ready operation. The id is the one `tl_schedule` returned, or +# the one `tl_op_id` computes. +tl_execute() { + local executor="$1" id="$2" + invoke "$TIMELOCK_ID" "$executor" execute \ + --executor "$executor" --id "$id" +} + # 32-byte hex salt derived from a label, so reruns are deterministic. The # salt is what makes two otherwise identical batches distinct operations. salt() { diff --git a/scripts/governance/stage2_rollback.sh b/scripts/governance/stage2_rollback.sh index 655ccbf..353c6e4 100644 --- a/scripts/governance/stage2_rollback.sh +++ b/scripts/governance/stage2_rollback.sh @@ -13,8 +13,8 @@ case "$PHASE" in "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" "$SALT" ;; execute) - tl_execute "$MULTISIG_ACCOUNT" \ - "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" "$SALT" + tl_execute "$MULTISIG_ACCOUNT" "$(tl_op_id \ + "[\"$MARKET_ID\",\"$ORACLE_ID\"]" '["transfer_admin","transfer_admin"]' "[$ARG,$ARG]" "$SALT")" show_admins ;; esac diff --git a/scripts/governance/stage3_proposer_to_governor.sh b/scripts/governance/stage3_proposer_to_governor.sh index 35dd5fa..0be7408 100644 --- a/scripts/governance/stage3_proposer_to_governor.sh +++ b/scripts/governance/stage3_proposer_to_governor.sh @@ -15,7 +15,7 @@ case "$PHASE" in tl_schedule "$MULTISIG_ACCOUNT" "$TARGETS" "$FNS" "$ARGS" "$SALT" ;; execute) - tl_execute "$MULTISIG_ACCOUNT" "$TARGETS" "$FNS" "$ARGS" "$SALT" + tl_execute "$MULTISIG_ACCOUNT" "$(tl_op_id "$TARGETS" "$FNS" "$ARGS" "$SALT")" echo "timelock governor is proposer: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$GOVERNOR_ID") (expect true)" ;; esac diff --git a/scripts/governance/stage4_lock_in.sh b/scripts/governance/stage4_lock_in.sh index 41bd5a4..dac7271 100644 --- a/scripts/governance/stage4_lock_in.sh +++ b/scripts/governance/stage4_lock_in.sh @@ -26,7 +26,7 @@ case "$PHASE" in ;; execute) require PROPOSAL_ID EXECUTOR - tl_execute "$EXECUTOR" "$TARGETS" "$FNS" "$ARGS" "$PROPOSAL_ID" + tl_execute "$EXECUTOR" "$(tl_op_id "$TARGETS" "$FNS" "$ARGS" "$PROPOSAL_ID")" echo "locked in: $(view "$TIMELOCK_ID" has_role --role "$(role proposer)" --account "$MULTISIG_ACCOUNT") (expect false)" ;; esac diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index 3645a88..1ec7eb8 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -10,8 +10,8 @@ //! Three roles, and no admin: //! //! Proposer may schedule an operation. -//! Executor may execute a ready operation (anyone may, if the executor -//! set is empty — see `is_open_executor`). +//! Executor may execute a ready operation. An empty executor set means +//! anyone may — see `is_open_executor`. //! Canceller may cancel a pending operation. //! //! There is deliberately no fourth role and no owner: the timelock's own @@ -20,20 +20,20 @@ //! re-entering itself, so `execute` recognises those calls and applies them //! in place instead of cross-calling. //! -//! An operation is a batch of calls plus a predecessor and a salt. Its id is -//! `sha256(xdr((targets, fns, args, predecessor, salt)))` — it commits to -//! exactly what will run, and the salt is what makes two otherwise identical -//! batches distinct. `execute` recomputes the id from the batch it is handed, -//! so it can only ever run the batch that was scheduled. +//! An operation is a batch of calls, a salt that makes two otherwise +//! identical batches distinct, and — for `schedule_after` — a predecessor. +//! Its id is `sha256` over the XDR of exactly those things, so the id +//! commits to what will run and an operation can never be swapped for +//! another. //! //! A predecessor orders operations against each other: an operation whose //! predecessor has not executed cannot execute. Cancelling a predecessor -//! therefore leaves its dependants permanently unexecutable, which is the -//! point: the batch that was going to run second never runs at all. +//! therefore strands its dependants for good, which is the point: the batch +//! that was going to run second never runs at all. use soroban_sdk::{ - contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, TryFromVal, - Val, Vec, + contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, + TryFromVal, Val, Vec, }; #[cfg(test)] @@ -47,7 +47,7 @@ mod types; use error::Error; pub use migrations::CURRENT_SCHEMA_VERSION; use types::DataKey; -pub use types::{Operation, OperationSpec, Role}; +pub use types::{Operation, Role}; /// The shortest delay the timelock will ever accept, in seconds. /// @@ -63,6 +63,28 @@ pub struct Timelock; // ── internals ──────────────────────────────────────────────────────────────── +/// The zero id, which stands for "no predecessor" in the stored form. +fn no_predecessor(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0u8; 32]) +} + +/// The stored form of a predecessor: the id, or the zero id for none. +fn predecessor_id(env: &Env, predecessor: Option>) -> BytesN<32> { + match predecessor { + Some(id) => id, + None => no_predecessor(env), + } +} + +/// The predecessor of a stored operation, as the caller sees it. +fn predecessor_of(env: &Env, op: &Operation) -> Option> { + if op.predecessor == no_predecessor(env) { + None + } else { + Some(op.predecessor.clone()) + } +} + fn min_delay(env: &Env) -> u64 { env.storage() .instance() @@ -99,23 +121,8 @@ fn set_role(env: &Env, role: Role, account: &Address, granted: bool) { events::role_changed(env, role, account.clone(), granted); } -/// The zero id, which stands for "no predecessor" in the stored form. -fn no_predecessor(env: &Env) -> BytesN<32> { - BytesN::from_array(env, &[0u8; 32]) -} - -/// The stored form of a predecessor: the id, or the zero id for none. -fn predecessor_id(env: &Env, predecessor: Option>) -> BytesN<32> { - match predecessor { - Some(id) => id, - None => no_predecessor(env), - } -} - fn load(env: &Env, id: &BytesN<32>) -> Option { - env.storage() - .persistent() - .get(&DataKey::Operation(id.clone())) + env.storage().persistent().get(&DataKey::Operation(id.clone())) } /// The `i`th argument of a self-call, decoded. @@ -216,90 +223,58 @@ impl Timelock { /// `now + delay`. Proposer-only, and `delay` may not be shorter than /// `min_delay`. /// - /// Returns the operation id, which is derived from the batch itself: the - /// same batch with the same predecessor and the same salt is the same - /// operation, and scheduling it twice is refused. + /// Returns the operation id, which is derived from the batch and the + /// salt: the same batch with the same salt is the same operation, and + /// scheduling it twice is refused. pub fn schedule( env: Env, proposer: Address, targets: Vec
, fns: Vec, args: Vec>, - predecessor: Option>, salt: BytesN<32>, delay: u64, ) -> BytesN<32> { - migrations::require_current(&env); - proposer.require_auth(); - require_role(&env, Role::Proposer, &proposer); - - let n = targets.len(); - if n == 0 || n != fns.len() || n != args.len() { - panic_with_error!(&env, Error::EmptyOperation); - } - if delay < Self::get_min_delay(env.clone()) { - panic_with_error!(&env, Error::InsufficientDelay); - } - - let id = Self::hash_operation( - env.clone(), - targets.clone(), - fns.clone(), - args.clone(), - predecessor.clone(), - salt.clone(), - ); - let key = DataKey::Operation(id.clone()); - if env.storage().persistent().has(&key) { - panic_with_error!(&env, Error::AlreadyScheduled); - } - - let ready_at = env.ledger().timestamp() + delay; - env.storage().persistent().set( - &key, - &Operation { - targets, - fns, - args, - predecessor: predecessor_id(&env, predecessor), - salt, - ready_at, - done: false, - }, - ); - events::scheduled(&env, id.clone(), proposer, ready_at); - id + Self::schedule_inner(&env, &proposer, targets, fns, args, None, salt, delay) } - /// Executes a ready operation. The batch, the predecessor and the salt - /// are passed again because the id is derived from them: only the batch - /// that was scheduled can be executed. + /// Schedules a batch that may not execute until `predecessor` has. /// - /// Every call runs, in order. A call that reverts reverts the whole - /// operation — there is no partial application. - pub fn execute( + /// The delay is the timelock's minimum: an operation that waits for + /// another one has already waited, and the wait it adds is the point. + pub fn schedule_after( env: Env, - executor: Address, + proposer: Address, targets: Vec
, fns: Vec, args: Vec>, - predecessor: Option>, + predecessor: BytesN<32>, salt: BytesN<32>, - ) { + ) -> BytesN<32> { + let delay = Self::get_min_delay(env.clone()); + Self::schedule_inner( + &env, + &proposer, + targets, + fns, + args, + Some(predecessor), + salt, + delay, + ) + } + + /// Executes a ready operation. + /// + /// Every call runs, in order. A call that reverts reverts the whole + /// operation — there is no partial application. + pub fn execute(env: Env, executor: Address, id: BytesN<32>) { migrations::require_current(&env); executor.require_auth(); if !Self::is_open_executor(env.clone()) { require_role(&env, Role::Executor, &executor); } - let id = Self::hash_operation( - env.clone(), - targets, - fns, - args, - predecessor.clone(), - salt.clone(), - ); let key = DataKey::Operation(id.clone()); let mut op: Operation = load(&env, &id).unwrap_or_else(|| panic_with_error!(&env, Error::NotPending)); @@ -318,8 +293,7 @@ impl Timelock { // The predecessor must have executed. A cancelled predecessor is // gone from storage, so it reads as "not done" here: the dependant // stays unexecutable rather than running out of order. - let pred = op.predecessor.clone(); - if pred != no_predecessor(&env) { + if let Some(pred) = predecessor_of(&env, &op) { let done = load(&env, &pred).is_some_and(|p| p.done); if !done { panic_with_error!(&env, Error::PredecessorNotDone); @@ -428,8 +402,8 @@ impl Timelock { /// The id of the operation a batch, a predecessor and a salt describe. /// - /// The same formula `schedule` and `execute` use, exposed so an - /// integrator can compute an id off-chain or in another contract. + /// The same formula `schedule` uses, exposed so an integrator can + /// compute an id off-chain or in another contract. pub fn hash_operation( env: Env, targets: Vec
, @@ -445,6 +419,62 @@ impl Timelock { predecessor: predecessor_id(&env, predecessor), salt, }; - env.crypto().sha256(&spec.clone().to_xdr(&env)).into() + env.crypto().sha256(&spec.to_xdr(&env)).into() + } +} + +/// The shared body of `schedule` and `schedule_after`. +impl Timelock { + #[allow(clippy::too_many_arguments)] + fn schedule_inner( + env: &Env, + proposer: &Address, + targets: Vec
, + fns: Vec, + args: Vec>, + predecessor: Option>, + salt: BytesN<32>, + delay: u64, + ) -> BytesN<32> { + migrations::require_current(env); + proposer.require_auth(); + require_role(env, Role::Proposer, proposer); + + let n = targets.len(); + if n == 0 || n != fns.len() || n != args.len() { + panic_with_error!(env, Error::EmptyOperation); + } + if delay < min_delay(env) { + panic_with_error!(env, Error::InsufficientDelay); + } + + let id = Self::hash_operation( + env.clone(), + targets.clone(), + fns.clone(), + args.clone(), + predecessor.clone(), + salt.clone(), + ); + let key = DataKey::Operation(id.clone()); + if env.storage().persistent().has(&key) { + panic_with_error!(env, Error::AlreadyScheduled); + } + + let ready_at = env.ledger().timestamp() + delay; + env.storage().persistent().set( + &key, + &Operation { + targets, + fns, + args, + predecessor: predecessor_id(env, predecessor), + salt, + ready_at, + done: false, + }, + ); + events::scheduled(env, id.clone(), proposer.clone(), ready_at); + id } } diff --git a/timelock/src/test.rs b/timelock/src/test.rs index a372450..59175fb 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -3,7 +3,7 @@ use crate::{Role, Timelock, TimelockClient}; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, + testutils::{Address as _, Ledger as _}, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, }; @@ -73,43 +73,40 @@ impl Harness<'_> { BytesN::from_array(&self.env, &[n; 32]) } - fn target_targets(&self) -> Vec
{ + fn targets(&self) -> Vec
{ vec![&self.env, self.target.address.clone()] } - fn set_value_fns(&self) -> Vec { + fn fns(&self) -> Vec { vec![&self.env, Symbol::new(&self.env, "set_value")] } - fn set_value_args(&self, value: u32) -> Vec> { + fn args(&self, value: u32) -> Vec> { vec![&self.env, vec![&self.env, value.into_val(&self.env)]] } - /// The arguments of the `execute` call, as the host sees them. - fn execute_args(&self, n: u8, value: u32) -> Vec { - vec![ - &self.env, - self.executor.clone().into_val(&self.env), - self.target_targets().into_val(&self.env), - self.set_value_fns().into_val(&self.env), - self.set_value_args(value).into_val(&self.env), - None::>.into_val(&self.env), - self.salt(n).into_val(&self.env), - ] - } - fn schedule_set_value(&self, n: u8, value: u32) -> BytesN<32> { self.client.schedule( &self.proposer, - &self.target_targets(), - &self.set_value_fns(), - &self.set_value_args(value), - &None, + &self.targets(), + &self.fns(), + &self.args(value), &self.salt(n), &DELAY, ) } + fn schedule_set_value_after(&self, n: u8, value: u32, predecessor: &BytesN<32>) -> BytesN<32> { + self.client.schedule_after( + &self.proposer, + &self.targets(), + &self.fns(), + &self.args(value), + predecessor, + &self.salt(n), + ) + } + /// Schedules a call whose target is the timelock itself. fn schedule_self(&self, n: u8, func: &str, args: Vec) -> BytesN<32> { self.client.schedule( @@ -117,23 +114,11 @@ impl Harness<'_> { &vec![&self.env, self.client.address.clone()], &vec![&self.env, Symbol::new(&self.env, func)], &vec![&self.env, args], - &None, &self.salt(n), &DELAY, ) } - fn execute_set_value(&self, n: u8, value: u32) { - self.client.execute( - &self.executor, - &self.target_targets(), - &self.set_value_fns(), - &self.set_value_args(value), - &None, - &self.salt(n), - ); - } - fn warp(&self, dt: u64) { let t = self.env.ledger().timestamp(); self.env.ledger().set_timestamp(t + dt); @@ -171,18 +156,13 @@ fn initialize_refuses_a_delay_below_the_documented_floor() { #[test] fn an_empty_executor_set_means_anyone_may_execute() { - let h = setup(); - assert!(!h.client.is_open_executor()); - let e = &h.env; + let e = Env::default(); + e.mock_all_auths(); let client = TimelockClient::new(&e, &e.register_contract(None, Timelock)); - client.initialize( - &DELAY, - &GRACE, - &vec![&e, h.proposer.clone()], - &vec![&e], - &vec![&e], - ); + let a = Address::generate(&e); + client.initialize(&DELAY, &GRACE, &vec![&e, a.clone()], &vec![&e], &vec![&e]); assert!(client.is_open_executor()); + assert!(client.has_role(&Role::Proposer, &a)); } // ── role gating ────────────────────────────────────────────────────────────── @@ -194,10 +174,9 @@ fn only_a_proposer_may_schedule() { .client .try_schedule( &h.outsider, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(1), - &None, + &h.targets(), + &h.fns(), + &h.args(1), &h.salt(1), &DELAY, ) @@ -206,10 +185,9 @@ fn only_a_proposer_may_schedule() { .client .try_schedule( &h.canceller, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(1), - &None, + &h.targets(), + &h.fns(), + &h.args(1), &h.salt(1), &DELAY, ) @@ -221,17 +199,7 @@ fn only_an_executor_may_execute() { let h = setup(); let id = h.schedule_set_value(1, 42); h.warp(DELAY); - assert!(h - .client - .try_execute( - &h.outsider, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.outsider, &id).is_err()); // The operation is still pending: the refused attempt changed nothing. assert!(h.client.is_operation_pending(&id)); } @@ -256,10 +224,9 @@ fn a_delay_shorter_than_min_delay_is_refused() { .client .try_schedule( &h.proposer, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(1), - &None, + &h.targets(), + &h.fns(), + &h.args(1), &h.salt(1), &(DELAY - 1), ) @@ -274,24 +241,23 @@ fn execution_is_refused_just_before_the_delay_and_allowed_exactly_at_it() { h.warp(DELAY - 1); assert!(!h.client.is_operation_ready(&id)); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &id).is_err()); h.warp(1); assert!(h.client.is_operation_ready(&id)); - h.execute_set_value(1, 42); + h.client.execute(&h.executor, &id); assert_eq!(h.target.value(), 42); } +#[test] +fn an_operation_expires_after_its_grace_period() { + let h = setup(); + let id = h.schedule_set_value(1, 42); + h.warp(DELAY + GRACE + 1); + assert!(h.client.try_execute(&h.executor, &id).is_err()); + assert_eq!(h.target.value(), 0); +} + // ── lifecycle ──────────────────────────────────────────────────────────────── #[test] @@ -299,19 +265,9 @@ fn an_operation_cannot_execute_twice() { let h = setup(); h.schedule_set_value(1, 42); h.warp(DELAY); - h.execute_set_value(1, 42); + h.client.execute(&h.executor, &h.salt(1)); assert!(h.client.is_operation_done(&h.salt(1))); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &h.salt(1)).is_err()); } #[test] @@ -320,17 +276,7 @@ fn cancelling_before_execution_stops_it() { let id = h.schedule_set_value(1, 42); h.client.cancel(&h.canceller, &id); h.warp(DELAY); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &id).is_err()); assert_eq!(h.target.value(), 0); } @@ -339,17 +285,7 @@ fn executing_after_a_cancel_is_refused() { let h = setup(); let id = h.schedule_set_value(1, 42); h.client.cancel(&h.canceller, &id); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &id).is_err()); } #[test] @@ -357,7 +293,7 @@ fn cancelling_an_already_executed_operation_is_refused() { let h = setup(); h.schedule_set_value(1, 42); h.warp(DELAY); - h.execute_set_value(1, 42); + h.client.execute(&h.executor, &h.salt(1)); assert!(h.client.try_cancel(&h.canceller, &h.salt(1)).is_err()); } @@ -375,10 +311,9 @@ fn scheduling_the_same_batch_twice_is_refused() { .client .try_schedule( &h.proposer, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, + &h.targets(), + &h.fns(), + &h.args(42), &h.salt(1), &DELAY, ) @@ -396,7 +331,6 @@ fn an_empty_batch_is_refused() { &vec![e], &vec![e], &vec![e], - &None, &h.salt(1), &DELAY, ) @@ -409,41 +343,16 @@ fn an_empty_batch_is_refused() { fn a_dependant_waits_for_its_predecessor() { let h = setup(); let first = h.schedule_set_value(1, 11); - let second = h.client.schedule( - &h.proposer, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(22), - &Some(first.clone()), - &h.salt(2), - &DELAY, - ); + let second = h.schedule_set_value_after(2, 22, &first); h.warp(DELAY); // The dependant is ready in time, but its predecessor has not run. assert!(h.client.is_operation_ready(&second)); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(22), - &Some(first.clone()), - &h.salt(2), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &second).is_err()); assert_eq!(h.target.value(), 0); - h.execute_set_value(1, 11); - h.client.execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(22), - &Some(first.clone()), - &h.salt(2), - ); + h.client.execute(&h.executor, &first); + h.client.execute(&h.executor, &second); assert_eq!(h.target.value(), 22); } @@ -451,28 +360,10 @@ fn a_dependant_waits_for_its_predecessor() { fn cancelling_a_predecessor_strands_its_dependant() { let h = setup(); let first = h.schedule_set_value(1, 11); - h.client.schedule( - &h.proposer, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(22), - &Some(first.clone()), - &h.salt(2), - &DELAY, - ); + let second = h.schedule_set_value_after(2, 22, &first); h.client.cancel(&h.canceller, &first); h.warp(DELAY); - assert!(h - .client - .try_execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(22), - &Some(first.clone()), - &h.salt(2), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &second).is_err()); assert_eq!(h.target.value(), 0); } @@ -481,66 +372,45 @@ fn cancelling_a_predecessor_strands_its_dependant() { #[test] fn self_calls_reconfigure_the_timelock() { let h = setup(); - let new_proposer = Address::generate(&h.env); let e = &h.env; + let new_proposer = Address::generate(&e); - let delay_args = vec![&e, (2 * DELAY).into_val(&e)]; - let grant_args = vec![ - &e, - Role::Proposer.into_val(&e), - new_proposer.clone().into_val(&e), - ]; - let revoke_args = vec![ - &e, - Role::Proposer.into_val(&e), - h.proposer.clone().into_val(&e), - ]; - let open_args = vec![&e, true.into_val(&e)]; - - let delay_op = h.schedule_self(1, "update_delay", delay_args.clone()); - let grant_op = h.schedule_self(2, "grant_role", grant_args.clone()); - let revoke_op = h.schedule_self(3, "revoke_role", revoke_args.clone()); - let open_op = h.schedule_self(4, "set_open_executor", open_args.clone()); + let delay_op = h.schedule_self(1, "update_delay", vec![&e, (2 * DELAY).into_val(&e)]); + let grant_op = h.schedule_self( + 2, + "grant_role", + vec![ + &e, + Role::Proposer.into_val(&e), + new_proposer.clone().into_val(&e), + ], + ); + let revoke_op = h.schedule_self( + 3, + "revoke_role", + vec![ + &e, + Role::Proposer.into_val(&e), + h.proposer.clone().into_val(&e), + ], + ); + let open_op = h.schedule_self(4, "set_open_executor", vec![&e, true.into_val(&e)]); h.warp(DELAY); + h.client.execute( &h.executor, - &vec![&e, h.client.address.clone()], - &vec![&e, Symbol::new(&e, "update_delay")], - &vec![&e, delay_args.clone()], - &None, - &h.salt(1), + &delay_op, ); assert_eq!(h.client.get_min_delay(), 2 * DELAY); - h.client.execute( - &h.executor, - &vec![&e, h.client.address.clone()], - &vec![&e, Symbol::new(&e, "grant_role")], - &vec![&e, grant_args.clone()], - &None, - &h.salt(2), - ); + h.client.execute(&h.executor, &grant_op); assert!(h.client.has_role(&Role::Proposer, &new_proposer)); - h.client.execute( - &h.executor, - &vec![&e, h.client.address.clone()], - &vec![&e, Symbol::new(&e, "revoke_role")], - &vec![&e, revoke_args.clone()], - &None, - &h.salt(3), - ); + h.client.execute(&h.executor, &revoke_op); assert!(!h.client.has_role(&Role::Proposer, &h.proposer)); - h.client.execute( - &h.executor, - &vec![&e, h.client.address.clone()], - &vec![&e, Symbol::new(&e, "set_open_executor")], - &vec![&e, open_args.clone()], - &None, - &h.salt(4), - ); + h.client.execute(&h.executor, &open_op); assert!(h.client.is_open_executor()); // Every one of those operations ran. @@ -554,19 +424,9 @@ fn self_calls_reconfigure_the_timelock() { fn a_self_call_the_timelock_does_not_implement_is_refused() { let h = setup(); let e = &h.env; - h.schedule_self(1, "not_a_function", vec![&e]); + let op = h.schedule_self(1, "not_a_function", vec![&e]); h.warp(DELAY); - assert!(h - .client - .try_execute( - &h.executor, - &vec![&e, h.client.address.clone()], - &vec![&e, Symbol::new(&e, "not_a_function")], - &vec![&e, vec![&e]], - &None, - &h.salt(1), - ) - .is_err()); + assert!(h.client.try_execute(&h.executor, &op).is_err()); } // ── the id ─────────────────────────────────────────────────────────────────── @@ -575,38 +435,27 @@ fn a_self_call_the_timelock_does_not_implement_is_refused() { fn the_operation_id_commits_to_the_batch_the_predecessor_and_the_salt() { let h = setup(); let id = h.client.hash_operation( - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), + &h.targets(), + &h.fns(), + &h.args(42), &None, &h.salt(1), ); assert_eq!(id, h.schedule_set_value(1, 42)); - // A different payload or a different salt gives a different id. + // A different payload, predecessor or salt gives a different id. let other = h.client.hash_operation( - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(43), + &h.targets(), + &h.fns(), + &h.args(43), &None, &h.salt(1), ); assert_ne!(id, other); - let salted = h.client.hash_operation( - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(2), - ); + let salted = h.client.hash_operation(&h.targets(), &h.fns(), &h.args(42), &None, &h.salt(2)); assert_ne!(id, salted); - let preceded = h.client.hash_operation( - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &Some(h.salt(3)), - &h.salt(1), - ); + let preceded = + h.client.hash_operation(&h.targets(), &h.fns(), &h.args(42), &Some(h.salt(3)), &h.salt(1)); assert_ne!(id, preceded); } @@ -625,8 +474,13 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { assert_eq!(op.ready_at, DELAY); // The spec the id was derived from is the same one that was stored. assert_eq!( - h.client - .hash_operation(&op.targets, &op.fns, &op.args, &None, &op.salt,), + h.client.hash_operation( + &op.targets, + &op.fns, + &op.args, + &None, + &op.salt, + ), id ); } @@ -641,22 +495,19 @@ fn the_timelock_is_the_admin_of_the_contract_it_acts_on() { // Only the executor's own authorization is supplied. The target's admin // is the timelock, and the timelock is its direct invoker, so the // target's own admin check passes without a second signature. - h.env.mock_auths(&[MockAuth { + h.env.set_auths(&[MockAuth { address: &h.executor, invoke: &MockAuthInvoke { contract: &h.client.address, fn_name: "execute", - args: h.execute_args(1, 42), + args: vec![ + &h.env, + h.executor.clone().into_val(&h.env), + h.salt(1).into_val(&h.env), + ], sub_invokes: &[], }, }]); - h.client.execute( - &h.executor, - &h.target_targets(), - &h.set_value_fns(), - &h.set_value_args(42), - &None, - &h.salt(1), - ); + h.client.execute(&h.executor, &h.salt(1)); assert_eq!(h.target.value(), 42); } diff --git a/timelock/src/types.rs b/timelock/src/types.rs index bb15e47..949e1e5 100644 --- a/timelock/src/types.rs +++ b/timelock/src/types.rs @@ -2,8 +2,8 @@ use soroban_sdk::{contracttype, Address, BytesN, Symbol, Val, Vec}; /// What a participant in the timelock is allowed to do. /// -/// Three roles, and no admin: the timelock has no owner, so every change to -/// it — including these role sets — is itself a delayed operation. +/// Three roles, and no admin: the role sets are the whole configuration, and +/// the timelock's own settings change only through a delayed operation. #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u32)] @@ -31,10 +31,10 @@ pub struct Operation { /// or the zero id when there is none. /// /// `Option>` cannot be a `#[contracttype]` field — the SDK's - /// XDR conversion has no `From>` for it — so the stored form - /// is the id itself, with the zero id standing for "no predecessor". + /// XDR conversion has no `From>` for it — so the stored form is + /// the id itself, with the zero id standing for "no predecessor". pub predecessor: BytesN<32>, - /// The nonce that makes this operation's id unique. + /// The nonce that makes two otherwise identical batches distinct. pub salt: BytesN<32>, /// Ledger timestamp from which the operation may be executed. pub ready_at: u64, @@ -46,10 +46,10 @@ pub struct Operation { /// The preimage an operation id is derived from. /// /// The id commits to exactly what will run: the calls, the predecessor they -/// wait on, and the salt that makes two otherwise identical batches -/// distinct. The parts of an operation that change over its life — when it -/// became ready, whether it has run — are deliberately not part of it, so the -/// id can be computed before the operation is scheduled. +/// wait on, and the salt that makes two otherwise identical batches distinct. +/// The parts of an operation that change over its life — when it became +/// ready, whether it has run — are deliberately not part of it, so the id can +/// be computed before the operation is scheduled. #[contracttype] #[derive(Clone)] pub struct OperationSpec { From 62ebf454224a937ac835c5cbca3c1e8ef1cc30f1 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:26:37 +0100 Subject: [PATCH 12/17] fix(timelock): import the id preimage type and apply rustfmt --- integration_tests/tests/handover.rs | 30 ++++++++++++++++---- timelock/src/lib.rs | 10 ++++--- timelock/src/test.rs | 43 ++++++++++++----------------- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/integration_tests/tests/handover.rs b/integration_tests/tests/handover.rs index a159735..179b412 100644 --- a/integration_tests/tests/handover.rs +++ b/integration_tests/tests/handover.rs @@ -360,7 +360,14 @@ impl World<'_> { let calls: Calls = ( vec![e, self.tl.address.clone()], vec![e, Symbol::new(e, "grant_role")], - vec![e, vec![e, Role::Proposer.into_val(e), self.gov.address.clone().into_val(e)]], + vec![ + e, + vec![ + e, + Role::Proposer.into_val(e), + self.gov.address.clone().into_val(e), + ], + ], ); let id = self.ms_schedule(calls, &salt); self.execute_after_delay(&id); @@ -374,10 +381,23 @@ impl World<'_> { let e = &self.env; self.govern(( vec![e, self.tl.address.clone(), self.tl.address.clone()], - vec![e, Symbol::new(e, "revoke_role"), Symbol::new(e, "revoke_role")], - vec![e, - vec![e, Role::Proposer.into_val(e), self.ms_account.clone().into_val(e)], - vec![e, Role::Canceller.into_val(e), self.ms_account.clone().into_val(e)], + vec![ + e, + Symbol::new(e, "revoke_role"), + Symbol::new(e, "revoke_role"), + ], + vec![ + e, + vec![ + e, + Role::Proposer.into_val(e), + self.ms_account.clone().into_val(e), + ], + vec![ + e, + Role::Canceller.into_val(e), + self.ms_account.clone().into_val(e), + ], ], )); } diff --git a/timelock/src/lib.rs b/timelock/src/lib.rs index 1ec7eb8..fe56e04 100644 --- a/timelock/src/lib.rs +++ b/timelock/src/lib.rs @@ -32,8 +32,8 @@ //! that was going to run second never runs at all. use soroban_sdk::{ - contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, - TryFromVal, Val, Vec, + contract, contractimpl, panic_with_error, xdr::ToXdr, Address, BytesN, Env, Symbol, TryFromVal, + Val, Vec, }; #[cfg(test)] @@ -46,7 +46,7 @@ mod types; use error::Error; pub use migrations::CURRENT_SCHEMA_VERSION; -use types::DataKey; +use types::{DataKey, OperationSpec}; pub use types::{Operation, Role}; /// The shortest delay the timelock will ever accept, in seconds. @@ -122,7 +122,9 @@ fn set_role(env: &Env, role: Role, account: &Address, granted: bool) { } fn load(env: &Env, id: &BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey::Operation(id.clone())) + env.storage() + .persistent() + .get(&DataKey::Operation(id.clone())) } /// The `i`th argument of a self-call, decoded. diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 59175fb..357601c 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -398,10 +398,7 @@ fn self_calls_reconfigure_the_timelock() { h.warp(DELAY); - h.client.execute( - &h.executor, - &delay_op, - ); + h.client.execute(&h.executor, &delay_op); assert_eq!(h.client.get_min_delay(), 2 * DELAY); h.client.execute(&h.executor, &grant_op); @@ -434,28 +431,27 @@ fn a_self_call_the_timelock_does_not_implement_is_refused() { #[test] fn the_operation_id_commits_to_the_batch_the_predecessor_and_the_salt() { let h = setup(); - let id = h.client.hash_operation( - &h.targets(), - &h.fns(), - &h.args(42), - &None, - &h.salt(1), - ); + let id = h + .client + .hash_operation(&h.targets(), &h.fns(), &h.args(42), &None, &h.salt(1)); assert_eq!(id, h.schedule_set_value(1, 42)); // A different payload, predecessor or salt gives a different id. - let other = h.client.hash_operation( + let other = h + .client + .hash_operation(&h.targets(), &h.fns(), &h.args(43), &None, &h.salt(1)); + assert_ne!(id, other); + let salted = h + .client + .hash_operation(&h.targets(), &h.fns(), &h.args(42), &None, &h.salt(2)); + assert_ne!(id, salted); + let preceded = h.client.hash_operation( &h.targets(), &h.fns(), - &h.args(43), - &None, + &h.args(42), + &Some(h.salt(3)), &h.salt(1), ); - assert_ne!(id, other); - let salted = h.client.hash_operation(&h.targets(), &h.fns(), &h.args(42), &None, &h.salt(2)); - assert_ne!(id, salted); - let preceded = - h.client.hash_operation(&h.targets(), &h.fns(), &h.args(42), &Some(h.salt(3)), &h.salt(1)); assert_ne!(id, preceded); } @@ -474,13 +470,8 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { assert_eq!(op.ready_at, DELAY); // The spec the id was derived from is the same one that was stored. assert_eq!( - h.client.hash_operation( - &op.targets, - &op.fns, - &op.args, - &None, - &op.salt, - ), + h.client + .hash_operation(&op.targets, &op.fns, &op.args, &None, &op.salt,), id ); } From bf60ef66522e5eb1ab1474b8a8b7abe6009c9443 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:33:26 +0100 Subject: [PATCH 13/17] fix(timelock): borrow the harness env once, and import the mock-auth types --- timelock/src/test.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 357601c..1d34a6e 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -3,7 +3,7 @@ use crate::{Role, Timelock, TimelockClient}; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Ledger as _}, + testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, }; @@ -373,28 +373,28 @@ fn cancelling_a_predecessor_strands_its_dependant() { fn self_calls_reconfigure_the_timelock() { let h = setup(); let e = &h.env; - let new_proposer = Address::generate(&e); + let new_proposer = Address::generate(e); - let delay_op = h.schedule_self(1, "update_delay", vec![&e, (2 * DELAY).into_val(&e)]); + let delay_op = h.schedule_self(1, "update_delay", vec![e, (2 * DELAY).into_val(e)]); let grant_op = h.schedule_self( 2, "grant_role", vec![ - &e, - Role::Proposer.into_val(&e), - new_proposer.clone().into_val(&e), + e, + Role::Proposer.into_val(e), + new_proposer.clone().into_val(e), ], ); let revoke_op = h.schedule_self( 3, "revoke_role", vec![ - &e, - Role::Proposer.into_val(&e), - h.proposer.clone().into_val(&e), + e, + Role::Proposer.into_val(e), + h.proposer.clone().into_val(e), ], ); - let open_op = h.schedule_self(4, "set_open_executor", vec![&e, true.into_val(&e)]); + let open_op = h.schedule_self(4, "set_open_executor", vec![e, true.into_val(e)]); h.warp(DELAY); @@ -421,7 +421,7 @@ fn self_calls_reconfigure_the_timelock() { fn a_self_call_the_timelock_does_not_implement_is_refused() { let h = setup(); let e = &h.env; - let op = h.schedule_self(1, "not_a_function", vec![&e]); + let op = h.schedule_self(1, "not_a_function", vec![e]); h.warp(DELAY); assert!(h.client.try_execute(&h.executor, &op).is_err()); } From 15c6217ea0abfb54543e66dfb6dd8f3fbe26721b Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:39:39 +0100 Subject: [PATCH 14/17] fix(timelock): mock the authorization, not set it --- timelock/src/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 1d34a6e..3765bce 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -486,7 +486,7 @@ fn the_timelock_is_the_admin_of_the_contract_it_acts_on() { // Only the executor's own authorization is supplied. The target's admin // is the timelock, and the timelock is its direct invoker, so the // target's own admin check passes without a second signature. - h.env.set_auths(&[MockAuth { + h.env.mock_auths(&[MockAuth { address: &h.executor, invoke: &MockAuthInvoke { contract: &h.client.address, From 271ff9f2776311ba4ef9ba480278972d3c8f02eb Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 14:44:58 +0100 Subject: [PATCH 15/17] fix(timelock): use the derived operation id in the tests --- timelock/src/test.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/timelock/src/test.rs b/timelock/src/test.rs index 3765bce..160071d 100644 --- a/timelock/src/test.rs +++ b/timelock/src/test.rs @@ -263,11 +263,11 @@ fn an_operation_expires_after_its_grace_period() { #[test] fn an_operation_cannot_execute_twice() { let h = setup(); - h.schedule_set_value(1, 42); + let id = h.schedule_set_value(1, 42); h.warp(DELAY); - h.client.execute(&h.executor, &h.salt(1)); - assert!(h.client.is_operation_done(&h.salt(1))); - assert!(h.client.try_execute(&h.executor, &h.salt(1)).is_err()); + h.client.execute(&h.executor, &id); + assert!(h.client.is_operation_done(&id)); + assert!(h.client.try_execute(&h.executor, &id).is_err()); } #[test] @@ -291,10 +291,10 @@ fn executing_after_a_cancel_is_refused() { #[test] fn cancelling_an_already_executed_operation_is_refused() { let h = setup(); - h.schedule_set_value(1, 42); + let id = h.schedule_set_value(1, 42); h.warp(DELAY); - h.client.execute(&h.executor, &h.salt(1)); - assert!(h.client.try_cancel(&h.canceller, &h.salt(1)).is_err()); + h.client.execute(&h.executor, &id); + assert!(h.client.try_cancel(&h.canceller, &id).is_err()); } #[test] @@ -481,7 +481,7 @@ fn the_stored_operation_is_the_spec_it_was_scheduled_with() { #[test] fn the_timelock_is_the_admin_of_the_contract_it_acts_on() { let h = setup(); - h.schedule_set_value(1, 42); + let id = h.schedule_set_value(1, 42); h.warp(DELAY); // Only the executor's own authorization is supplied. The target's admin // is the timelock, and the timelock is its direct invoker, so the @@ -494,11 +494,11 @@ fn the_timelock_is_the_admin_of_the_contract_it_acts_on() { args: vec![ &h.env, h.executor.clone().into_val(&h.env), - h.salt(1).into_val(&h.env), + id.clone().into_val(&h.env), ], sub_invokes: &[], }, }]); - h.client.execute(&h.executor, &h.salt(1)); + h.client.execute(&h.executor, &id); assert_eq!(h.target.value(), 42); } From c26c63352e15b1e4d5b2836f2b53de66fd14242c Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 16:41:16 +0100 Subject: [PATCH 16/17] fix(vault): drop the crate-level inner attribute from a module file 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. --- vault/src/token_validation.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/vault/src/token_validation.rs b/vault/src/token_validation.rs index f1420ef..68ca3aa 100644 --- a/vault/src/token_validation.rs +++ b/vault/src/token_validation.rs @@ -1,4 +1,3 @@ -#![no_std] use soroban_sdk::{token, Address, Env}; @@ -31,9 +30,7 @@ impl TokenTransferGuard { client.transfer(from, recipient, &amount); let after_bal = client.balance(recipient); - let received = after_bal - .checked_sub(before_bal) - .ok_or(TokenError::DeltaMismatch)?; + let received = after_bal.checked_sub(before_bal).ok_or(TokenError::DeltaMismatch)?; if received < amount { return Err(TokenError::DeltaMismatch); From 445f3ccbfbe6c6c1d7c3e3b8983711ef36347929 Mon Sep 17 00:00:00 2001 From: tesleem-28 Date: Tue, 20 Oct 2026 16:51:53 +0100 Subject: [PATCH 17/17] style(vault): format the token validation module --- vault/src/token_validation.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vault/src/token_validation.rs b/vault/src/token_validation.rs index 68ca3aa..dd1f0c5 100644 --- a/vault/src/token_validation.rs +++ b/vault/src/token_validation.rs @@ -1,4 +1,3 @@ - use soroban_sdk::{token, Address, Env}; #[derive(Clone, Debug, PartialEq)] @@ -30,7 +29,9 @@ impl TokenTransferGuard { client.transfer(from, recipient, &amount); let after_bal = client.balance(recipient); - let received = after_bal.checked_sub(before_bal).ok_or(TokenError::DeltaMismatch)?; + let received = after_bal + .checked_sub(before_bal) + .ok_or(TokenError::DeltaMismatch)?; if received < amount { return Err(TokenError::DeltaMismatch);