diff --git a/README.md b/README.md index 08079b50..07ef43d0 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,75 @@ For more details, see: - The [article by Granola](https://granola.team/blog/mina-on-chain-voting-results-instructions/) - The [FAQ](https://forums.minaprotocol.com/t/on-chain-voting-frequently-asked-questions-faq/5959) + +## Voting on a MEF + +To cast a vote on a particular MEF, a user must send a transaction to **themselves** with a +specially-constructed memo field. The memo field must adhere to the following convention. + +**For example:** + +``` +To vote in favor of 'MEF 1', the memo field must be populated with: 'YES ID 1' +Similarly, if the intent is to vote against 'MEF 1', the memo field must +contain: 'NO ID 1'. +``` + +*Vote With Auro Wallet* +- Ensure you're on the correct network (e.g., devnet). +- Click on your wallet address to copy it to the clipboard (you’ll need it in step 4). +- Navigate to the Send feature in the wallet. +- In the To field, paste your own wallet address. +- Enter 1 in the Amount field. +- To cast your vote: + - Enter YES ID # in the Memo field to vote in favor of the proposal. + - Enter NO ID # in the Memo field to vote against the proposal. + - Replace # with the actual proposal ID you are voting for. + - Confirm and submit the transaction. Your vote will be recorded on-chain. + +# Proposal Consideration API + +## Overview +The **Proposal Consideration API** allows users to fetch detailed data about a proposal, including community votes, staking weights, and vote details within a specified time range. + +--- + +## Endpoint + +### `GET /api/mef_proposal_consideration/:id/:start_time/:end_time?ledger_hash` + +Retrieve details for a specific proposal within a specified time frame. Optionally, you can configure the ledger hash to compute vote weights. + +### Path Parameters +| Parameter | Type | Description | +|--------------|-----------|------------------------------------------------------| +| `id` | `integer` | Unique identifier for the proposal. | +| `start_time` | `integer` | Proposal start time in milliseconds unix timestamp. | +| `end_time` | `integer` | Proposal end time in milliseconds unix timestamp. | + +### Query Parameters (optional) +| Parameter | Type | Description | +|--------------|-----------|------------------------------------------------------| +| `ledger_hash` | `string` | Ledger hash used to compute weights of the votes. | + +--- + +### Response Details + +| Field | Description | +|-------------------------|----------------------------------------------------------------| +| `proposal_id` | Unique identifier of the proposal. | +| `total_community_votes` | Total number of votes cast by the community. | +| `total_positive_community_votes` | Total number of positive votes cast by the community. | +| `total_negative_community_votes` | Total number of negative votes cast by the community. | +| `total_stake_weight` | Total staking weight applied to the proposal. | +| `positive_stake_weight` | Staking weight supporting the proposal. | +| `negative_stake_weight` | Staking weight opposing the proposal. | +| `vote_status` | Current status of the proposal. | +| `elegible` | Elegible status | + +--- + ## Software Development Install [Nix](https://nixos.org/download) and [direnv](https://direnv.net/docs/installation.html). diff --git a/server/Cargo.lock b/server/Cargo.lock index 1368686a..4f773381 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1653,7 +1653,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mina-ocv" -version = "0.10.0" +version = "0.12.0" dependencies = [ "anyhow", "aws-sdk-s3", diff --git a/server/Cargo.toml b/server/Cargo.toml index 8b84460e..cb13676c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mina-ocv" -version = "0.11.0" +version = "0.12.0" edition = "2021" [dependencies] diff --git a/server/proposals/proposals.json b/server/proposals/proposals.json index 4cd3b7ea..f76dc769 100644 --- a/server/proposals/proposals.json +++ b/server/proposals/proposals.json @@ -83,4 +83,4 @@ "network": "devnet" } ] -} +} \ No newline at end of file diff --git a/server/proposals/proposals_schema.json b/server/proposals/proposals_schema.json index bc10ff7e..985f3b1f 100644 --- a/server/proposals/proposals_schema.json +++ b/server/proposals/proposals_schema.json @@ -77,4 +77,4 @@ } }, "required": ["proposals"] -} +} \ No newline at end of file diff --git a/server/src/archive.rs b/server/src/archive.rs index c6923b28..b64941b8 100644 --- a/server/src/archive.rs +++ b/server/src/archive.rs @@ -1,13 +1,14 @@ -use crate::{BlockStatus, ChainStatusType}; use anyhow::{Context, Result}; use diesel::{ + PgConnection, QueryableByName, RunQueryDsl, r2d2::ConnectionManager, sql_query, sql_types::{BigInt, Text}, - PgConnection, QueryableByName, RunQueryDsl, }; use r2d2::Pool; +use crate::{BlockStatus, ChainStatusType}; + #[derive(Clone)] pub struct Archive(Pool>); diff --git a/server/src/config.rs b/server/src/config.rs index 695086cf..8d58b3bc 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,10 +1,12 @@ -use crate::{Archive, Caches, Ocv, Proposal, ProposalsManifest}; +use std::{fs, path::PathBuf, str::FromStr}; + use anyhow::Result; use bytes::Bytes; use clap::{Args, Parser, ValueEnum}; use derive_more::Display; use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf, str::FromStr}; + +use crate::{Archive, Caches, Ocv, Proposal, ProposalsManifest}; #[derive(Clone, Args)] pub struct OcvConfig { diff --git a/server/src/ledger.rs b/server/src/ledger.rs index a454d0d5..f8996421 100644 --- a/server/src/ledger.rs +++ b/server/src/ledger.rs @@ -1,11 +1,13 @@ -use crate::{s3_client, Ocv, ProposalVersion, Vote, Wrapper}; -use anyhow::{anyhow, Result}; +use std::{collections::HashMap, fs, io::Read, path::PathBuf}; + +use anyhow::{Result, anyhow}; use flate2::read::GzDecoder; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fs, io::Read, path::PathBuf}; use tar::Archive; +use crate::{Ocv, ProposalVersion, Vote, Wrapper, s3_client}; + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct Ledger(pub Vec); @@ -107,6 +109,39 @@ impl Ledger { } } } + + pub fn get_stake_weight_mep( + &self, + _map: &Wrapper>, + public_key: impl Into, + ) -> Result { + let public_key: String = public_key.into(); + + let account = + self.0.iter().find(|d| d.pk == public_key).ok_or_else(|| anyhow!("account {public_key} not found in ledger"))?; + + let balance = account.balance.parse().unwrap_or_else(|_| Decimal::new(0, LEDGER_BALANCE_SCALE)); + + if account.delegate.clone().unwrap_or(public_key.clone()) != public_key { + return Ok(Decimal::new(0, LEDGER_BALANCE_SCALE)); + } + + let delegators = self + .0 + .iter() + .filter(|d| d.delegate.clone().unwrap_or(d.pk.clone()) == public_key && d.pk != public_key) + .collect::>(); + + if delegators.is_empty() { + return Ok(balance); + } + + let stake_weight = delegators.iter().fold(Decimal::new(0, LEDGER_BALANCE_SCALE), |acc, x| { + x.balance.parse().unwrap_or_else(|_| Decimal::new(0, LEDGER_BALANCE_SCALE)) + acc + }); + + Ok(stake_weight + balance) + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] diff --git a/server/src/ocv.rs b/server/src/ocv.rs index cdc3c2ff..4160e052 100644 --- a/server/src/ocv.rs +++ b/server/src/ocv.rs @@ -1,8 +1,10 @@ -use crate::{util::Caches, Archive, Ledger, Network, Proposal, Vote, VoteWithWeight, Wrapper}; -use anyhow::{anyhow, Result}; +use std::{path::PathBuf, sync::Arc}; + +use anyhow::{Result, anyhow}; use rust_decimal::Decimal; use serde::Serialize; -use std::{path::PathBuf, sync::Arc}; + +use crate::{Archive, Ledger, Network, Proposal, Vote, VoteWithWeight, Wrapper, util::Caches}; #[derive(Clone)] pub struct Ocv { @@ -43,6 +45,120 @@ impl Ocv { Ok(ProposalResponse { proposal, votes }) } + pub async fn proposal_consideration( + &self, + id: usize, + start_time: i64, + end_time: i64, + ledger_hash: Option, + ) -> Result { + let proposal_key = "MEF".to_string() + &id.to_string(); + let votes = if let Some(cached_votes) = self.caches.votes.get(&proposal_key).await { + cached_votes.to_vec() + } else { + let transactions = self.archive.fetch_transactions(start_time, end_time)?; + + let chain_tip = self.archive.fetch_chain_tip()?; + let votes = Wrapper(transactions.into_iter().map(std::convert::Into::into).collect()) + .process_mep(id, chain_tip) + .sort_by_timestamp() + .to_vec() + .0; + + self.caches.votes.insert(proposal_key.clone(), Arc::new(votes.clone())).await; + tracing::info!("votes {}", votes.len()); + votes + }; + + // weighted votes + let mut positive_stake_weight = Decimal::from(0); + let mut negative_stake_weight = Decimal::from(0); + + // check community votes + let mut total_positive_community_votes = 0; + let mut total_negative_community_votes = 0; + for vote in &votes { + if vote.memo.to_lowercase() == format!("yes id {}", id) { + total_positive_community_votes += 1; + } + if vote.memo.to_lowercase() == format!("no id {}", id) { + total_negative_community_votes += 1; + } + } + // Check if enough positive votes + if total_positive_community_votes < 10 { + return Ok(GetMinaProposalConsiderationResponse { + proposal_id: id, + total_community_votes: votes.len(), + total_positive_community_votes, + total_negative_community_votes, + total_stake_weight: Decimal::ZERO, + positive_stake_weight: Decimal::ZERO, + negative_stake_weight: Decimal::ZERO, + votes, + elegible: false, + vote_status: "Insufficient voters".to_string(), + }); + } + + // Calculate weighted votes if ledger_hash params is provided + if let Some(hash) = ledger_hash { + let votes_weighted = if let Some(cached_votes) = self.caches.votes_weighted.get(&proposal_key).await { + cached_votes.to_vec() + } else { + let transactions = self.archive.fetch_transactions(start_time, end_time)?; + + let chain_tip = self.archive.fetch_chain_tip()?; + + let ledger = if let Some(cached_ledger) = self.caches.ledger.get(&hash).await { + Ledger(cached_ledger.to_vec()) + } else { + let ledger = Ledger::fetch(self, &hash).await?; + + self.caches.ledger.insert(hash, Arc::new(ledger.0.clone())).await; + + ledger + }; + + let votes = Wrapper(transactions.into_iter().map(std::convert::Into::into).collect()) + .into_weighted_mep(id, &ledger, chain_tip) + .sort_by_timestamp() + .0; + + self.caches.votes_weighted.insert(proposal_key.clone(), Arc::new(votes.clone())).await; + + votes + }; + for vote in &votes_weighted { + if vote.memo.to_lowercase() == format!("no id {}", id) { + negative_stake_weight += vote.weight; + } + if vote.memo.to_lowercase() == format!("yes id {}", id) { + positive_stake_weight += vote.weight; + } + positive_stake_weight += vote.weight; + } + } else { + tracing::info!("ledger_hash is not provided."); + } + + let total_stake_weight = positive_stake_weight + negative_stake_weight; + + // Voting results + Ok(GetMinaProposalConsiderationResponse { + proposal_id: id, + total_community_votes: votes.len(), + total_positive_community_votes, + total_negative_community_votes, + total_stake_weight, + positive_stake_weight, + negative_stake_weight, + votes, + elegible: true, + vote_status: "Proposal selected for the next phase".to_string(), + }) + } + pub async fn proposal_result(&self, id: usize) -> Result { let proposal = self.find_proposal(id)?; let hash = match proposal.ledger_hash.clone() { @@ -132,3 +248,17 @@ pub struct GetMinaProposalResultResponse { negative_stake_weight: Decimal, votes: Vec, } + +#[derive(Serialize)] +pub struct GetMinaProposalConsiderationResponse { + proposal_id: usize, + total_community_votes: usize, + total_positive_community_votes: usize, + total_negative_community_votes: usize, + total_stake_weight: Decimal, + positive_stake_weight: Decimal, + negative_stake_weight: Decimal, + votes: Vec, + vote_status: String, + elegible: bool, +} diff --git a/server/src/serve.rs b/server/src/serve.rs index 6aded79a..c32db330 100644 --- a/server/src/serve.rs +++ b/server/src/serve.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use anyhow::Result; use axum::{ Json, Router, debug_handler, - extract::{Path, State}, + extract::{Path, Query, State}, response::IntoResponse, routing::get, serve as axum_serve, @@ -40,6 +40,7 @@ impl ServeArgs { .route("/api/proposals", get(get_proposals)) .route("/api/proposal/:id", get(get_proposal)) .route("/api/proposal/:id/results", get(get_proposal_result)) + .route("/api/mef_proposal_consideration/:id/:start_time/:end_time", get(get_proposal_consideration)) .layer(CorsLayer::permissive()) .with_state(Arc::new(ocv)); axum_serve(listener, router).with_graceful_shutdown(shutdown_signal()).await?; @@ -70,3 +71,14 @@ async fn get_proposal_result(ctx: State>, Path(id): Path) -> imp tracing::info!("get_proposal_result {}", id); Wrapper(ctx.proposal_result(id).await) } + +#[debug_handler] +async fn get_proposal_consideration( + ctx: State>, + Path((id, start_time, end_time)): Path<(usize, i64, i64)>, + Query(params): Query>, +) -> impl IntoResponse { + let ledger_hash = params.get("ledger_hash").cloned(); + tracing::info!("get_proposal_consideration {} {} {}", id, start_time, end_time); + Wrapper(ctx.proposal_consideration(id, start_time, end_time, ledger_hash).await) +} diff --git a/server/src/util/caches.rs b/server/src/util/caches.rs index 96eae7ec..92bf9c60 100644 --- a/server/src/util/caches.rs +++ b/server/src/util/caches.rs @@ -1,7 +1,9 @@ -use crate::{ledger::LedgerAccount, Vote, VoteWithWeight}; -use moka::future::Cache as MokaCache; use std::sync::Arc; +use moka::future::Cache as MokaCache; + +use crate::{Vote, VoteWithWeight, ledger::LedgerAccount}; + #[derive(Clone)] pub struct Caches { pub votes: MokaCache>>, diff --git a/server/src/util/s3.rs b/server/src/util/s3.rs index e02f2ff3..1143a1bf 100644 --- a/server/src/util/s3.rs +++ b/server/src/util/s3.rs @@ -1,8 +1,9 @@ +use std::sync::OnceLock; + use aws_sdk_s3::{ - config::{Builder, Region}, Client, + config::{Builder, Region}, }; -use std::sync::OnceLock; pub fn s3_client() -> &'static Client { static HASHMAP: OnceLock = OnceLock::new(); diff --git a/server/src/vote.rs b/server/src/vote.rs index 0c4b72b1..18bf474d 100644 --- a/server/src/vote.rs +++ b/server/src/vote.rs @@ -1,10 +1,12 @@ -use crate::{archive::FetchTransactionResult, ledger::Ledger, Proposal, Wrapper}; +use std::collections::{HashMap, hash_map::Entry}; + use anyhow::{Context, Result}; use diesel::SqlType; use diesel_derive_enum::DbEnum; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use std::collections::{hash_map::Entry, HashMap}; + +use crate::{Proposal, Wrapper, archive::FetchTransactionResult, ledger::Ledger}; #[derive(SqlType)] #[diesel(postgres_type(name = "chain_status_type"))] @@ -90,6 +92,15 @@ impl Vote { None } + pub fn match_decoded_mef_memo(&mut self, key: &str) -> Option { + if let Ok(decoded) = self.decode_memo() { + if decoded.to_lowercase() == format!("yes id {}", key) || decoded.to_lowercase() == format!("no id {}", key) { + return Some(decoded); + } + } + None + } + pub(crate) fn decode_memo(&self) -> Result { let decoded = bs58::decode(&self.memo).into_vec().with_context(|| format!("failed to decode memo {} - bs58", &self.memo))?; @@ -138,6 +149,35 @@ impl Wrapper> { Wrapper(map) } + pub fn process_mep(self, id: usize, tip: i64) -> Wrapper> { + let mut map = HashMap::new(); + let id_str = id.to_string(); + + for mut vote in self.0 { + if let Some(memo) = vote.match_decoded_mef_memo(&id_str) { + vote.update_memo(memo); + + if tip - vote.height >= 10 { + vote.update_status(BlockStatus::Canonical); + } + + match map.entry(vote.account.clone()) { + Entry::Vacant(e) => { + e.insert(vote); + } + Entry::Occupied(mut e) => { + let current_vote = e.get_mut(); + if vote.is_newer_than(current_vote) { + *current_vote = vote; + } + } + } + } + } + + Wrapper(map) + } + pub fn into_weighted(self, proposal: &Proposal, ledger: &Ledger, tip: i64) -> Wrapper> { let votes = self.process(&proposal.key, tip); @@ -152,6 +192,21 @@ impl Wrapper> { Wrapper(votes_with_stake) } + + pub fn into_weighted_mep(self, id: usize, ledger: &Ledger, tip: i64) -> Wrapper> { + let votes = self.process_mep(id, tip); + + let votes_with_stake: Vec = votes + .0 + .iter() + .filter_map(|(account, vote)| { + let stake = ledger.get_stake_weight_mep(&votes, account).ok()?; + Some(vote.to_weighted(stake)) + }) + .collect(); + + Wrapper(votes_with_stake) + } } impl Wrapper> {