diff --git a/Cargo.lock b/Cargo.lock index 47c6f4d..552daae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,7 +1234,7 @@ checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "client" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "bigdecimal", @@ -1258,7 +1258,7 @@ dependencies = [ [[package]] name = "client-interface" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "common", @@ -1287,7 +1287,7 @@ checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "common" -version = "0.0.10" +version = "0.0.11" dependencies = [ "aws-nitro-enclaves-cose", "aws-nitro-enclaves-nsm-api", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "enclave" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "aws-nitro-enclaves-nsm-api", @@ -1845,7 +1845,7 @@ dependencies = [ [[package]] name = "enclave-interface" -version = "0.0.10" +version = "0.0.11" dependencies = [ "common", "nix 0.27.1", @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "service" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "aws-config", @@ -5611,7 +5611,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stress-tool" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index e87fa38..4ee8dbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" [workspace.package] homepage = "https://projectglove.io/" repository = "https://github.com/projectglove/glove-monorepo/" -version = "0.0.10" +version = "0.0.11" [workspace.dependencies] anyhow = "1.0.86" @@ -29,7 +29,7 @@ serde = { version = "1.0.203", features = ["derive"] } serde_bytes = "0.11.14" serde_cbor = "0.11.2" serde_json = "1.0.117" -serde_with = { version = "3.8.1", features = ["base64"] } +serde_with = "3.8.1" parity-scale-codec = "3.6.12" bigdecimal = "0.4.3" clap = { version = "4.5.4", features = ["derive"] } diff --git a/README.md b/README.md index e43474d..90ed5ba 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,41 @@ If request body is invalid then a `422 Unprocessable Entity` is returned. If the request itself then a `400 Bad Request` is returned with a JSON object containing the error type (`error`) and description (`description`). `429 Too Many Requests` can also be returned and the request should be retried later. +## `GET /poll-info/{poll_index}` + +Get Glove-specific information about poll with index `poll_index`. + +### Request + +None + +### Response + +A JSON object with the following fields: + +#### `mixing_time` + +**Estimated** time the Glove service will mix vote requests and submit them on-chain. If the service hasn't received +requests for this poll then this is the time the service _would_ mix if it had. This field is only available for polls +which have reached the decision phase. The mixing time isn't fixed and may change as the poll progresses. It is thus +recommended to occasionally poll this end-point. + +The time is given in terms of both block number and UNIX timestamp seconds; the value is an object with fields +`block_number` and `timestamp`. + +If the poll is not active then `400 Bad Request` is returned. + +#### Example + +```json +{ + "mixing_time": { + "block_number": 22508946, + "timestamp": 1726170438 + } +} +``` + # Client CLI There is a CLI client for interacting with the Glove service from the command line. It is built alongside the Glove diff --git a/client-interface/src/lib.rs b/client-interface/src/lib.rs index 6967220..949d78e 100644 --- a/client-interface/src/lib.rs +++ b/client-interface/src/lib.rs @@ -197,6 +197,15 @@ impl SubstrateNetwork { .ok_or_else(|| SubxtError::Other("Current block number not available".into()))?; Ok(current_block_number) } + + pub async fn current_time(&self) -> Result { + let current_time = self.api + .storage() + .at_latest().await? + .fetch(&storage().timestamp().now()).await? + .ok_or_else(|| SubxtError::Other("Current time not available".into()))?; + Ok(current_time) + } } impl Debug for SubstrateNetwork { diff --git a/devops/ansible/roles/glove/templates/glove.service b/devops/ansible/roles/glove/templates/glove.service index 826f39e..44b19e2 100644 --- a/devops/ansible/roles/glove/templates/glove.service +++ b/devops/ansible/roles/glove/templates/glove.service @@ -3,7 +3,7 @@ Description=Glove Nitro service After=network.target [Service] -ExecStart=/usr/local/glove/service --proxy-secret-phrase {{ glove_proxy_secret_phrase }} --address {{ glove_address }} --node-endpoint {{ glove_node_endpoint }} --regular-mix {% if glove_db == "dynamodb" %} +ExecStart=/usr/local/glove/service --proxy-secret-phrase {{ glove_proxy_secret_phrase }} --address {{ glove_address }} --node-endpoint {{ glove_node_endpoint }} {% if glove_db == "dynamodb" %} dynamodb --table-name {{ glove_db_table }} {% else %} in-memory diff --git a/service/src/lib.rs b/service/src/lib.rs index 5342aed..5450192 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -9,7 +9,7 @@ use sp_runtime::AccountId32; use tokio::sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}; use tracing::warn; -use client_interface::{account_to_subxt_multi_address, CallableSubstrateNetwork, subscan}; +use client_interface::{account_to_subxt_multi_address, CallableSubstrateNetwork, ReferendumStatus, subscan, SubstrateNetwork}; use client_interface::metadata::runtime_types::pallet_conviction_voting::pallet::Call as ConvictionVotingCall; use client_interface::metadata::runtime_types::pallet_conviction_voting::vote::{AccountVote, Vote}; use client_interface::metadata::runtime_types::pallet_proxy::pallet::Call as ProxyCall; @@ -26,6 +26,46 @@ pub mod mixing; pub mod dynamodb; pub mod storage; +pub const BLOCK_TIME_SECS: u32 = 6; +/// The period near the end of decision or confirmation that Glove must mix and submit votes. +const GLOVE_MIX_PERIOD: u32 = (15 * 60) / BLOCK_TIME_SECS; + +pub async fn calculate_mixing_time( + poll_status: ReferendumStatus, + network: &SubstrateNetwork +) -> Result { + let Some(deciding_status) = poll_status.deciding else { + return Ok(MixingTime::NotDeciding); + }; + let decision_period = network.get_tracks()? + .get(&poll_status.track) + .map(|track_info| track_info.decision_period) + .ok_or_else(|| subxt::Error::Other("Track not found".into()))?; + let decision_end = deciding_status.since + decision_period; + if let Some(confirming_end) = deciding_status.confirming { + if confirming_end < decision_end { + return Ok(MixingTime::Confirming(confirming_end - GLOVE_MIX_PERIOD)); + } + } + Ok(MixingTime::Deciding(decision_end - GLOVE_MIX_PERIOD)) +} + +pub enum MixingTime { + Deciding(u32), + Confirming(u32), + NotDeciding +} + +impl MixingTime { + pub fn block_number(&self) -> Option { + match self { + MixingTime::Deciding(block_number) => Some(*block_number), + MixingTime::Confirming(block_number) => Some(*block_number), + MixingTime::NotDeciding => None + } + } +} + /// Voter lookup for a poll. #[derive(Clone)] pub struct VoterLookup { diff --git a/service/src/main.rs b/service/src/main.rs index 60022a7..89045a4 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -9,7 +9,7 @@ use aws_sdk_dynamodb::operation::delete_item::DeleteItemError; use aws_sdk_dynamodb::operation::put_item::PutItemError; use aws_sdk_dynamodb::operation::query::QueryError; use aws_sdk_dynamodb::operation::scan::ScanError; -use axum::extract::{Json, State}; +use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Router; @@ -32,21 +32,19 @@ use attestation::Error::InsecureMode; use client_interface::{CallableSubstrateNetwork, is_glove_member, ProxyError, ReferendumStatus, ServiceInfo, SignedRemoveVoteRequest, SubstrateNetwork}; use client_interface::account_to_subxt_multi_address; use client_interface::BatchError; -use client_interface::metadata::referenda::storage::types::referendum_info_for::ReferendumInfoFor; use client_interface::metadata::runtime_types::frame_system::pallet::Call as SystemCall; use client_interface::metadata::runtime_types::pallet_conviction_voting::pallet::Call as ConvictionVotingCall; use client_interface::metadata::runtime_types::pallet_conviction_voting::pallet::Error::InsufficientFunds; use client_interface::metadata::runtime_types::pallet_conviction_voting::pallet::Error::NotVoter; use client_interface::metadata::runtime_types::pallet_proxy::pallet::Call as ProxyCall; use client_interface::metadata::runtime_types::pallet_proxy::pallet::Error::NotProxy; -use client_interface::metadata::runtime_types::pallet_referenda::types::DecidingStatus; use client_interface::metadata::runtime_types::polkadot_runtime::{RuntimeCall, RuntimeError}; use client_interface::metadata::runtime_types::polkadot_runtime::RuntimeError::Proxy; use client_interface::subscan::Subscan; use common::{attestation, SignedGloveResult, SignedVoteRequest}; use common::attestation::{AttestationBundle, AttestationBundleLocation, GloveProofLite}; use RuntimeError::ConvictionVoting; -use service::{GloveContext, GloveState, mixing, storage, to_proxied_vote_call}; +use service::{BLOCK_TIME_SECS, calculate_mixing_time, GloveContext, GloveState, mixing, MixingTime, storage, to_proxied_vote_call}; use service::dynamodb::DynamodbGloveStorage; use service::enclave::EnclaveHandle; use service::storage::{GloveStorage, InMemoryGloveStorage}; @@ -140,13 +138,6 @@ enum EnclaveMode { Mock } -// TODO Sign the enclave image -// TODO Permantely ban accounts which vote directly -// TODO Endpoint for poll end time and other info? - -/// The period near the end of decision or confirmation that Glove must mix and submit votes. -const GLOVE_MIX_PERIOD: u32 = (15 * 60) / 6; - #[tokio::main] async fn main() -> anyhow::Result<()> { let filter = EnvFilter::try_new( @@ -221,6 +212,7 @@ async fn main() -> anyhow::Result<()> { .route("/info", get(info)) .route("/vote", post(vote)) .route("/remove-vote", post(remove_vote)) + .route("/poll-info/:poll_index", get(poll_info)) .layer(TraceLayer::new_for_http()) .with_state(glove_context); let listener = TcpListener::bind(args.address).await?; @@ -324,10 +316,9 @@ async fn run_background_task(context: Arc, subscan: Subscan) -> an let network = &context.network; let storage = &context.storage; let regular_mix_enabled = context.regular_mix_enabled; - let tracks = network.get_tracks()?; for poll_index in storage.get_poll_indices().await? { - let Some(ReferendumInfoFor::Ongoing(status)) = network.get_poll(poll_index).await? else { + let Some(status) = network.get_ongoing_poll(poll_index).await? else { info!("Removing poll {} as it is no longer active", poll_index); context.remove_poll(poll_index).await?; continue; @@ -342,10 +333,7 @@ async fn run_background_task(context: Arc, subscan: Subscan) -> an violators, it will need to be re-mixed", poll_index); } } else if !mix_required { - let decision_period = tracks.get(&status.track) - .map(|track_info| track_info.decision_period) - .unwrap_or(0); - if is_poll_ready_for_final_mix(poll_index, status, decision_period, network).await? { + if is_poll_ready_for_final_mix(poll_index, status, network).await? { mix_required = true; } } @@ -384,21 +372,22 @@ async fn check_non_glove_voters( async fn is_poll_ready_for_final_mix( poll_index: u32, poll_status: ReferendumStatus, - decision_period: u32, network: &SubstrateNetwork ) -> Result { - if let Some(DecidingStatus { since: deciding_since, confirming }) = poll_status.deciding { - let now = network.current_block_number().await?; - if deciding_since + decision_period >= now - GLOVE_MIX_PERIOD { - info!("Poll {} is nearing the end of its decision period and will be mixed", poll_index); + let now = network.current_block_number().await?; + match calculate_mixing_time(poll_status, network).await? { + MixingTime::Deciding(block_number) if block_number >= now => { + info!("Poll {} is nearing the end of its decision period and will be mixed", + poll_index); return Ok(true); } - if confirming.is_some() && confirming.unwrap() >= now - GLOVE_MIX_PERIOD { - info!("Poll {} is nearing the end of its confirmation period and will be mixed", poll_index); + MixingTime::Confirming(block_number) if block_number >= now => { + info!("Poll {} is nearing the end of its confirmation period and will be mixed", + poll_index); return Ok(true); } + _ => Ok(false) } - Ok(false) } async fn info(context: State>) -> Json { @@ -492,6 +481,38 @@ async fn remove_vote( Ok(()) } +async fn poll_info( + State(context): State>, + Path(poll_index): Path +) -> Result, PollInfoError> { + if context.regular_mix_enabled { + return Err(PollInfoError::RegularMixEnabled); + } + let Some(status) = context.network.get_ongoing_poll(poll_index).await? else { + return Err(PollInfoError::PollNotActive); + }; + let current_block = context.network.current_block_number().await?; + let current_time = context.network.current_time().await? / 1000; + let mixing_time = calculate_mixing_time(status, &context.network).await? + .block_number() + .map(|block_number| MixingTimeJson { + block_number, + timestamp: current_time + ((block_number - current_block) * BLOCK_TIME_SECS) as u64 + }); + Ok(Json(PollInfo { mixing_time })) +} + +#[derive(Debug, Clone, Serialize)] +struct PollInfo { + mixing_time: Option +} + +#[derive(Debug, Clone, Serialize)] +struct MixingTimeJson { + block_number: u32, + timestamp: u64 +} + async fn mix_votes(context: &GloveContext, poll_index: u32) { loop { match try_mix_votes(context, poll_index).await { @@ -643,7 +664,7 @@ enum InternalError { #[error("Proxy error: {0}")] Proxy(#[from] ProxyError), #[error("Storage error: {0}")] - Storage(#[from] storage::Error) + Storage(#[from] storage::Error), } impl InternalError { @@ -826,3 +847,38 @@ impl From for RemoveVoteError { RemoveVoteError::Internal(error.into()) } } + +#[derive(thiserror::Error, Debug)] +enum PollInfoError { + #[error("Regular mixing is enabled")] + RegularMixEnabled, + #[error("Poll is not active")] + PollNotActive, + #[error("Internal error: {0}")] + Internal(#[from] InternalError) +} + +impl IntoResponse for PollInfoError { + fn into_response(self) -> Response { + match self { + PollInfoError::RegularMixEnabled => + (StatusCode::NOT_FOUND, "Regular mixing is enabled".to_string()).into_response(), + PollInfoError::PollNotActive => { + ( + StatusCode::BAD_REQUEST, + Json(BadRequestResponse { + error: "PollNotActive".into(), + description: self.to_string() + }) + ).into_response() + } + PollInfoError::Internal(error) => error.into_response() + } + } +} + +impl From for PollInfoError { + fn from(error: SubxtError) -> Self { + PollInfoError::Internal(error.into()) + } +}