diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d43ae8ae..93cf5a0894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 2026-04-30 +- #2798 (Non-breaking) Adds timelock support to the SDK. To use, add the `sov-timelock` module to the runtime, override the `timelock()` accessor on `HasCapabilities`, and then override `Runtime::timelock_for_callmessage` to match `CallMessage`s and return `TimelockPolicy` for those that should be timelocked. The module supports configurable cancellation policies, including delegating to a separate cancel address. See the `sov-timelock` module README for more details. Existing rollups do not need to do anything. + # 2026-04-23 - #2693 Adds new apis for statemap iteration at `/modules/{module}/state/{map_name}/items` diff --git a/Cargo.lock b/Cargo.lock index 30dd20aab9..0fee24afbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5754,7 +5754,6 @@ dependencies = [ "sov-state", "sov-test-modules", "sov-test-utils", - "sov-timelock", "sov-value-setter", "strum 0.26.3", "tempfile", @@ -13027,7 +13026,18 @@ name = "sov-timelock" version = "0.3.0" dependencies = [ "anyhow", + "borsh", + "schemars 0.8.22", + "serde", + "serde_json", + "sov-chain-state", "sov-modules-api", + "sov-test-modules", + "sov-test-utils", + "sov-timelock", + "sov-value-setter", + "strum 0.26.3", + "thiserror 2.0.17", ] [[package]] diff --git a/crates/module-system/module-implementations/integration-tests/Cargo.toml b/crates/module-system/module-implementations/integration-tests/Cargo.toml index cec2ba10bb..43609b293e 100644 --- a/crates/module-system/module-implementations/integration-tests/Cargo.toml +++ b/crates/module-system/module-implementations/integration-tests/Cargo.toml @@ -49,7 +49,6 @@ sov-attester-incentives = { workspace = true, features = ["native"] } sov-chain-state = { workspace = true, features = ["native"] } sov-value-setter = { workspace = true, features = ["native"] } sov-test-modules = { workspace = true, features = ["native"] } -sov-timelock = { workspace = true, features = ["native"] } sov-sequencer-registry = { workspace = true, features = ["native"] } sov-bank = { workspace = true, features = ["native"] } sov-accounts = { workspace = true, features = ["native"] } diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/timelock.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/timelock.rs index 799c4f831f..7435ce8ba0 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/timelock.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/timelock.rs @@ -2,27 +2,27 @@ use std::num::NonZeroU64; use sov_modules_api::capabilities::TimelockPolicy; use sov_modules_api::prelude::UnwrapInfallible; -use sov_test_modules::hooks_count::TxHooksCount; +use sov_modules_api::TxEffect; use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; use sov_test_utils::runtime::{TestRunner, ValueSetter}; use sov_test_utils::{ generate_optimistic_runtime_with_kernel, AsUser, TestUser, TransactionTestCase, }; -use sov_value_setter::{CallMessage, ValueSetterConfig}; +use sov_value_setter::{CallMessage as ValueSetterCallMessage, ValueSetterConfig}; use crate::stf_blueprint::S; generate_optimistic_runtime_with_kernel!( - TimelockRuntime <= + NoopTimelockRuntime <= kernel_type: sov_test_utils::runtime::BasicKernel<'a, S>, modules: [ - value_setter: ValueSetter, - tx_hooks_count: TxHooksCount, - timelock: sov_timelock::Timelock + value_setter: ValueSetter ], - timelock_policy_wrapper: |call: &TimelockRuntimeCall| { + timelock_policy_wrapper: |call: &NoopTimelockRuntimeCall| { match call { - TimelockRuntimeCall::ValueSetter(CallMessage::SetValue { value: 7, .. }) => { + NoopTimelockRuntimeCall::ValueSetter( + ValueSetterCallMessage::SetValue { value: 7, .. } + ) => { Some(TimelockPolicy { unlock_seconds_from_proposal: NonZeroU64::new(60).unwrap(), expire_seconds_after_unlock_override: Some(60), @@ -33,7 +33,7 @@ generate_optimistic_runtime_with_kernel!( }, ); -type RT = TimelockRuntime; +type RT = NoopTimelockRuntime; fn setup() -> (TestUser, TestRunner) { let genesis_config = @@ -50,8 +50,6 @@ fn setup() -> (TestUser, TestRunner) { ValueSetterConfig { admin: admin.address(), }, - (), - (), ); let runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); @@ -60,16 +58,22 @@ fn setup() -> (TestUser, TestRunner) { } #[test] -fn timelocked_call_registers_proposal_without_dispatching_call() { +fn timelocked_call_is_rejected_when_timelock_capability_is_unavailable() { let (admin, mut runner) = setup(); runner.execute_transaction(TransactionTestCase { - input: admin.create_plain_message::>(CallMessage::SetValue { + input: admin.create_plain_message::>(ValueSetterCallMessage::SetValue { value: 7, gas: None, }), assert: Box::new(|result, state| { - assert!(result.tx_receipt.is_successful()); + let TxEffect::Reverted(contents) = &result.tx_receipt else { + panic!("expected reverted transaction, got {:?}", result.tx_receipt); + }; + assert!(contents + .reason + .to_string() + .contains("Timelocks are not available")); assert_eq!( ValueSetter::::default() .value @@ -77,73 +81,6 @@ fn timelocked_call_registers_proposal_without_dispatching_call() { .unwrap_infallible(), None ); - assert_eq!( - TxHooksCount::::default() - .post_dispatch_tx_hook_count - .get(state) - .unwrap_infallible(), - Some(1) - ); - }), - }); -} - -#[test] -fn repeated_timelocked_call_still_registers_without_dispatching_call() { - let (admin, mut runner) = setup(); - - for count in 1..=2 { - runner.execute_transaction(TransactionTestCase { - input: admin.create_plain_message::>(CallMessage::SetValue { - value: 7, - gas: None, - }), - assert: Box::new(move |result, state| { - assert!(result.tx_receipt.is_successful()); - assert_eq!( - ValueSetter::::default() - .value - .get(state) - .unwrap_infallible(), - None - ); - assert_eq!( - TxHooksCount::::default() - .post_dispatch_tx_hook_count - .get(state) - .unwrap_infallible(), - Some(count) - ); - }), - }); - } -} - -#[test] -fn untimelocked_call_dispatches_normally() { - let (admin, mut runner) = setup(); - - runner.execute_transaction(TransactionTestCase { - input: admin.create_plain_message::>(CallMessage::SetValue { - value: 8, - gas: None, - }), - assert: Box::new(|result, state| { - assert!(result.tx_receipt.is_successful()); - assert_eq!( - ValueSetter::::default() - .value - .get(state) - .unwrap_infallible(), - Some(8) - ); - assert_eq!( - TxHooksCount::::default() - .post_dispatch_tx_hook_count - .get(state) - .unwrap_infallible(), - Some(1) - ); }), }); } diff --git a/crates/module-system/module-implementations/sov-timelock/Cargo.toml b/crates/module-system/module-implementations/sov-timelock/Cargo.toml index 75aa198595..f825464cee 100644 --- a/crates/module-system/module-implementations/sov-timelock/Cargo.toml +++ b/crates/module-system/module-implementations/sov-timelock/Cargo.toml @@ -15,9 +15,32 @@ workspace = true [dependencies] anyhow = { workspace = true } +borsh = { workspace = true, features = ["rc"] } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sov-chain-state = { workspace = true } sov-modules-api = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +sov-test-modules = { workspace = true, features = ["native"] } +sov-test-utils = { workspace = true } +sov-timelock = { path = ".", version = "*", features = ["native"] } +sov-value-setter = { workspace = true, features = ["native"] } +strum = { workspace = true } [features] default = [] -arbitrary = ["sov-modules-api/arbitrary"] -native = ["sov-modules-api/native"] +arbitrary = [ + "sov-modules-api/arbitrary", + "sov-test-utils/arbitrary", + "sov-timelock/arbitrary" +] +native = [ + "sov-chain-state/native", + "sov-modules-api/native", + "sov-test-modules/native", + "sov-timelock/native", + "sov-value-setter/native" +] diff --git a/crates/module-system/module-implementations/sov-timelock/README.md b/crates/module-system/module-implementations/sov-timelock/README.md index 78f4dbfae9..ea3777c2fb 100644 --- a/crates/module-system/module-implementations/sov-timelock/README.md +++ b/crates/module-system/module-implementations/sov-timelock/README.md @@ -1,3 +1,251 @@ # `sov-timelock` -Minimal timelock capability module for Sovereign SDK runtimes. +Timelock capability module for Sovereign SDK runtimes. + +The module stores pending proposals by owner and proposal id, exposes runtime +capability methods for registering and unlocking proposals, and provides +user-callable messages for cancelling proposals and modifying cancellation +policies. + +## Runtime Integration + +Add `sov_timelock::Timelock` to the runtime, return it from the runtime's +timelock capability accessor, and implement `timelock_for_callmessage` for the +runtime calls that must be delayed. + +```rust,ignore +use std::num::NonZeroU64; + +use sov_modules_api::capabilities::{TimelockCapability, TimelockPolicy}; +use sov_modules_api::Spec; + +pub struct Runtime { + pub admin: my_admin_module::Admin, + pub timelock: sov_timelock::Timelock, + // Other modules... +} + +impl sov_modules_api::capabilities::HasCapabilities for Runtime { + // Existing capability implementation omitted. + + fn timelock(&mut self) -> impl TimelockCapability { + &mut self.timelock + } +} + +impl sov_modules_stf_blueprint::Runtime for Runtime { + // Other associated types and methods omitted. + + fn timelock_for_callmessage(&self, call: &Self::Decodable) -> Option { + match call { + RuntimeCall::Admin(my_admin_module::CallMessage::RotateAdmin { .. }) => { + Some(TimelockPolicy { + unlock_seconds_from_proposal: NonZeroU64::new(24 * 60 * 60).unwrap(), + expire_seconds_after_unlock_override: None, + }) + } + _ => None, + } + } +} +``` + +If a runtime returns a timelock policy but does not provide a concrete timelock +capability, the transaction is rejected with `TimelocksNotAvailable`. This keeps +timelocks opt-in for runtimes that do not include `sov-timelock`. + +## Module-Owned Timelocks + +Modules may also depend directly on `sov-timelock` and enforce their own +timelocks internally, instead of asking the runtime to match on their call +messages in `timelock_for_callmessage`. This is useful when the timelock is a +module invariant rather than a rollup-level policy. `sov-timelock` uses this +pattern for `ModifyCancellationPolicy`. + +The module should encode the operation it wants to protect, wrap those bytes with +`TimelockProposalData::custom_data(module_id, bytes)`, and call +`register_or_try_unlock_proposal`. If the outcome is `Registered`, the module +returns without applying the protected action. If the outcome is `Unlocked`, the +module applies the action. The timelock module must still be part of the +runtime; the dependent module takes a module reference to it with `#[module]`. + +```rust,no_run +use std::num::NonZeroU64; + +use schemars::JsonSchema; +use sov_modules_api::capabilities::{ + TimelockCapability, TimelockPolicy, TimelockProposalData, TimelockProposalOutcome, +}; +use sov_modules_api::macros::{serialize, UniversalWallet}; +use sov_modules_api::{ + Context, CoreModuleError, Module, ModuleId, ModuleInfo, Spec, StateValue, TxState, +}; + +#[derive(Clone, ModuleInfo)] +pub struct ExampleModule { + #[id] + pub id: ModuleId, + + #[state] + pub setup_mode_terminated: StateValue, + + #[module] + pub timelock: sov_timelock::Timelock, +} + +#[derive(Debug, PartialEq, Eq, Clone, JsonSchema, UniversalWallet)] +#[serialize(Borsh, Serde)] +pub enum CallMessage { + TerminateSetupMode {}, +} + +impl Module for ExampleModule { + type Spec = S; + type Config = (); + type CallMessage = CallMessage; + type Event = (); + type Error = sov_modules_api::Error; + + fn call( + &mut self, + msg: Self::CallMessage, + context: &Context, + state: &mut impl TxState, + ) -> Result<(), Self::Error> { + match msg { + CallMessage::TerminateSetupMode {} => self.terminate_setup_mode(context, state), + } + } +} + +impl ExampleModule { + fn terminate_setup_mode( + &mut self, + context: &Context, + state: &mut impl TxState, + ) -> Result<(), sov_modules_api::Error> { + let encoded_message = borsh::to_vec(&CallMessage::TerminateSetupMode {}) + .map_err(|error| CoreModuleError::Generic(anyhow::anyhow!(error)))?; + let proposal_data = TimelockProposalData::custom_data(self.id, encoded_message); + + let outcome = self.timelock.register_or_try_unlock_proposal( + context.sender(), + proposal_data, + TimelockPolicy { + unlock_seconds_from_proposal: NonZeroU64::new(24 * 60 * 60).unwrap(), + expire_seconds_after_unlock_override: None, + }, + state, + )?; + + match outcome { + TimelockProposalOutcome::Registered => {} + TimelockProposalOutcome::Unlocked => { + self.setup_mode_terminated + .set(&true, state) + .map_err(CoreModuleError::state_write)?; + } + } + + Ok(()) + } +} +``` + +The `CustomData` domain is separated from runtime call-message proposal data, so +module-owned proposal ids cannot collide with runtime-managed timelocks. + +## Duplicate Proposals + +Simultaneous duplicate proposals are not supported. Proposals are keyed by +`(owner_address, proposal_id)`, and the proposal id is derived from the encoded +call message or module-owned custom proposal data. If the same owner submits the +same timelocked call while it is already pending, the module treats it as an +attempt to unlock the existing proposal, not as a request to create another one. + +Modules that need multiple simultaneous timelocks for otherwise identical +actions should make their call messages intentionally malleable, for example by +including a meaningless `nonce` or `salt` field purely to produce a distinct +proposal hash. + +## Transaction UX + +For a timelocked runtime call, the proposal id is the hash of the encoded +`Runtime::CallMessage`, not the hash of the raw transaction. Users submit the +same call message twice, usually in two distinct signed transactions with fresh +nonce or uniqueness metadata: + +1. The first transaction registers the call as a pending proposal and does not + dispatch the underlying call. +2. A repeat transaction before the unlock time is rejected as still locked. +3. A repeat transaction after the unlock time dispatches the underlying call and + consumes the proposal. +4. A repeat transaction after the expiry window is rejected as expired. Expired + proposals can be removed with `CleanExpiredProposals`. + +```rust,ignore +use sov_modules_api::capabilities::{ + calculate_timelock_proposal_id, TimelockProposalData, +}; +use sov_modules_api::EncodeCall; + +let call = RuntimeCall::Admin(my_admin_module::CallMessage::RotateAdmin { + new_admin, +}); + +let proposal_id = calculate_timelock_proposal_id::( + &TimelockProposalData::call_message(Runtime::::encode(&call)), +); + +submit_transaction(call.clone()); // Registers the proposal. +wait_until_unlock_time(); +submit_transaction(call); // Executes and consumes the proposal. +``` + +The default post-unlock expiry window is two days. A runtime policy can override +that window with `expire_seconds_after_unlock_override`. + +## Cancelling Proposals + +The proposal owner can cancel a pending proposal by passing `address: None`. + +```rust,ignore +let cancel = RuntimeCall::Timelock(sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: None, +}); + +submit_transaction(cancel); +``` + +An authorized canceller can cancel on behalf of the owner by passing the owner's +address explicitly. + +```rust,ignore +let cancel = RuntimeCall::Timelock(sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: Some(owner_address), +}); + +submit_transaction(cancel); +``` + +Users configure that authorized canceller with `ModifyCancellationPolicy`. + +```rust,ignore +let update_policy = + RuntimeCall::Timelock(sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: sov_timelock::CancellationPolicy { + authorized_canceller, + policy_change_timelock_seconds: 60 * 60, + }, + }); + +submit_transaction(update_policy); +``` + +If the current policy has `policy_change_timelock_seconds == 0`, the update is +applied immediately. Otherwise, the policy update is itself timelocked: submit +the same `ModifyCancellationPolicy` call once to register the update, then submit +it again after the policy-change delay to apply it. Policy update proposals use +the default expiry window. diff --git a/crates/module-system/module-implementations/sov-timelock/src/call.rs b/crates/module-system/module-implementations/sov-timelock/src/call.rs new file mode 100644 index 0000000000..361384eb9a --- /dev/null +++ b/crates/module-system/module-implementations/sov-timelock/src/call.rs @@ -0,0 +1,193 @@ +use std::num::NonZeroU64; + +use schemars::JsonSchema; +use sov_modules_api::capabilities::{ + ProposalId, TimelockCapability, TimelockError, TimelockPolicy, TimelockProposalData, + TimelockProposalOutcome, +}; +use sov_modules_api::macros::{serialize, UniversalWallet}; +use sov_modules_api::{ + Context, CoreModuleError, Error as ModuleError, EventEmitter, SafeVec, Spec, TxState, +}; + +use crate::{ + CancellationPolicy, Error, Event, ProposalKey, Timelock, MAX_PROPOSAL_KEYS_PER_CLEANUP, +}; + +/// Calls supported by the timelock module. +#[derive(Debug, PartialEq, Eq, Clone, JsonSchema, UniversalWallet)] +#[serialize(Borsh, Serde)] +#[serde(bound = "S: Spec")] +#[schemars(bound = "S::Address: ::schemars::JsonSchema", rename = "CallMessage")] +#[serde(rename_all = "snake_case")] +pub enum CallMessage { + /// Cancel a pending proposal. + CancelProposal { + /// Proposal id to cancel. + proposal_id: ProposalId, + /// Owner address. If absent, the sender is treated as the owner. + address: Option, + }, + /// Modify the sender's cancellation policy. + ModifyCancellationPolicy { + /// New cancellation policy. + new_policy: CancellationPolicy, + }, + /// Clean expired proposals. + CleanExpiredProposals { + /// Proposal keys to clean. + proposal_keys: SafeVec, MAX_PROPOSAL_KEYS_PER_CLEANUP>, + }, +} + +impl Timelock { + pub(super) fn cancel_proposal( + &mut self, + proposal_id: ProposalId, + address: Option, + context: &Context, + state: &mut impl TxState, + ) -> Result<(), Error> { + let address = address.unwrap_or(*context.sender()); + let key = ProposalKey::new(address, proposal_id); + let Some(pending_proposal) = self + .proposals + .get(&key, state) + .map_err(CoreModuleError::state_read)? + else { + return Err(TimelockError::ProposalNotFound.into()); + }; + + if context.sender() != &address + && context.sender() + != &pending_proposal + .unlock_condition + .cancellation_policy + .authorized_canceller + { + return Err(Error::UnauthorizedCanceller { + sender: *context.sender(), + address, + authorized_canceller: pending_proposal + .unlock_condition + .cancellation_policy + .authorized_canceller, + proposal_id, + }); + } + + self.delete_proposal(&key, state)?; + self.emit_event( + state, + Event::ProposalCancelled { + address, + proposal_id, + cancelled_by: *context.sender(), + }, + ); + Ok(()) + } + + pub(super) fn clean_expired_proposals( + &mut self, + proposal_keys: SafeVec, MAX_PROPOSAL_KEYS_PER_CLEANUP>, + state: &mut impl TxState, + ) -> Result<(), Error> { + let current_time = self.current_time_secs(state)?; + for key in proposal_keys { + self.clean_expired_proposal(&key, current_time, state)?; + } + + Ok(()) + } + + fn clean_expired_proposal( + &mut self, + key: &ProposalKey, + current_time: u64, + state: &mut impl TxState, + ) -> Result<(), Error> { + let Some(pending_proposal) = self + .proposals + .get(key, state) + .map_err(CoreModuleError::state_read)? + else { + return Err(TimelockError::ProposalNotFound.into()); + }; + + if current_time <= pending_proposal.unlock_condition.executable_until { + return Err(Error::ProposalNotExpired { + proposal_key: key.clone(), + current_time, + executable_until: pending_proposal.unlock_condition.executable_until, + }); + } + + self.delete_proposal(key, state)?; + self.emit_event( + state, + Event::ExpiredProposalCleaned { + address: key.0, + proposal_id: key.1, + }, + ); + + Ok(()) + } + + pub(super) fn modify_cancellation_policy( + &mut self, + new_policy: CancellationPolicy, + context: &Context, + state: &mut impl TxState, + ) -> Result<(), ModuleError> { + let current_policy = self + .cancellation_policy(context.sender(), state) + .map_err(ModuleError::from)?; + let Some(policy_change_timelock_seconds) = + NonZeroU64::new(current_policy.policy_change_timelock_seconds) + else { + self.policies + .set(context.sender(), &new_policy, state) + .map_err(CoreModuleError::state_write) + .map_err(ModuleError::from)?; + return Ok(()); + }; + + let proposal_data = self + .policy_update_proposal_data(&new_policy) + .map_err(ModuleError::from)?; + let policy = TimelockPolicy { + unlock_seconds_from_proposal: policy_change_timelock_seconds, + expire_seconds_after_unlock_override: None, + }; + match self.register_or_try_unlock_proposal( + context.sender(), + proposal_data, + policy, + state, + )? { + TimelockProposalOutcome::Registered => {} + TimelockProposalOutcome::Unlocked => { + self.policies + .set(context.sender(), &new_policy, state) + .map_err(CoreModuleError::state_write) + .map_err(ModuleError::from)?; + } + } + + Ok(()) + } + + fn policy_update_proposal_data( + &self, + new_policy: &CancellationPolicy, + ) -> Result> { + let encoded_message = borsh::to_vec(&CallMessage::ModifyCancellationPolicy { + new_policy: new_policy.clone(), + }) + .map_err(|error| CoreModuleError::Generic(anyhow::anyhow!(error)))?; + + Ok(TimelockProposalData::custom_data(self.id, encoded_message)) + } +} diff --git a/crates/module-system/module-implementations/sov-timelock/src/error.rs b/crates/module-system/module-implementations/sov-timelock/src/error.rs new file mode 100644 index 0000000000..492850e734 --- /dev/null +++ b/crates/module-system/module-implementations/sov-timelock/src/error.rs @@ -0,0 +1,98 @@ +use sov_modules_api::capabilities::{ProposalId, TimelockError}; +use sov_modules_api::{err_detail, CoreModuleError, ErrorContext, ErrorDetail, Spec}; + +use crate::ProposalKey; + +/// Errors returned by the timelock module. +#[derive(Debug, thiserror::Error, serde::Serialize)] +#[serde( + tag = "error_code", + rename_all = "snake_case", + bound = "S::Address: serde::Serialize" +)] +pub enum Error { + /// A core module error occurred. + #[error(transparent)] + Core(#[from] CoreModuleError), + /// A timelock semantic error occurred. + #[error(transparent)] + Timelock(#[from] TimelockError), + /// The proposal already exists. + #[error("Proposal {proposal_id} already exists for address {address}")] + ProposalAlreadyExists { + /// Proposal owner. + address: S::Address, + /// Existing proposal id. + proposal_id: ProposalId, + }, + /// The proposal owner already has the maximum number of pending proposals. + #[error( + "Address {address} already has the maximum of {max_pending_proposals} pending proposals" + )] + MaxPendingProposalsReached { + /// Proposal owner. + address: S::Address, + /// Maximum pending proposals per address. + max_pending_proposals: u32, + }, + /// The proposal counter is inconsistent with the proposal map. + #[error( + "Proposal count for address {address} is inconsistent while deleting proposal {proposal_id}" + )] + ProposalCountUnderflow { + /// Proposal owner. + address: S::Address, + /// Proposal id being deleted. + proposal_id: ProposalId, + }, + /// The proposal exists but has not expired yet. + #[error( + "Proposal {proposal_key} is not expired at current time {current_time}; executable until {executable_until}" + )] + ProposalNotExpired { + /// Proposal key. + proposal_key: ProposalKey, + /// Current chain time in seconds. + current_time: u64, + /// Unix timestamp after which the proposal is expired. + executable_until: u64, + }, + /// The sender is not authorized to cancel the proposal. + #[error( + "Address {sender} is not authorized to cancel proposal {proposal_id} for {address}; authorized canceller is {authorized_canceller}" + )] + UnauthorizedCanceller { + /// Transaction sender. + sender: S::Address, + /// Proposal owner. + address: S::Address, + /// Address authorized by the proposal's cancellation policy. + authorized_canceller: S::Address, + /// Proposal id. + proposal_id: ProposalId, + }, + /// The current chain time is negative. + #[error("Current chain time {current_time} is before the Unix epoch")] + InvalidCurrentTime { + /// Current chain time in seconds. + current_time: i64, + }, + /// Computing an unlock timestamp overflowed. + #[error( + "Timestamp overflow while computing unlock condition from current time {current_time}" + )] + TimestampOverflow { + /// Current chain time in seconds. + current_time: u64, + }, +} + +impl ErrorDetail for Error +where + S: Spec, + S::Address: serde::Serialize, +{ + fn error_detail(&self) -> Result> { + Ok(err_detail!(self)) + } +} diff --git a/crates/module-system/module-implementations/sov-timelock/src/event.rs b/crates/module-system/module-implementations/sov-timelock/src/event.rs new file mode 100644 index 0000000000..75dab6b54c --- /dev/null +++ b/crates/module-system/module-implementations/sov-timelock/src/event.rs @@ -0,0 +1,48 @@ +use schemars::JsonSchema; +use sov_modules_api::capabilities::{ProposalId, TimelockProposalData}; +use sov_modules_api::macros::serialize; +use sov_modules_api::Spec; + +/// Events emitted by the timelock module. +#[derive(Debug, PartialEq, Eq, Clone, JsonSchema)] +#[serialize(Borsh, Serde)] +#[serde(bound = "S: Spec", rename_all = "snake_case")] +#[schemars(bound = "S::Address: ::schemars::JsonSchema", rename = "Event")] +pub enum Event { + /// A proposal was registered and is pending unlock. + ProposalRegistered { + /// Proposal owner. + address: S::Address, + /// Proposal id. + proposal_id: ProposalId, + /// Full proposal data whose hash is the proposal id. + proposal_data: TimelockProposalData, + /// Unix timestamp at which the proposal becomes executable. + executable_from: u64, + /// Unix timestamp after which the proposal is expired. + executable_until: u64, + }, + /// A proposal was unlocked and consumed for execution. + ProposalUnlocked { + /// Proposal owner. + address: S::Address, + /// Proposal id. + proposal_id: ProposalId, + }, + /// A pending proposal was cancelled. + ProposalCancelled { + /// Proposal owner. + address: S::Address, + /// Proposal id. + proposal_id: ProposalId, + /// Address that cancelled the proposal. + cancelled_by: S::Address, + }, + /// An expired proposal was cleaned. + ExpiredProposalCleaned { + /// Proposal owner. + address: S::Address, + /// Proposal id. + proposal_id: ProposalId, + }, +} diff --git a/crates/module-system/module-implementations/sov-timelock/src/lib.rs b/crates/module-system/module-implementations/sov-timelock/src/lib.rs index 7ea637ae5f..86c0fdfcbe 100644 --- a/crates/module-system/module-implementations/sov-timelock/src/lib.rs +++ b/crates/module-system/module-implementations/sov-timelock/src/lib.rs @@ -3,23 +3,192 @@ #![deny(missing_docs)] #![doc = include_str!("../README.md")] +mod call; +mod error; +mod event; + +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use borsh::{BorshDeserialize, BorshSerialize}; +use schemars::JsonSchema; use sov_modules_api::capabilities::{ - ProposalId, TimelockCapability, TimelockError, TimelockPolicy, + calculate_timelock_proposal_id_metered, ProposalId, TimelockCapability, TimelockError, + TimelockPolicy, TimelockProposalData, TimelockProposalOutcome, }; +use sov_modules_api::macros::UniversalWallet; use sov_modules_api::{ - Context, DaSpec, Error, GenesisState, Module, ModuleId, ModuleInfo, ModuleRestApi, - NotInstantiable, Spec, TxState, + Context, CoreModuleError, DaSpec, Error as ModuleError, EventEmitter, GenesisState, Module, + ModuleId, ModuleInfo, ModuleRestApi, Spec, StateMap, TxState, }; +pub use call::CallMessage; +pub use error::Error; +pub use event::Event; + +/// Maximum number of pending proposals per address. +pub const MAX_PENDING_PROPOSALS_PER_ADDRESS: u32 = 128; + +/// Maximum number of proposal keys accepted by a cleanup call. +pub const MAX_PROPOSAL_KEYS_PER_CLEANUP: usize = 128; + +const PROPOSAL_KEY_SEPARATOR: &str = "/proposals/"; + +/// Storage key for a pending proposal. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + BorshDeserialize, + BorshSerialize, + serde::Serialize, + serde::Deserialize, + JsonSchema, + UniversalWallet, +)] +#[serde(bound = "S: Spec", deny_unknown_fields)] +#[schemars(bound = "S::Address: ::schemars::JsonSchema", rename = "ProposalKey")] +pub struct ProposalKey( + /// Proposal owner. + pub S::Address, + /// Proposal id. + pub ProposalId, +); + +impl ProposalKey { + fn new(address: S::Address, proposal_id: ProposalId) -> Self { + Self(address, proposal_id) + } +} + +impl Display for ProposalKey { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{PROPOSAL_KEY_SEPARATOR}{}", self.0, self.1) + } +} + +impl FromStr for ProposalKey +where + S: Spec, + S::Address: FromStr>>, +{ + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let Some((address, proposal_id)) = s.rsplit_once(PROPOSAL_KEY_SEPARATOR) else { + anyhow::bail!( + "{s} is not a proposal key: missing '{PROPOSAL_KEY_SEPARATOR}' separator" + ); + }; + + Ok(Self( + S::Address::from_str(address) + .map_err(|error| anyhow::Error::from_boxed(error.into()))?, + ProposalId::from_str(proposal_id)?, + )) + } +} + +/// Cancellation rules for proposals owned by an address. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshDeserialize, + BorshSerialize, + serde::Serialize, + serde::Deserialize, + JsonSchema, + UniversalWallet, +)] +#[serde(bound = "S: Spec", deny_unknown_fields)] +#[schemars( + bound = "S::Address: ::schemars::JsonSchema", + rename = "CancellationPolicy" +)] +pub struct CancellationPolicy { + /// Address allowed to cancel proposals for this account when the cancel call specifies an owner. + pub authorized_canceller: S::Address, + /// Delay before policy updates take effect. + pub policy_change_timelock_seconds: u64, +} + +/// Stored unlock condition for a pending proposal. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshDeserialize, + BorshSerialize, + serde::Serialize, + serde::Deserialize, + JsonSchema, +)] +#[serde(bound = "S: Spec", deny_unknown_fields)] +#[schemars( + bound = "S::Address: ::schemars::JsonSchema", + rename = "UnlockCondition" +)] +pub struct UnlockCondition { + /// Unix timestamp at which the proposal becomes executable. + pub executable_from: u64, + /// Unix timestamp after which the proposal is expired. + pub executable_until: u64, + /// Cancellation policy snapshotted when the proposal was created. + pub cancellation_policy: CancellationPolicy, +} + +/// A pending proposal and its unlock condition. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshDeserialize, + BorshSerialize, + serde::Serialize, + serde::Deserialize, + JsonSchema, +)] +#[serde(bound = "S: Spec", deny_unknown_fields)] +#[schemars( + bound = "S::Address: ::schemars::JsonSchema", + rename = "PendingProposal" +)] +pub struct PendingProposal { + /// Full proposal data whose hash is used as the proposal id. + pub proposal_data: TimelockProposalData, + /// Condition that must hold before the proposal may execute. + pub unlock_condition: UnlockCondition, +} + /// Timelock module. -/// -/// The initial MVP exposes the timelock capability interface without storing proposals. #[derive(Clone, ModuleInfo, ModuleRestApi)] pub struct Timelock { /// The ID of the sov-timelock module. #[id] pub id: ModuleId, + /// Pending proposals. + #[state] + pub proposals: StateMap, PendingProposal>, + + /// Number of pending proposals per address. + #[state] + pub proposal_counts: StateMap, + + /// Cancellation policy per address. + #[state] + pub policies: StateMap>, + + /// Reference to the ChainState module. + #[module] + pub(crate) chain_state: sov_chain_state::ChainState, + #[phantom] phantom: std::marker::PhantomData, } @@ -29,57 +198,260 @@ impl Module for Timelock { type Config = (); - type CallMessage = NotInstantiable; + type CallMessage = CallMessage; - type Event = (); + type Event = Event; - type Error = anyhow::Error; + type Error = ModuleError; fn genesis( &mut self, _genesis_rollup_header: &<::Da as DaSpec>::BlockHeader, _config: &Self::Config, _state: &mut impl GenesisState, - ) -> Result<(), Self::Error> { + ) -> anyhow::Result<()> { Ok(()) } fn call( &mut self, - _msg: Self::CallMessage, - _context: &Context, - _state: &mut impl TxState, + msg: Self::CallMessage, + context: &Context, + state: &mut impl TxState, ) -> Result<(), Self::Error> { + match msg { + CallMessage::CancelProposal { + proposal_id, + address, + } => self + .cancel_proposal(proposal_id, address, context, state) + .map_err(ModuleError::from)?, + CallMessage::ModifyCancellationPolicy { new_policy } => { + self.modify_cancellation_policy(new_policy, context, state)?; + } + CallMessage::CleanExpiredProposals { proposal_keys } => { + self.clean_expired_proposals(proposal_keys, state) + .map_err(ModuleError::from)?; + } + } Ok(()) } } impl TimelockCapability for Timelock { - fn has_proposal( + fn register_or_try_unlock_proposal( + &mut self, + address: &S::Address, + proposal_data: TimelockProposalData, + policy: TimelockPolicy, + state: &mut impl TxState, + ) -> Result { + let proposal_id = calculate_timelock_proposal_id_metered::<_, S>(&proposal_data, state) + .map_err(|error| CoreModuleError::Generic(anyhow::anyhow!(error))) + .map_err(ModuleError::from)?; + let key = ProposalKey::new(*address, proposal_id); + if self + .proposals + .get(&key, state) + .map_err(CoreModuleError::state_read)? + .is_some() + { + self.try_unlock_proposal_inner(address, &proposal_id, state) + .map_err(ModuleError::from)?; + Ok(TimelockProposalOutcome::Unlocked) + } else { + self.register_proposal_inner(address, proposal_id, proposal_data, policy, state) + .map_err(ModuleError::from)?; + Ok(TimelockProposalOutcome::Registered) + } + } +} + +impl Timelock { + fn current_time_secs(&self, state: &mut impl TxState) -> Result> { + let current_time = self + .chain_state + .get_time(state) + .map_err(CoreModuleError::state_read)? + .secs(); + + current_time + .try_into() + .map_err(|_| Error::InvalidCurrentTime { current_time }) + } + + fn cancellation_policy( &self, - _address: &S::Address, - _proposal_id: &ProposalId, - _state: &mut impl TxState, - ) -> Result { - Ok(false) + address: &S::Address, + state: &mut impl TxState, + ) -> Result, Error> { + Ok(self + .policies + .get(address, state) + .map_err(CoreModuleError::state_read)? + .unwrap_or(CancellationPolicy { + authorized_canceller: *address, + policy_change_timelock_seconds: 0, + })) } - fn register_proposal( + fn unlock_condition( + &self, + address: &S::Address, + policy: TimelockPolicy, + state: &mut impl TxState, + ) -> Result, Error> { + let current_time = self.current_time_secs(state)?; + let executable_from = current_time + .checked_add(policy.unlock_seconds_from_proposal.get()) + .ok_or(Error::TimestampOverflow { current_time })?; + let executable_until = executable_from + .checked_add(policy.expire_seconds_after_unlock()) + .ok_or(Error::TimestampOverflow { current_time })?; + + Ok(UnlockCondition { + executable_from, + executable_until, + cancellation_policy: self.cancellation_policy(address, state)?, + }) + } + + fn register_proposal_inner( &mut self, - _address: &S::Address, - _proposal_id: ProposalId, - _policy: TimelockPolicy, - _state: &mut impl TxState, - ) -> Result<(), Error> { + address: &S::Address, + proposal_id: ProposalId, + proposal_data: TimelockProposalData, + policy: TimelockPolicy, + state: &mut impl TxState, + ) -> Result<(), Error> { + let key = ProposalKey::new(*address, proposal_id); + if self + .proposals + .get(&key, state) + .map_err(CoreModuleError::state_read)? + .is_some() + { + return Err(Error::ProposalAlreadyExists { + address: *address, + proposal_id, + }); + } + + let unlock_condition = self.unlock_condition(address, policy, state)?; + let pending_proposal = PendingProposal { + proposal_data, + unlock_condition, + }; + self.add_proposal(&key, &pending_proposal, state)?; + self.emit_event( + state, + Event::ProposalRegistered { + address: *address, + proposal_id, + proposal_data: pending_proposal.proposal_data.clone(), + executable_from: pending_proposal.unlock_condition.executable_from, + executable_until: pending_proposal.unlock_condition.executable_until, + }, + ); Ok(()) } - fn try_unlock_proposal( + fn try_unlock_proposal_inner( &mut self, - _address: &S::Address, - _proposal_id: &ProposalId, - _state: &mut impl TxState, - ) -> Result<(), Error> { - Err(TimelockError::ProposalNotFound.into()) + address: &S::Address, + proposal_id: &ProposalId, + state: &mut impl TxState, + ) -> Result<(), Error> { + let key = ProposalKey::new(*address, *proposal_id); + let Some(pending_proposal) = self + .proposals + .get(&key, state) + .map_err(CoreModuleError::state_read)? + else { + return Err(TimelockError::ProposalNotFound.into()); + }; + + let current_time = self.current_time_secs(state)?; + if current_time < pending_proposal.unlock_condition.executable_from { + return Err(TimelockError::ProposalLocked.into()); + } + + if current_time > pending_proposal.unlock_condition.executable_until { + return Err(TimelockError::ProposalExpired.into()); + } + + self.delete_proposal(&key, state)?; + self.emit_event( + state, + Event::ProposalUnlocked { + address: *address, + proposal_id: *proposal_id, + }, + ); + + Ok(()) + } + + fn add_proposal( + &mut self, + key: &ProposalKey, + pending_proposal: &PendingProposal, + state: &mut impl TxState, + ) -> Result<(), Error> { + let count = self + .proposal_counts + .get(&key.0, state) + .map_err(CoreModuleError::state_read)? + .unwrap_or_default(); + + if count >= MAX_PENDING_PROPOSALS_PER_ADDRESS { + return Err(Error::MaxPendingProposalsReached { + address: key.0, + max_pending_proposals: MAX_PENDING_PROPOSALS_PER_ADDRESS, + }); + } + + self.proposals + .set(key, pending_proposal, state) + .map_err(CoreModuleError::state_write)?; + self.proposal_counts + .set(&key.0, &(count + 1), state) + .map_err(CoreModuleError::state_write)?; + + Ok(()) + } + + fn delete_proposal( + &mut self, + key: &ProposalKey, + state: &mut impl TxState, + ) -> Result<(), Error> { + let Some(new_count) = self + .proposal_counts + .get(&key.0, state) + .map_err(CoreModuleError::state_read)? + .unwrap_or_default() + .checked_sub(1) + else { + return Err(Error::ProposalCountUnderflow { + address: key.0, + proposal_id: key.1, + }); + }; + + self.proposals + .delete(key, state) + .map_err(CoreModuleError::state_write)?; + if new_count == 0 { + self.proposal_counts + .delete(&key.0, state) + .map_err(CoreModuleError::state_write)?; + } else { + self.proposal_counts + .set(&key.0, &new_count, state) + .map_err(CoreModuleError::state_write)?; + } + + Ok(()) } } diff --git a/crates/module-system/module-implementations/sov-timelock/tests/integration/main.rs b/crates/module-system/module-implementations/sov-timelock/tests/integration/main.rs new file mode 100644 index 0000000000..28a05ae745 --- /dev/null +++ b/crates/module-system/module-implementations/sov-timelock/tests/integration/main.rs @@ -0,0 +1,3 @@ +mod timelock; + +type S = sov_test_utils::TestSpec; diff --git a/crates/module-system/module-implementations/sov-timelock/tests/integration/timelock.rs b/crates/module-system/module-implementations/sov-timelock/tests/integration/timelock.rs new file mode 100644 index 0000000000..7621f40dad --- /dev/null +++ b/crates/module-system/module-implementations/sov-timelock/tests/integration/timelock.rs @@ -0,0 +1,827 @@ +use std::num::NonZeroU64; +use std::str::FromStr; + +use sov_modules_api::capabilities::{ + calculate_timelock_proposal_id, ProposalId, TimelockPolicy, TimelockProposalData, + DEFAULT_EXPIRE_SECONDS_AFTER_UNLOCK, +}; +use sov_modules_api::da::Time; +use sov_modules_api::prelude::UnwrapInfallible; +use sov_modules_api::{ApiStateAccessor, EncodeCall, SafeVec}; +use sov_test_modules::hooks_count::TxHooksCount; +use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; +use sov_test_utils::runtime::{TestRunner, ValueSetter}; +use sov_test_utils::{ + generate_optimistic_runtime_with_kernel, AsUser, TestUser, TransactionTestCase, +}; +use sov_timelock::{ + CancellationPolicy, Event as TimelockEvent, PendingProposal, ProposalKey, Timelock, + MAX_PENDING_PROPOSALS_PER_ADDRESS, MAX_PROPOSAL_KEYS_PER_CLEANUP, +}; +use sov_value_setter::{CallMessage as ValueSetterCallMessage, ValueSetterConfig}; + +use crate::S; + +generate_optimistic_runtime_with_kernel!( + TimelockRuntime <= + kernel_type: sov_test_utils::runtime::BasicKernel<'a, S>, + modules: [ + value_setter: ValueSetter, + tx_hooks_count: TxHooksCount, + timelock: sov_timelock::Timelock + ], + timelock_policy_wrapper: |call: &TimelockRuntimeCall| { + match call { + TimelockRuntimeCall::ValueSetter(ValueSetterCallMessage::SetValue { value: 9, .. }) => { + Some(TimelockPolicy { + unlock_seconds_from_proposal: NonZeroU64::new(60).unwrap(), + expire_seconds_after_unlock_override: Some(0), + }) + } + TimelockRuntimeCall::ValueSetter(ValueSetterCallMessage::SetValue { value, .. }) + if *value == 7 || *value >= 100 => + { + Some(TimelockPolicy { + unlock_seconds_from_proposal: NonZeroU64::new(60).unwrap(), + expire_seconds_after_unlock_override: Some(60), + }) + } + _ => None, + } + }, + timelock_capability: timelock_capability, +); + +type RT = TimelockRuntime; + +fn timelock_capability( + runtime: &mut TimelockRuntime, +) -> &mut Timelock { + &mut runtime.timelock +} + +fn setup_with_accounts(account_count: usize) -> (Vec>, TestRunner) { + let genesis_config = HighLevelOptimisticGenesisConfig::generate() + .add_accounts_with_default_balance(account_count); + + let users = genesis_config.additional_accounts().to_vec(); + let admin = users.first().unwrap().clone(); + + let genesis = GenesisConfig::from_minimal_config( + genesis_config.into(), + ValueSetterConfig { + admin: admin.address(), + }, + (), + (), + ); + + let mut runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + runner.config.freeze_time = Some(Time::from_secs(1_000)); + + (users, runner) +} + +fn setup() -> (TestUser, TestRunner) { + let (mut users, runner) = setup_with_accounts(1); + let admin = users.pop().unwrap(); + + (admin, runner) +} + +fn set_value_message(value: u32) -> ValueSetterCallMessage { + ValueSetterCallMessage::SetValue { value, gas: None } +} + +fn set_value_proposal_id(value: u32) -> sov_modules_api::capabilities::ProposalId { + calculate_timelock_proposal_id::(&set_value_proposal_data(value)) +} + +fn set_value_proposal_key( + address: ::Address, + value: u32, +) -> ProposalKey { + ProposalKey(address, set_value_proposal_id(value)) +} + +fn set_value_proposal_data(value: u32) -> TimelockProposalData { + let encoded = >>::encode_call(set_value_message(value)); + TimelockProposalData::call_message(encoded) +} + +fn register_set_value_proposal(runner: &mut TestRunner, user: &TestUser, value: u32) { + runner.execute_transaction(TransactionTestCase { + input: user.create_plain_message::>(set_value_message(value)), + assert: Box::new(|result, _state| { + assert!(result.tx_receipt.is_successful()); + }), + }); +} + +fn cleanup_keys( + keys: Vec>, +) -> SafeVec, MAX_PROPOSAL_KEYS_PER_CLEANUP> { + SafeVec::try_from(keys).unwrap() +} + +fn stored_value(state: &mut ApiStateAccessor) -> Option { + ValueSetter::::default() + .value + .get(state) + .unwrap_infallible() +} + +fn pending_proposal( + state: &mut ApiStateAccessor, + key: &ProposalKey, +) -> Option> { + Timelock::::default() + .proposals + .get(key, state) + .unwrap_infallible() +} + +fn proposal_count( + state: &mut ApiStateAccessor, + address: &::Address, +) -> Option { + Timelock::::default() + .proposal_counts + .get(address, state) + .unwrap_infallible() +} + +fn stored_cancellation_policy( + state: &mut ApiStateAccessor, + address: &::Address, +) -> Option> { + Timelock::::default() + .policies + .get(address, state) + .unwrap_infallible() +} + +fn post_dispatch_count(state: &mut ApiStateAccessor) -> Option { + TxHooksCount::::default() + .post_dispatch_tx_hook_count + .get(state) + .unwrap_infallible() +} + +#[test] +fn proposal_key_display_roundtrips() { + let (admin, _runner) = setup(); + let key = ProposalKey(admin.address(), ProposalId::new([1; 32])); + let encoded = key.to_string(); + + assert!(encoded.contains("/proposals/")); + assert!(!encoded.starts_with("addresses/")); + assert_eq!(ProposalKey::::from_str(&encoded).unwrap(), key); +} + +#[test] +fn timelocked_call_registers_proposal_without_dispatching_call() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + let proposal_id = proposal_key.1; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![TimelockRuntimeEvent::Timelock( + TimelockEvent::ProposalRegistered { + address: admin_address, + proposal_id, + proposal_data: set_value_proposal_data(7), + executable_from: 1_060, + executable_until: 1_120, + } + )] + ); + assert_eq!(stored_value(state), None); + assert_eq!(post_dispatch_count(state), Some(1)); + let proposal = pending_proposal(state, &proposal_key).unwrap(); + assert_eq!(proposal.proposal_data, set_value_proposal_data(7)); + assert_eq!(proposal.unlock_condition.executable_from, 1_060); + assert_eq!(proposal.unlock_condition.executable_until, 1_120); + assert_eq!(proposal_count(state, &admin_address), Some(1)); + }), + }); +} + +#[test] +fn repeated_timelocked_call_reverts_while_locked() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + + register_set_value_proposal(&mut runner, &admin, 7); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert_eq!(stored_value(state), None); + assert_eq!(post_dispatch_count(state), Some(1)); + assert!(pending_proposal(state, &proposal_key).is_some()); + }), + }); +} + +#[test] +fn unlocked_timelocked_call_dispatches_and_consumes_proposal() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + let proposal_id = proposal_key.1; + + register_set_value_proposal(&mut runner, &admin, 7); + + runner.config.freeze_time = Some(Time::from_secs(1_060)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert!(result.events.iter().any(|event| { + event + == &TimelockRuntimeEvent::Timelock(TimelockEvent::ProposalUnlocked { + address: admin_address, + proposal_id, + }) + })); + assert_eq!(stored_value(state), Some(7)); + assert_eq!(post_dispatch_count(state), Some(2)); + assert!(pending_proposal(state, &proposal_key).is_none()); + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn expired_timelocked_call_reverts_without_consuming_proposal() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + + register_set_value_proposal(&mut runner, &admin, 7); + + runner.config.freeze_time = Some(Time::from_secs(1_121)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert_eq!(stored_value(state), None); + assert!(pending_proposal(state, &proposal_key).is_some()); + assert_eq!(proposal_count(state, &admin_address), Some(1)); + assert_eq!(post_dispatch_count(state), Some(1)); + }), + }); +} + +#[test] +fn zero_expiry_policy_executes_at_exact_unlock_timestamp() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 9); + let proposal_id = proposal_key.1; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(9)), + assert: Box::new({ + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![TimelockRuntimeEvent::Timelock( + TimelockEvent::ProposalRegistered { + address: admin_address, + proposal_id, + proposal_data: set_value_proposal_data(9), + executable_from: 1_060, + executable_until: 1_060, + } + )] + ); + let proposal = pending_proposal(state, &proposal_key).unwrap(); + assert_eq!(proposal.unlock_condition.executable_from, 1_060); + assert_eq!(proposal.unlock_condition.executable_until, 1_060); + } + }), + }); + + runner.config.freeze_time = Some(Time::from_secs(1_060)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::CleanExpiredProposals { + proposal_keys: cleanup_keys(vec![proposal_key.clone()]), + }, + ), + assert: Box::new({ + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert!(pending_proposal(state, &proposal_key).is_some()); + assert_eq!(proposal_count(state, &admin_address), Some(1)); + assert_eq!(stored_value(state), None); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(9)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert!(result.events.iter().any(|event| { + event + == &TimelockRuntimeEvent::Timelock(TimelockEvent::ProposalUnlocked { + address: admin_address, + proposal_id, + }) + })); + assert_eq!(stored_value(state), Some(9)); + assert!(pending_proposal(state, &proposal_key).is_none()); + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn owner_can_cancel_pending_proposal() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + let proposal_id = proposal_key.1; + + register_set_value_proposal(&mut runner, &admin, 7); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: None, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![TimelockRuntimeEvent::Timelock( + TimelockEvent::ProposalCancelled { + address: admin_address, + proposal_id, + cancelled_by: admin_address, + } + )] + ); + assert!(pending_proposal(state, &proposal_key).is_none()); + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn authorized_canceller_can_cancel_pending_proposal() { + let (users, mut runner) = setup_with_accounts(2); + let admin = users[0].clone(); + let canceller = users[1].clone(); + let admin_address = admin.address(); + let canceller_address = canceller.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + let proposal_id = proposal_key.1; + let cancellation_policy = CancellationPolicy:: { + authorized_canceller: canceller_address, + policy_change_timelock_seconds: 0, + }; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: cancellation_policy.clone(), + }, + ), + assert: Box::new({ + let cancellation_policy = cancellation_policy.clone(); + move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(cancellation_policy) + ); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new({ + let cancellation_policy = cancellation_policy.clone(); + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_successful()); + let proposal = pending_proposal(state, &proposal_key).unwrap(); + assert_eq!( + proposal.unlock_condition.cancellation_policy, + cancellation_policy + ); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: canceller.create_plain_message::>( + sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: Some(admin_address), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![TimelockRuntimeEvent::Timelock( + TimelockEvent::ProposalCancelled { + address: admin_address, + proposal_id, + cancelled_by: canceller_address, + } + )] + ); + assert!(pending_proposal(state, &proposal_key).is_none()); + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn proposals_snapshot_cancellation_policy_at_registration() { + let (users, mut runner) = setup_with_accounts(3); + let admin = users[0].clone(); + let initial_canceller = users[1].clone(); + let replacement_canceller = users[2].clone(); + let admin_address = admin.address(); + let initial_canceller_address = initial_canceller.address(); + let replacement_canceller_address = replacement_canceller.address(); + let proposal_key = set_value_proposal_key(admin_address, 7); + let proposal_id = proposal_key.1; + let initial_policy = CancellationPolicy:: { + authorized_canceller: initial_canceller_address, + policy_change_timelock_seconds: 0, + }; + let replacement_policy = CancellationPolicy:: { + authorized_canceller: replacement_canceller_address, + policy_change_timelock_seconds: 0, + }; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: initial_policy.clone(), + }, + ), + assert: Box::new(|result, _state| { + assert!(result.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(7)), + assert: Box::new({ + let initial_policy = initial_policy.clone(); + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_successful()); + let proposal = pending_proposal(state, &proposal_key).unwrap(); + assert_eq!( + proposal.unlock_condition.cancellation_policy, + initial_policy + ); + assert_eq!(proposal_count(state, &admin_address), Some(1)); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: replacement_policy.clone(), + }, + ), + assert: Box::new({ + let initial_policy = initial_policy.clone(); + let replacement_policy = replacement_policy.clone(); + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(replacement_policy) + ); + assert_eq!( + pending_proposal(state, &proposal_key) + .unwrap() + .unlock_condition + .cancellation_policy, + initial_policy + ); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: replacement_canceller.create_plain_message::>( + sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: Some(admin_address), + }, + ), + assert: Box::new({ + let replacement_policy = replacement_policy.clone(); + let proposal_key = proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert!(pending_proposal(state, &proposal_key).is_some()); + assert_eq!(proposal_count(state, &admin_address), Some(1)); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(replacement_policy) + ); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: initial_canceller.create_plain_message::>( + sov_timelock::CallMessage::CancelProposal { + proposal_id, + address: Some(admin_address), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![TimelockRuntimeEvent::Timelock( + TimelockEvent::ProposalCancelled { + address: admin_address, + proposal_id, + cancelled_by: initial_canceller_address, + } + )] + ); + assert!(pending_proposal(state, &proposal_key).is_none()); + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn expired_proposals_can_be_batch_cleaned_without_owner_permission() { + let (users, mut runner) = setup_with_accounts(2); + let admin = users[0].clone(); + let cleaner = users[1].clone(); + let admin_address = admin.address(); + let first_proposal_key = set_value_proposal_key(admin_address, 100); + let second_proposal_key = set_value_proposal_key(admin_address, 101); + let first_proposal_id = first_proposal_key.1; + let second_proposal_id = second_proposal_key.1; + + for value in [100, 101] { + register_set_value_proposal(&mut runner, &admin, value); + } + + runner.config.freeze_time = Some(Time::from_secs(1_121)); + let proposal_keys = cleanup_keys(vec![first_proposal_key, second_proposal_key]); + runner.execute_transaction(TransactionTestCase { + input: cleaner.create_plain_message::>( + sov_timelock::CallMessage::CleanExpiredProposals { + proposal_keys: proposal_keys.clone(), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + result.events, + vec![ + TimelockRuntimeEvent::Timelock(TimelockEvent::ExpiredProposalCleaned { + address: admin_address, + proposal_id: first_proposal_id, + }), + TimelockRuntimeEvent::Timelock(TimelockEvent::ExpiredProposalCleaned { + address: admin_address, + proposal_id: second_proposal_id, + }), + ] + ); + for proposal_key in proposal_keys { + assert!(pending_proposal(state, &proposal_key).is_none()); + } + assert_eq!(proposal_count(state, &admin_address), None); + }), + }); +} + +#[test] +fn cleanup_reverts_on_unexpired_or_missing_proposals() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let expired_proposal_key = set_value_proposal_key(admin_address, 100); + let unexpired_proposal_key = set_value_proposal_key(admin_address, 101); + let missing_proposal_key = ProposalKey(admin_address, ProposalId::new([9; 32])); + + register_set_value_proposal(&mut runner, &admin, 100); + + runner.config.freeze_time = Some(Time::from_secs(1_061)); + register_set_value_proposal(&mut runner, &admin, 101); + + runner.config.freeze_time = Some(Time::from_secs(1_121)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::CleanExpiredProposals { + proposal_keys: cleanup_keys(vec![ + expired_proposal_key.clone(), + unexpired_proposal_key.clone(), + ]), + }, + ), + assert: Box::new({ + let expired_proposal_key = expired_proposal_key.clone(); + let unexpired_proposal_key = unexpired_proposal_key.clone(); + move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert!(pending_proposal(state, &expired_proposal_key).is_some()); + assert!(pending_proposal(state, &unexpired_proposal_key).is_some()); + assert_eq!(proposal_count(state, &admin_address), Some(2)); + } + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::CleanExpiredProposals { + proposal_keys: cleanup_keys(vec![ + expired_proposal_key.clone(), + missing_proposal_key, + ]), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert!(pending_proposal(state, &expired_proposal_key).is_some()); + assert!(pending_proposal(state, &unexpired_proposal_key).is_some()); + assert_eq!(proposal_count(state, &admin_address), Some(2)); + }), + }); +} + +#[test] +fn registration_reverts_after_max_pending_proposals() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + + for value in 100..(100 + MAX_PENDING_PROPOSALS_PER_ADDRESS) { + register_set_value_proposal(&mut runner, &admin, value); + } + + let rejected_value = 100 + MAX_PENDING_PROPOSALS_PER_ADDRESS; + let rejected_proposal_key = set_value_proposal_key(admin_address, rejected_value); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(rejected_value)), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert_eq!( + proposal_count(state, &admin_address), + Some(MAX_PENDING_PROPOSALS_PER_ADDRESS) + ); + assert!(pending_proposal(state, &rejected_proposal_key).is_none()); + }), + }); +} + +#[test] +fn cancellation_policy_updates_are_self_timelocked_after_initial_policy() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let initial_policy = CancellationPolicy:: { + authorized_canceller: admin_address, + policy_change_timelock_seconds: 60, + }; + let replacement_policy = CancellationPolicy:: { + authorized_canceller: admin_address, + policy_change_timelock_seconds: 120, + }; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: initial_policy.clone(), + }, + ), + assert: Box::new(|result, _state| { + assert!(result.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: replacement_policy.clone(), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(initial_policy) + ); + }), + }); + + runner.config.freeze_time = Some(Time::from_secs(1_060)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: replacement_policy.clone(), + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(replacement_policy) + ); + }), + }); +} + +#[test] +fn cancellation_policy_update_proposals_expire_after_default_window() { + let (admin, mut runner) = setup(); + let admin_address = admin.address(); + let initial_policy = CancellationPolicy:: { + authorized_canceller: admin_address, + policy_change_timelock_seconds: 60, + }; + let replacement_policy = CancellationPolicy:: { + authorized_canceller: admin_address, + policy_change_timelock_seconds: 120, + }; + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: initial_policy.clone(), + }, + ), + assert: Box::new(|result, _state| { + assert!(result.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: replacement_policy.clone(), + }, + ), + assert: Box::new(|result, _state| { + assert!(result.tx_receipt.is_successful()); + }), + }); + + let expired_proposal_time = + i64::try_from(1_060 + DEFAULT_EXPIRE_SECONDS_AFTER_UNLOCK + 1).unwrap(); + runner.config.freeze_time = Some(Time::from_secs(expired_proposal_time)); + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>( + sov_timelock::CallMessage::ModifyCancellationPolicy { + new_policy: replacement_policy, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_reverted()); + assert_eq!( + stored_cancellation_policy(state, &admin_address), + Some(initial_policy) + ); + }), + }); +} + +#[test] +fn untimelocked_call_dispatches_normally() { + let (admin, mut runner) = setup(); + + runner.execute_transaction(TransactionTestCase { + input: admin.create_plain_message::>(set_value_message(8)), + assert: Box::new(|result, state| { + assert!(result.tx_receipt.is_successful()); + assert_eq!(stored_value(state), Some(8)); + assert_eq!(post_dispatch_count(state), Some(1)); + }), + }); +} diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/timelock.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/timelock.rs index f57b3e02b1..015e2fbd0c 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/timelock.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/timelock.rs @@ -2,9 +2,15 @@ use std::num::NonZeroU64; +use borsh::{BorshDeserialize, BorshSerialize}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{err_detail, Error, ErrorContext, ErrorDetail, HexHash, Spec, TxState}; +use super::{calculate_hash, calculate_hash_metered}; +use crate::{ + err_detail, Error, ErrorContext, ErrorDetail, GasMeter, GasMeteringError, HexHash, HexString, + ModuleId, Spec, TxState, +}; /// Identifier for a timelock proposal. /// @@ -14,6 +20,93 @@ pub type ProposalId = HexHash; /// Default number of seconds after unlock during which a proposal may be executed. pub const DEFAULT_EXPIRE_SECONDS_AFTER_UNLOCK: u64 = 86_400 * 2; // 2 days +/// Domain-separated proposal data used to compute timelock proposal ids. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshDeserialize, + BorshSerialize, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum TimelockProposalData { + /// Encoded runtime call message bytes. + CallMessage { + /// Encoded runtime call message bytes. + data: HexString, + }, + /// Module-owned custom proposal bytes. + CustomData { + /// Module owning the custom proposal. + module_id: ModuleId, + /// Encoded module-specific proposal payload. + data: HexString, + }, +} + +impl TimelockProposalData { + /// Creates proposal data for an encoded runtime call message. + pub fn call_message(data: impl Into>) -> Self { + Self::CallMessage { + data: HexString::new(data.into()), + } + } + + /// Creates module-owned custom proposal data. + pub fn custom_data(module_id: ModuleId, data: impl Into>) -> Self { + Self::CustomData { + module_id, + data: HexString::new(data.into()), + } + } +} + +/// Calculates a timelock proposal id and charges gas for hashing. +/// +/// The input is domain-separated so runtime call messages cannot overlap with +/// module-owned custom proposal payloads. +pub fn calculate_timelock_proposal_id_metered, S: Spec>( + data: &TimelockProposalData, + gas_meter: &mut G, +) -> Result> { + let encoded_data = borsh::to_vec(data).expect("Serialization to vec is infallible"); + calculate_hash_metered::(&encoded_data, gas_meter) +} + +/// Calculates a module-owned custom timelock proposal id and charges gas for hashing. +pub fn calculate_custom_timelock_proposal_id_metered, S: Spec>( + module_id: &ModuleId, + data: &[u8], + gas_meter: &mut G, +) -> Result> { + let proposal_data = TimelockProposalData::custom_data(*module_id, data); + calculate_timelock_proposal_id_metered::(&proposal_data, gas_meter) +} + +/// Calculates a timelock proposal id without charging gas. +/// +/// This helper is intended for tests and clients that need to derive the same proposal id +/// outside transaction execution. +pub fn calculate_timelock_proposal_id(data: &TimelockProposalData) -> ProposalId { + let encoded_data = borsh::to_vec(data).expect("Serialization to vec is infallible"); + calculate_hash::(&encoded_data) +} + +/// Calculates a module-owned custom timelock proposal id without charging gas. +/// +/// This helper is intended for tests and clients that need to derive the same proposal id +/// outside transaction execution. +pub fn calculate_custom_timelock_proposal_id( + module_id: &ModuleId, + data: &[u8], +) -> ProposalId { + calculate_timelock_proposal_id::(&TimelockProposalData::custom_data(*module_id, data)) +} + /// Timelock policy returned by a runtime for call messages that must be delayed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct TimelockPolicy { @@ -23,7 +116,7 @@ pub struct TimelockPolicy { /// [`DEFAULT_EXPIRE_SECONDS_AFTER_UNLOCK`] is used. /// /// Caution: an override of `0` has no special meaning, and the proposal will be executable - /// only at its exact unlock timestamp. Take care to set reasonable expiraty delays. + /// only at its exact unlock timestamp. Take care to set reasonable expiry windows. pub expire_seconds_after_unlock_override: Option, } @@ -35,6 +128,15 @@ impl TimelockPolicy { } } +/// Outcome of registering or unlocking a timelock proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TimelockProposalOutcome { + /// The proposal did not exist and has been registered. + Registered, + /// The proposal existed, was unlocked, and has been consumed. + Unlocked, +} + /// Errors returned when trying to unlock a timelock proposal. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] #[serde(tag = "error_code", rename_all = "snake_case")] @@ -64,88 +166,36 @@ impl ErrorDetail for TimelockError { /// Methods return [`crate::Error`] so semantic timelock failures and state/gas access /// failures can use the same error path as [`crate::DispatchCall::dispatch_call`]. pub trait TimelockCapability { - /// Returns true if the proposal exists for the provided address. - fn has_proposal( - &self, - address: &S::Address, - proposal_id: &ProposalId, - state: &mut impl TxState, - ) -> Result; - - /// Registers a new proposal for the provided address. - fn register_proposal( + /// Registers a proposal if it does not exist, or tries to unlock and consume an existing proposal. + fn register_or_try_unlock_proposal( &mut self, address: &S::Address, - proposal_id: ProposalId, + proposal_data: TimelockProposalData, policy: TimelockPolicy, state: &mut impl TxState, - ) -> Result<(), Error>; - - /// Tries to unlock a proposal, consuming it if unlocking succeeds. - fn try_unlock_proposal( - &mut self, - address: &S::Address, - proposal_id: &ProposalId, - state: &mut impl TxState, - ) -> Result<(), Error>; + ) -> Result; } impl TimelockCapability for () { - fn has_proposal( - &self, - _address: &S::Address, - _proposal_id: &ProposalId, - _state: &mut impl TxState, - ) -> Result { - Ok(false) - } - - fn register_proposal( + fn register_or_try_unlock_proposal( &mut self, _address: &S::Address, - _proposal_id: ProposalId, + _proposal_data: TimelockProposalData, _policy: TimelockPolicy, _state: &mut impl TxState, - ) -> Result<(), Error> { + ) -> Result { Err(TimelockError::TimelocksNotAvailable.into()) } - - fn try_unlock_proposal( - &mut self, - _address: &S::Address, - _proposal_id: &ProposalId, - _state: &mut impl TxState, - ) -> Result<(), Error> { - Err(TimelockError::ProposalNotFound.into()) - } } impl + ?Sized> TimelockCapability for &mut T { - fn has_proposal( - &self, - address: &S::Address, - proposal_id: &ProposalId, - state: &mut impl TxState, - ) -> Result { - (**self).has_proposal(address, proposal_id, state) - } - - fn register_proposal( + fn register_or_try_unlock_proposal( &mut self, address: &S::Address, - proposal_id: ProposalId, + proposal_data: TimelockProposalData, policy: TimelockPolicy, state: &mut impl TxState, - ) -> Result<(), Error> { - (**self).register_proposal(address, proposal_id, policy, state) - } - - fn try_unlock_proposal( - &mut self, - address: &S::Address, - proposal_id: &ProposalId, - state: &mut impl TxState, - ) -> Result<(), Error> { - (**self).try_unlock_proposal(address, proposal_id, state) + ) -> Result { + (**self).register_or_try_unlock_proposal(address, proposal_data, policy, state) } } diff --git a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs index b2be621a83..359d714cfa 100644 --- a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs +++ b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs @@ -1,8 +1,9 @@ use borsh::BorshDeserialize; +use sov_modules_api::capabilities::{AuthenticationError, AuthenticationOutput, FatalError}; use sov_modules_api::capabilities::{ - calculate_hash_metered, HasCapabilities, SequencingDataHandler, TimelockCapability, + HasCapabilities, SequencingDataHandler, TimelockCapability, TimelockProposalData, + TimelockProposalOutcome, }; -use sov_modules_api::capabilities::{AuthenticationError, AuthenticationOutput, FatalError}; use sov_modules_api::transaction::AuthenticatedTransactionData; use sov_modules_api::{ BatchSequencerReceipt, Context, DispatchCall, Error, IgnoredTransactionReceipt, Spec, @@ -139,23 +140,18 @@ fn attempt_tx, I: StateProvider>( runtime.pre_dispatch_tx_hook(tx, state)?; if let Some(policy) = runtime.timelock_for_callmessage(&message) { - let encoded_message = RT::encode(&message); - let proposal_id = calculate_hash_metered::<_, S>(&encoded_message, state) - .map_err(|error| Error::from(anyhow::anyhow!(error)))?; - let proposal_exists = { - let timelock = runtime.timelock(); - timelock.has_proposal(ctx.sender(), &proposal_id, state)? - }; - - if proposal_exists { - { - let mut timelock = runtime.timelock(); - timelock.try_unlock_proposal(ctx.sender(), &proposal_id, state)?; - } - runtime.dispatch_call(message, state, ctx)?; - } else { + let outcome = { let mut timelock = runtime.timelock(); - timelock.register_proposal(ctx.sender(), proposal_id, policy, state)?; + timelock.register_or_try_unlock_proposal( + ctx.sender(), + TimelockProposalData::call_message(RT::encode(&message)), + policy, + state, + )? + }; + match outcome { + TimelockProposalOutcome::Registered => {} + TimelockProposalOutcome::Unlocked => runtime.dispatch_call(message, state, ctx)?, } } else { runtime.dispatch_call(message, state, ctx)?; diff --git a/crates/utils/sov-test-utils/src/runtime/macros.rs b/crates/utils/sov-test-utils/src/runtime/macros.rs index f6b7fb7471..57389eed9c 100644 --- a/crates/utils/sov-test-utils/src/runtime/macros.rs +++ b/crates/utils/sov-test-utils/src/runtime/macros.rs @@ -22,7 +22,6 @@ macro_rules! generate_runtime_without_capabilities { $(, transaction_priority_wrapper: $transaction_priority_wrapper_expr:expr)? // Optional: A wrapper expression for custom transaction timelock policy logic. // Expected signature for the expression: `fn(&Self::Decodable) -> Option`. - // If provided, the runtime must include a module named `timelock`. $(, timelock_policy_wrapper: $timelock_policy_wrapper_expr:expr)? $(, populate_pinned_cache_fn: $populate_pinned_cache_fn_expr:expr)? // optional final comma for the entire argument block @@ -243,9 +242,9 @@ macro_rules! generate_runtime_without_capabilities { #[macro_export] macro_rules! __impl_runtime_timelock_capability { () => {}; - ($timelock_policy_wrapper_expr:expr) => { + ($timelock_capability_expr:expr) => { fn timelock(&mut self) -> impl ::sov_modules_api::capabilities::TimelockCapability { - &mut self.timelock + ($timelock_capability_expr)(self) } }; } @@ -267,6 +266,10 @@ macro_rules! generate_runtime { auth_call_wrapper: $auth_wrapper:expr $(, transaction_delay_ms_wrapper: $transaction_delay_ms_wrapper_expr:expr)? $(, timelock_policy_wrapper: $timelock_policy_wrapper_expr:expr)? + // Optional: An accessor for a concrete timelock capability. + // Expected signature for the expression: `fn(&mut Self) -> impl TimelockCapability`. + // If not provided, the runtime uses the default no-op timelock capability. + $(, timelock_capability: $timelock_capability_expr:expr)? $(, populate_pinned_cache_fn: $populate_pinned_cache_fn_expr:expr)? // optional final comma $(,)? @@ -308,7 +311,7 @@ macro_rules! generate_runtime { ) } - $crate::__impl_runtime_timelock_capability!($($timelock_policy_wrapper_expr)?); + $crate::__impl_runtime_timelock_capability!($($timelock_capability_expr)?); } }; ( @@ -323,6 +326,10 @@ macro_rules! generate_runtime { $(, transaction_delay_ms_wrapper: $transaction_delay_ms_wrapper_expr:expr)? $(, transaction_priority_wrapper: $transaction_priority_wrapper_expr:expr)? $(, timelock_policy_wrapper: $timelock_policy_wrapper_expr:expr)? + // Optional: An accessor for a concrete timelock capability. + // Expected signature for the expression: `fn(&mut Self) -> impl TimelockCapability`. + // If not provided, the runtime uses the default no-op timelock capability. + $(, timelock_capability: $timelock_capability_expr:expr)? $(, populate_pinned_cache_fn: $populate_pinned_cache_fn_expr:expr)? // optional final comma $(,)? @@ -366,7 +373,7 @@ macro_rules! generate_runtime { ) } - $crate::__impl_runtime_timelock_capability!($($timelock_policy_wrapper_expr)?); + $crate::__impl_runtime_timelock_capability!($($timelock_capability_expr)?); } } } @@ -398,6 +405,10 @@ macro_rules! generate_optimistic_runtime_with_kernel { $(, transaction_delay_ms_wrapper: $transaction_delay_ms_wrapper_expr:expr)? $(, transaction_priority_wrapper: $transaction_priority_wrapper_expr:expr)? $(, timelock_policy_wrapper: $timelock_policy_wrapper_expr:expr)? + // Optional: An accessor for a concrete timelock capability. + // Expected signature for the expression: `fn(&mut Self) -> impl TimelockCapability`. + // If not provided, the runtime uses the default no-op timelock capability. + $(, timelock_capability: $timelock_capability_expr:expr)? $(, populate_pinned_cache_fn: $populate_pinned_cache_fn_expr:expr)? $(,)? // Optional trailing comma for the module list or wrapper ) => { @@ -413,6 +424,7 @@ macro_rules! generate_optimistic_runtime_with_kernel { $(, transaction_delay_ms_wrapper: $transaction_delay_ms_wrapper_expr)? $(, transaction_priority_wrapper: $transaction_priority_wrapper_expr)? $(, timelock_policy_wrapper: $timelock_policy_wrapper_expr)? + $(, timelock_capability: $timelock_capability_expr)? $(, populate_pinned_cache_fn: $populate_pinned_cache_fn_expr)? } };