diff --git a/Cargo.lock b/Cargo.lock index 5ea3e31..199782d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -802,6 +817,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1852,6 +1881,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +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 = "icu_collections" version = "2.0.0" @@ -2550,6 +2603,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" name = "opengov-cli" version = "0.2.0" dependencies = [ + "chrono", "clap", "hex", "parity-scale-codec", @@ -4752,6 +4806,41 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "windows-link" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index ad7170e..6b9e317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ hex = "0.4.3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } clap = { version = "4.4.18", features = ["derive", "cargo"] } reqwest = "0.12.3" +chrono = "0.4.41" diff --git a/README.md b/README.md index 869b99b..308132b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ Options: Track on which to submit the referendum --at Optional: Enact at a particular block number + --at-date + Optional: Enact at a particular wall clock time (format: DD-MM-YYThhmm, e.g. 25-05-21T0800) + Note: The date/time is interpreted in your local machine's time zone. --after Optional: Enact after a given number of blocks --output-len-limit diff --git a/src/functions.rs b/src/functions.rs index 4b7af95..d77f238 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -1,4 +1,20 @@ +use crate::{kusama_asset_hub, polkadot_asset_hub}; +use chrono::{Local, NaiveDateTime, TimeZone, Utc}; use std::fs; +use subxt::{OnlineClient, PolkadotConfig}; + +const BLOCK_TIME_MILLIS: u64 = 6_000; // Polkadot/Kusama block time in milliseconds + +#[derive(Debug)] +pub struct TimeConversionError(String); + +impl std::fmt::Display for TimeConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for TimeConversionError {} // Check what the user entered for the proposal. If it is just call data, return it back. Otherwise, // we expect a path to a file that contains the call data. Read that in and return it. @@ -13,3 +29,64 @@ pub(crate) fn get_proposal_bytes(proposal: String) -> Vec { hex::decode(contents.as_str().trim_start_matches("0x")).expect("Valid proposal") } } + +/// Convert a wall clock time string to a block number on Asset Hub. +/// Format: YY-MM-DDThhmm (e.g. 25-05-21T0800 for 21st May 2025 at 08:00) +pub(crate) async fn wall_clock_to_block_number( + time_str: &str, + network: &str, +) -> Result { + // Parse the input time string + let naive_dt = NaiveDateTime::parse_from_str(time_str, "%y-%m-%dT%H%M") + .map_err(|e| TimeConversionError(format!("Invalid time format: {}", e)))?; + + // Convert to UTC + let local_dt = Local + .from_local_datetime(&naive_dt) + .single() + .ok_or_else(|| TimeConversionError("Invalid local time".to_string()))?; + let utc_dt = local_dt.with_timezone(&Utc); + + // Connect to Asset Hub (where referenda are submitted post-AHM) + let url = match network.to_lowercase().as_str() { + "polkadot" => "wss://asset-hub-polkadot-rpc.dwellir.com:443", + "kusama" => "wss://asset-hub-kusama-rpc.dwellir.com:443", + _ => return Err(TimeConversionError("Invalid network".to_string())), + }; + + let api = OnlineClient::::from_url(url) + .await + .map_err(|e| TimeConversionError(format!("Failed to connect to node: {}", e)))?; + + let current_block = api + .blocks() + .at_latest() + .await + .map_err(|e| TimeConversionError(format!("Failed to get latest block: {}", e)))?; + + let current_block_number = current_block.number(); + let current_timestamp = api + .storage() + .at(current_block.hash()) + .fetch(&match network.to_lowercase().as_str() { + "polkadot" => polkadot_asset_hub::storage().timestamp().now(), + "kusama" => kusama_asset_hub::storage().timestamp().now(), + _ => return Err(TimeConversionError("Invalid network".to_string())), + }) + .await + .map_err(|e| TimeConversionError(format!("Failed to get block timestamp: {}", e)))? + .ok_or_else(|| TimeConversionError("Failed to get timestamp".to_string()))?; + + // Calculate time difference in milliseconds + let target_timestamp = utc_dt.timestamp_millis() as u64; + let time_diff_ms = if target_timestamp > current_timestamp { + target_timestamp - current_timestamp + } else { + return Err(TimeConversionError("Target time is in the past".to_string())); + }; + + // Calculate block difference (rounding up) + let block_diff = (time_diff_ms + BLOCK_TIME_MILLIS - 1) / BLOCK_TIME_MILLIS; + + Ok(current_block_number + block_diff as u32) +} diff --git a/src/submit_referendum.rs b/src/submit_referendum.rs index 46e5b2d..4ec37cb 100644 --- a/src/submit_referendum.rs +++ b/src/submit_referendum.rs @@ -1,3 +1,4 @@ +use crate::functions::wall_clock_to_block_number; use crate::*; use clap::Parser as ClapParser; use std::fs; @@ -22,6 +23,11 @@ pub(crate) struct ReferendumArgs { #[clap(long = "at")] at: Option, + /// Optional: Enact at a particular wall clock time (format: YY-MM-DDThhmm, e.g. 25-05-21T0800 for 21st May 2025 at 08:00). + /// This will create an estimated block number based on the target block production rate. Note that as some block production slots are inevitably missed, this is the earliest time at which the referendum will enact. + #[clap(long = "at-date")] + at_date: Option, + /// Optional: Enact after a given number of blocks. #[clap(long = "after")] after: Option, @@ -46,7 +52,7 @@ pub(crate) struct ReferendumArgs { // The sub-command's "main" function. pub(crate) async fn submit_referendum(prefs: ReferendumArgs) { // Find out what the user wants to do. - let proposal_details = parse_inputs(prefs); + let proposal_details = parse_inputs(prefs).await; // Generate the calls necessary. let calls = generate_calls(&proposal_details).await; // Tell the user what to do. @@ -54,7 +60,7 @@ pub(crate) async fn submit_referendum(prefs: ReferendumArgs) { } // Parse the CLI inputs and return a typed struct with all the details needed. -fn parse_inputs(prefs: ReferendumArgs) -> ProposalDetails { +async fn parse_inputs(prefs: ReferendumArgs) -> ProposalDetails { use DispatchTimeWrapper::*; use NetworkTrack::*; use Output::*; @@ -98,17 +104,33 @@ fn parse_inputs(prefs: ReferendumArgs) -> ProposalDetails { _ => panic!("`network` must be `polkadot` or `kusama`"), }; - let dispatch = match (prefs.at, prefs.after) { - (None, None) => { + let dispatch = match (prefs.at, prefs.at_date, prefs.after) { + (None, None, None) => { println!("\nNo enactment time specified. Defaulting to `After(10)`."); - println!("Specify an enactment time with `--at ` or `--after `.\n"); + println!("Specify an enactment time with `--at `, `--at-date