From e63e2ec283da41b2829e8aec1b8545f38a340e28 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 02:14:54 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20vote=20par=20la=20cha=C3=AEne,=20corre?= =?UTF-8?q?ctions=20de=20la=20relecture=20de=2074f96be=20(serveur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adresse : refusée au-delà de 64 caractères avant tout décodage (le base58 coûte le carré de la longueur : 80 000 caractères prenaient 38 s) ; - tâche de fond : 50 bulletins par passe, le moins récemment lu d'abord ; lu une dernière fois à son échéance, une heure de grâce si l'indexeur se tait ; - plafond de 20 par identité : en attente, plus lâchés depuis une heure ; purge des lâchés après un jour, des expirés après une semaine ; - bulletin Ğecko : seul un commentaire payé le tranche, une copie nue s'ignore ; - finalité : seul un bloc ≤ squidStatus.height compte ; recherche filtrée (auteur lié, ou virement vers le compte des votes) et paginée par 50 ; - « ton vote » : un bulletin en attente plus ancien qu'un vote confirmé se tait ; - reçu (GET /api/ballots/{id}/receipt) et historique (GET /api/ballots), pour qui l'a demandé seulement ; - un nom vu seulement par un vote sur la chaîne reste libre pour un lien d'équipe ; - totaux affichés isolés (shown_tally) pour une publication par paliers. Les sondes de la relecture deviennent des tests, vus rouges avant le correctif. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/address.rs | 25 + crates/ld-engine/src/ballot.rs | 75 ++- crates/ld-server/src/ballots.rs | 311 +++++++++-- crates/ld-server/src/docs.rs | 18 +- crates/ld-server/src/indexer.rs | 239 +++++++-- crates/ld-server/src/lib.rs | 2 + crates/ld-server/src/proposals.rs | 6 +- crates/ld-server/src/team.rs | 9 +- crates/ld-server/tests/ballots.rs | 487 +++++++++++++++++- .../tests/fixtures/indexer-merci.json | 483 +++++++++++++++++ 10 files changed, 1552 insertions(+), 103 deletions(-) diff --git a/crates/ld-engine/src/address.rs b/crates/ld-engine/src/address.rs index a8b3945..ea63b42 100644 --- a/crates/ld-engine/src/address.rs +++ b/crates/ld-engine/src/address.rs @@ -7,6 +7,12 @@ /// The SS58 prefix of the Ğ1 chain. pub const G1_PREFIX: u16 = 4450; +/// The longest text read as an address. An SS58 address of a 32-byte key is +/// about 48 characters; base58 decoding costs the square of the length, so a +/// longer text is refused before it is decoded (review of 74f96be: 250 000 +/// characters took 32 s on a thread of the server). +pub const MAX_LEN: usize = 64; + const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; fn base58_decode(s: &str) -> Option> { @@ -72,6 +78,9 @@ fn prefix_bytes(prefix: u16) -> Vec { /// The prefix and the 32-byte public key of an SS58 address, if it is one. pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> { + if addr.len() > MAX_LEN { + return None; + } let data = base58_decode(addr)?; let (prefix, at) = match *data.first()? { b @ 0..=63 => (u16::from(b), 1), @@ -146,6 +155,22 @@ mod tests { } } + #[test] + fn a_long_text_is_refused_before_it_is_decoded() { + // Base58 decoding costs the square of the length, on a thread of the server: + // 250 000 characters took 32 s (review of 74f96be). An SS58 address is about 48. + for (g1, _, generic) in VECTORS { + assert!(g1.len() <= 64 && generic.len() <= 64); + } + let long = "z".repeat(40_000); + let t = std::time::Instant::now(); + assert_eq!(decode(&long), None); + assert_eq!(to_g1(&long), None); + assert!(t.elapsed() < std::time::Duration::from_millis(20), "{:?}", t.elapsed()); + // One character too many, even of leading ones (zero bytes), is refused. + assert_eq!(decode(&format!("{}{}", "1".repeat(65 - VECTORS[0].0.len()), VECTORS[0].0)), None); + } + #[test] fn what_is_not_an_address() { let (g1, _, _) = VECTORS[0]; diff --git a/crates/ld-engine/src/ballot.rs b/crates/ld-engine/src/ballot.rs index d7c9a94..3b42dac 100644 --- a/crates/ld-engine/src/ballot.rs +++ b/crates/ld-engine/src/ballot.rs @@ -17,6 +17,11 @@ //! per identity, in the order of the chain — block, then rank of the event in //! the block. A ballot confirmed late never overrides a more recent one. //! +//! Anyone who sees a remark may copy it. A ballot bound to an account is settled +//! by that account's comment only; a ballot bound to none (a Ğecko code) by a +//! **paid** comment only — a bare remark costs nothing to send, a transfer of +//! the amount does. +//! //! Pure: the salt comes from the caller, the facts of the chain too. use crate::address::{self, G1_PREFIX}; @@ -217,6 +222,10 @@ pub enum Verdict { Paid { identity: ChainIdentity }, /// Signed by another account than the one bound: a copy of the remark, ignored. OtherAuthor, + /// A ballot bound to no account (a Ğecko code), and a comment without the + /// transfer: a copy of the remark anyone may send for free, ignored. Only a + /// paid comment settles such a ballot. + Unpaid, /// The account is not that of an identity. NoIdentity, /// No transfer of the amount to the vote account in the same transaction. @@ -227,30 +236,46 @@ impl Verdict { /// Why a ballot is refused, as stored and translated. pub fn reason(self) -> Option<&'static str> { match self { - Self::Paid { .. } | Self::OtherAuthor => None, + Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None, Self::NoIdentity => Some("no_identity"), Self::NoTransfer => Some("transfer"), } } + + /// Whether this comment settles the ballot (counted or refused), or is a copy to pass over. + pub fn settles(self) -> bool { + !matches!(self, Self::OtherAuthor | Self::Unpaid) + } +} + +/// Whether the comment's transaction carries the transfer the ballot asks: +/// from its signer, to the vote account, at least the amount. +pub fn paid(c: &Comment, e: &Expected) -> bool { + c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) } pub fn verdict(c: &Comment, e: &Expected) -> Verdict { if e.author.is_some_and(|a| a != c.author) { return Verdict::OtherAuthor; } + let paid = paid(c, e); + if e.author.is_none() && !paid { + return Verdict::Unpaid; + } let Some(identity) = c.identity else { return Verdict::NoIdentity }; - if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) { + if !paid { return Verdict::NoTransfer; } Verdict::Paid { identity } } /// The comment that settles a ballot: the first in the order of the chain that is -/// not a copy by another account. `None`: nothing yet, keep waiting. +/// not a copy — by another account than the one bound, or, unbound, without the +/// transfer. `None`: nothing yet, keep waiting. pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> { let mut sorted: Vec<&Comment> = comments.iter().collect(); sorted.sort_by_key(|c| c.position); - sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| *v != Verdict::OtherAuthor) + sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles()) } #[cfg(test)] @@ -400,15 +425,18 @@ mod tests { let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } }; assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former); assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity); - // Too little, elsewhere, or nothing. - assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer); - assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer); - assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer); - // Paid from another account than the signer's. - assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer); + // Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound. + let bound = Expected { author: Some(key(SIMONE)), ..e }; + for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] { + let c = comment(1, SIMONE, Some((1304, true)), unpaid); + assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}"); + assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}"); + } assert_eq!(Verdict::NoTransfer.reason(), Some("transfer")); assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity")); assert_eq!(ok.reason(), None); + assert_eq!(Verdict::Unpaid.reason(), None); + assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles()); } #[test] @@ -429,4 +457,31 @@ mod tests { assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity))); assert_eq!(judge(&[], &free), None); } + + #[test] + fn an_unbound_ballot_is_settled_by_a_paid_comment_only() { + // A Ğecko code binds no account. A remark alone costs nothing to send: + // a bare copy must not settle the ballot (review of 74f96be, P3). + let free = Expected { account: key(ALICE), amount: 10, author: None }; + let bare = comment(8, SIMONE, None, &[]); + let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]); + let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]); + assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting"); + let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]); + assert_eq!( + judge(&[paid.clone(), elsewhere, short, bare], &free), + Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER })) + ); + // Paid by an account without identity: paid, so it settles — refused. + let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]); + assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity))); + // The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot. + let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]); + let theirs = ChainIdentity { index: 1, member: true }; + assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs }))); + // Bound to an account: its own comment without the transfer is refused, and told. + let bound = Expected { author: Some(key(SIMONE)), ..free }; + let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]); + assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer))); + } } diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs index 561b1f3..25af6ee 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -15,9 +15,12 @@ //! - **The last one stands**, per proposal and per identity, in the order of //! the chain: a ballot confirmed late never overrides a more recent one. //! -//! A background task reads the indexer for pending ballots every 10 seconds -//! while there are some, lets them expire after 24 hours, and runs the -//! adoption pass after each confirmation. +//! A background task reads the indexer for pending ballots every 10 seconds, +//! at most 50 a pass, the least recently read first: every pending ballot is +//! read at least every ⌈n / 50⌉ passes, whether a page waits for it or not (a +//! Ğecko code). A ballot is read one last time at its end (24 hours), then let +//! go; the adoption pass runs after each confirmation. Ballots let go are purged +//! after a while (a day for those the wallet never signed, a week for the others). use crate::AppState; use crate::adoption::{self, Entered}; @@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading}; use crate::util; use axum::Json; use axum::extract::{Path, State}; +use axum::http::{HeaderValue, header}; +use axum::response::{IntoResponse, Response}; use ld_engine::address; use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict}; use rand::RngCore; @@ -40,15 +45,26 @@ use trustin_access::VoteWay; /// Every article of a long document, with room to spare. const MAX_LINES: usize = 200; -/// Pending ballots one session may hold at once. +/// Ballots one identity may hold at once: those pending, and those let go within +/// the last hour (`DELETE`, the wallet did not sign) — whatever the session. const MAX_PENDING: i64 = 20; +const DROPPED_WINDOW_SECS: i64 = 3600; /// A ballot not seen on the chain within this time is let go. pub const EXPIRES_HOURS: i64 = 24; +/// At its end, a ballot no indexer could be read for — or whose comment waits +/// for a final block — is kept and read again, this long at most. +const GRACE_SECS: i64 = 3600; +/// Let go, a ballot is purged, lines and all: a day after the wallet did not sign +/// it, a week after it was not seen in time (under the article, it says so meanwhile). +const PURGE_DROPPED_SECS: i64 = 24 * 3600; +const PURGE_EXPIRED_SECS: i64 = 7 * 24 * 3600; /// The page asks often; the indexer is read at most this often per ballot. const RECHECK_SECS: i64 = 3; const WATCH_EVERY: Duration = Duration::from_secs(10); -/// Pending ballots read per pass of the background task. +/// Pending ballots read per pass of the background task, the least recently read first. const PER_PASS: i64 = 50; +/// Ballots a history shows, the most recent first. +const HISTORY: i64 = 200; /// One confirmation at a time: « the last one stands » reads the other ballots of /// the identity, then writes; two at once could both read before either writes. @@ -226,14 +242,16 @@ pub async fn create( Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?), None => None, }; - let pending: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?", + // Pending, or let go within the hour: letting a ballot go does not free its place at once. + let held: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ballots WHERE requested_by = ? + AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))", ) .bind(&user.id) - .bind(util::now()) + .bind(util::in_seconds(-DROPPED_WINDOW_SECS)) .fetch_one(&state.db) .await?; - if pending >= MAX_PENDING { + if held >= MAX_PENDING { return Err(AppError::Conflict("too_many_ballots")); } let lines: Vec = casts @@ -333,6 +351,160 @@ pub async fn drop( Ok(Json(json!({ "ballot": view(&state, &row).await? }))) } +// --------------------------------------------------------------------------- +// The receipt: the ballot itself, for the one who asked — never anyone else +// --------------------------------------------------------------------------- + +/// A title as stored (`{lang: text}`), as JSON; a bare text if it is not a map. +fn title_json(raw: &str) -> serde_json::Value { + serde_json::from_str::(raw).ok().filter(|v| v.is_object()).unwrap_or_else(|| json!(raw)) +} + +/// `GET /api/ballots` — the history of the person's ballots, the most recent +/// first: those of its identity only, and not those the wallet never signed. +pub async fn mine(State(state): State, User(user): User) -> AppResult> { + #[derive(sqlx::FromRow)] + struct Row { + id: String, + doc_id: String, + group_slug: String, + slug: String, + title: String, + digest: String, + status: String, + reason: Option, + block: Option, + voter: Option, + lines: i64, + created_at: String, + settled_at: Option, + } + let rows = sqlx::query_as::<_, Row>(&format!( + "SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block, + i.display_name AS voter, (SELECT COUNT(*) FROM ballot_lines l WHERE l.ballot_id = b.id) AS lines, + b.created_at, b.settled_at + FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id + WHERE b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped' + ORDER BY b.created_at DESC, b.rowid DESC LIMIT {HISTORY}" + )) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + let list: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) }, + "comment": format!("{}{}", ballot::PREFIX, r.digest), "status": r.status, "reason": r.reason, + "block": r.block, "voter": r.voter, "lines": r.lines, "createdAt": r.created_at, "settledAt": r.settled_at, + }) + }) + .collect(); + Ok(Json(json!({ "ballots": list }))) +} + +/// `GET /api/ballots/{id}/receipt` — the ballot itself, to download: its lines, +/// its salt, the canonical text they make, its seal and where it stands. With +/// it, the person recomputes the seal and finds it on the chain; nobody else +/// ever gets it (the chain shows who voted, never what). +pub async fn receipt(State(state): State, User(user): User, Path(id): Path) -> AppResult { + #[derive(sqlx::FromRow)] + struct Extra { + salt: String, + signer: Option, + event_id: Option, + settled_at: Option, + title: String, + } + #[derive(sqlx::FromRow)] + struct LineRow { + proposal_id: String, + revision: i64, + choice: String, + code: String, + title: Option, + } + let row = load(&state, &id).await?; + if row.requested_by != user.id { + return Err(AppError::NotFound); + } + let extra = sqlx::query_as::<_, Extra>( + "SELECT b.salt, b.signer, b.event_id, b.settled_at, d.title FROM ballots b JOIN documents d ON d.id = b.doc_id + WHERE b.id = ?", + ) + .bind(&id) + .fetch_one(&state.db) + .await?; + let lines = sqlx::query_as::<_, LineRow>( + "SELECT l.proposal_id, l.revision, l.choice, u.code, p.title FROM ballot_lines l + JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id + WHERE l.ballot_id = ? ORDER BY l.proposal_id", + ) + .bind(&id) + .fetch_all(&state.db) + .await?; + let sealed: Vec = lines + .iter() + .filter_map(|l| { + Some(ballot::Line { + proposal_id: l.proposal_id.clone(), + revision: u32::try_from(l.revision).ok()?, + choice: Choice::parse(&l.choice)?, + }) + }) + .collect(); + let canonical = ballot::canonical(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt) + .map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?; + let seal = ballot::seal(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt) + .map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?; + if seal.digest != row.digest || sealed.len() != lines.len() { + return Err(anyhow::anyhow!("ballot {id}: its lines no longer make its seal").into()); + } + let voter: Option = match &row.voter_id { + Some(v) => { + sqlx::query_scalar("SELECT display_name FROM identities WHERE id = ?") + .bind(v) + .fetch_optional(&state.db) + .await? + } + None => None, + }; + let body = json!({ + "format": "ld1", + "id": row.id, + "doc": row.doc_id, + "docTitle": title_json(&extra.title), + "author": row.author, + "lines": lines.iter().map(|l| json!({ + "proposalId": l.proposal_id, "revision": l.revision, "choice": l.choice, "article": l.code, "title": l.title, + })).collect::>(), + "salt": extra.salt, + "canonical": canonical, + "digest": seal.digest, + "comment": seal.comment, + "remarkHash": seal.remark_hash, + "account": row.account, + "amount": row.amount, + "status": row.status, + "reason": row.reason, + "block": row.block, + "event": extra.event_id, + "signer": extra.signer, + "voter": voter, + "createdAt": row.created_at, + "settledAt": extra.settled_at, + }); + let day = row.created_at.get(..10).unwrap_or("vote"); + let name = format!("recu-vote-{day}-{}.json", row.id.get(..8).unwrap_or(&row.id)); + let mut res = Json(body).into_response(); + let headers = res.headers_mut(); + if let Ok(v) = HeaderValue::from_str(&format!("attachment; filename=\"{name}\"")) { + headers.insert(header::CONTENT_DISPOSITION, v); + } + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + Ok(res) +} + async fn view(state: &AppState, row: &BallotRow) -> AppResult { let voter: Option = match &row.voter_id { Some(v) => { @@ -352,29 +524,47 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult })) } -/// Reads the indexer for one pending ballot and settles it if the chain has spoken. +/// Reads the indexer for one pending ballot and settles it if the chain has spoken +/// — in a final block. At its end, a ballot is read one last time, then let go. pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> { let now = util::now(); - if row.expires_at <= now { - expire(state).await?; - return Ok(()); - } - let reading = indexer::comments(state, &row.remark_hash).await; - sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?; - let Reading::Found(list) = reading else { - tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending"); - return Ok(()); - }; - let comment = row.comment(); - let list: Vec = list.into_iter().filter(|f| f.remark == comment).collect(); + let due = row.expires_at <= now; + let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS); let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else { return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into()); }; let expected = Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) }; - let comments: Vec = list.iter().map(|f| f.comment.clone()).collect(); - let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) }; - let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) }; + let comment = row.comment(); + // Only the comments that carry exactly the remark of the ballot. + let judged = |list: &[Found]| { + let mine: Vec = + list.iter().filter(|f| f.remark == comment).map(|f| f.comment.clone()).collect(); + ballot::judge(&mine, &expected) + }; + let filter = indexer::filter(&row.account, amount, row.author.as_deref()); + let reading = indexer::comments(state, &row.remark_hash, &filter, |list| judged(list).is_some()).await; + sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?; + let (list, unfinal) = match reading { + Reading::Found { comments, unfinal } => (comments, unfinal), + Reading::Silent => { + tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending"); + if past_grace { + let_go(state, row).await?; + } + return Ok(()); + } + }; + let Some((position, verdict)) = judged(&list) else { + // Nothing final settles it. At its end: let go, unless a comment waits for its block to be final. + if due && (!unfinal || past_grace) { + let_go(state, row).await?; + } + return Ok(()); + }; + let Some(found) = list.iter().find(|f| f.comment.position == position && f.remark == comment) else { + return Ok(()); + }; match verdict { // The right to vote is checked now, at confirmation, by trustinWallet's rule. Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => { @@ -516,25 +706,59 @@ async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) - Ok(()) } -/// Lets go of the pending ballots not seen on the chain in time. +/// Lets go of one pending ballot not seen on the chain in time. +async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> { + sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'") + .bind(util::now()) + .bind(&row.id) + .execute(&state.db) + .await?; + Ok(()) +} + +/// Lets go of the pending ballots past their end and the grace after it: those +/// the indexer could not be read for in time. pub async fn expire(state: &AppState) -> AppResult { - let now = util::now(); let done = sqlx::query( "UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?", ) - .bind(&now) - .bind(&now) + .bind(util::now()) + .bind(util::in_seconds(-GRACE_SECS)) .execute(&state.db) .await?; Ok(done.rows_affected()) } -/// One pass of the background task: expire, then read the indexer for each -/// pending ballot, oldest first. Returns how many were read. +/// Purges the ballots let go long enough ago, lines and all. A ballot let go +/// never counted: nothing of a vote is lost. +pub async fn purge(state: &AppState) -> AppResult { + const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL + AND ((reason = 'dropped' AND settled_at < ?) OR settled_at < ?)"; + let (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS)); + let mut tx = state.db.begin().await?; + sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})")) + .bind(&dropped) + .bind(&expired) + .execute(&mut *tx) + .await?; + let done = sqlx::query(&format!("DELETE FROM ballots WHERE id IN ({WHICH})")) + .bind(&dropped) + .bind(&expired) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(done.rows_affected()) +} + +/// One pass of the background task: let go and purge, then read the indexer for +/// the pending ballots, the least recently read first (never read, then the +/// oldest reading). Returns how many were read. pub async fn sweep(state: &AppState) -> AppResult { expire(state).await?; + purge(state).await?; let rows = sqlx::query_as::<_, BallotRow>(&format!( - "{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}" + "{BALLOT_SELECT} WHERE status = 'pending' + ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}" )) .fetch_all(&state.db) .await?; @@ -600,9 +824,10 @@ pub struct MyBallot { pub choice: String, } -/// Per version: the most recent pending ballot of `me`; else its most recent -/// ballot if that one was refused or let go (a confirmed one shows as its vote). -/// A ballot the wallet never signed (dropped) says nothing. +/// Per version: the most recent pending ballot of `me` asked after its last +/// confirmed one; else its most recent ballot if that one was refused or let go +/// (a confirmed one shows as its vote). A ballot the wallet never signed +/// (dropped) says nothing; one asked before a confirmed vote says nothing either. pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct Row { @@ -626,12 +851,20 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult = HashMap::new(); for r in rows { let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice }; - if b.status == "pending" { - pending.insert(r.proposal_id.clone(), b.clone()); + match b.status.as_str() { + // A confirmed vote: what was asked before it says nothing more. + "confirmed" => { + latest.remove(&r.proposal_id); + pending.remove(&r.proposal_id); + } + "pending" => { + pending.insert(r.proposal_id, b); + } + _ => { + latest.insert(r.proposal_id, b); + } } - latest.insert(r.proposal_id, b); } - latest.retain(|_, b| b.status != "confirmed"); latest.extend(pending); Ok(latest) } diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 994ac0c..7399a10 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -396,6 +396,20 @@ impl DocState { self.tallies.get(id).copied().unwrap_or_default() } + /// The totals shown to people: today the live count, as `tally`. The one + /// place where a publication by steps would plug in — on the chain, who + /// voted and when is public, and live totals tell what (the secret of the + /// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads + /// `tally`; the published texts (`publish.rs`) would follow this one. + pub fn shown_tally(&self, id: &str) -> Tally { + self.tally(id) + } + + /// How far the threshold is, as shown: on `shown_tally`. + pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { + self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok() + } + pub fn unit_states(&self) -> Vec { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); self.units @@ -525,7 +539,7 @@ fn proposal_json( me: &str, ) -> serde_json::Value { let (mine, ballot) = mine; - let eval = st.evaluation(unit, p); + let eval = st.shown_evaluation(unit, p); let (kind, target) = match p.kind() { ProposalKind::Text => ("text", None), ProposalKind::Remove => ("remove", None), @@ -542,7 +556,7 @@ fn proposal_json( "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", - "tally": st.tally(&p.id), + "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), diff --git a/crates/ld-server/src/indexer.rs b/crates/ld-server/src/indexer.rs index 08e5d37..2255844 100644 --- a/crates/ld-server/src/indexer.rs +++ b/crates/ld-server/src/indexer.rs @@ -7,6 +7,25 @@ //! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with //! errors, is skipped; one that answers « nothing » may be behind, so the next //! is asked too. Nobody answering is not a « no »: the ballot stays pending. +//! +//! **Finality.** The indexer serves the best chain, blocks not yet final +//! included. `squidStatus` exposes `height` and `hash` only (no +//! `finalizedHeight`); `height` is the last final block it processed — relevé +//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block +//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the +//! finality of Duniter v2. Only a comment at or below it settles a ballot: a +//! comment above may still vanish with its block. +//! +//! **What is asked.** Only what may settle the ballot: for a ballot bound to an +//! account, that account's comments; for one bound to none (a Ğecko code), the +//! comments paid to the vote account — a bare copy of a remark costs nothing to +//! send, and must never hide the real one. The engine judges again what comes +//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes +//! nothing but the number of pages. +//! +//! **Pages.** In the order of the chain, 50 comments a page, page after page +//! until a comment settles the ballot, a block not yet final is reached, or the +//! last page — at most 20 pages a reading. use crate::AppState; use ld_engine::address; @@ -15,11 +34,19 @@ use serde::Deserialize; use serde_json::json; use std::time::Duration; -/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30. -pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \ - orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \ - isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }"; +/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31. +pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \ + squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \ + orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \ + nodes { id blockNumber remark author { id identity { index name isMember } } \ + transfersByCommentId { nodes { fromId toId amount } } } } }"; +/// Pages read at most for one ballot, one reading. +pub const MAX_PAGES: usize = 20; +/// The largest answer read: a page of 50 comments weighs about 30 KB. +const MAX_BODY: usize = 1 << 20; +/// A Ğ1 identity name is 42 characters at most. +const MAX_NAME: usize = 64; const TIMEOUT: Duration = Duration::from_secs(6); #[derive(Deserialize)] @@ -32,7 +59,28 @@ struct Answer { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Data { - tx_comments: Nodes, + squid_status: Status, + tx_comments: Connection, +} + +#[derive(Deserialize)] +struct Status { + height: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Connection { + #[serde(default)] + page_info: Option, + nodes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + end_cursor: Option, } #[derive(Deserialize)] @@ -85,12 +133,23 @@ pub struct Found { pub name: Option, } +/// One page of an answer. +#[derive(Debug)] +pub struct Page { + /// The last final block (`squidStatus.height`). + pub finalized: u32, + pub comments: Vec, + /// The cursor of the next page, if there is one. + pub next: Option, +} + #[derive(Debug)] pub enum Reading { /// No indexer answered. Silent, - /// The comments found (possibly none). - Found(Vec), + /// What an indexer answered: the comments in final blocks, in the order of the + /// chain (possibly none), and whether others wait in blocks not yet final. + Found { comments: Vec, unfinal: bool }, } fn amount(v: &serde_json::Value) -> Option { @@ -106,6 +165,9 @@ fn found(node: Node) -> Option { if position.block != node.block_number { return None; } + if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) { + return None; + } let (_, author) = address::decode(&node.author.id)?; let transfers = node .transfers_by_comment_id @@ -133,59 +195,127 @@ fn found(node: Node) -> Option { }) } -/// The comments of one answer of the indexer; `None` if it is not one. -pub fn parse(body: &serde_json::Value) -> Option> { +/// One page of an answer of the indexer; `None` if it is not one. +pub fn parse(body: &serde_json::Value) -> Option { let answer: Answer = serde_json::from_value(body.clone()).ok()?; if answer.errors.as_ref().is_some_and(|e| !e.is_null()) { return None; } - let nodes = answer.data?.tx_comments.nodes; + let data = answer.data?; + let nodes = data.tx_comments.nodes; let count = nodes.len(); - let out: Vec = nodes.into_iter().filter_map(found).collect(); - if out.len() != count { - tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside"); + let comments: Vec = nodes.into_iter().filter_map(found).collect(); + if comments.len() != count { + tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside"); } - Some(out) + let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor); + Some(Page { finalized: data.squid_status.height, comments, next }) } -/// The comments carrying the remark of hash `remark_hash` (`0x…`). -pub async fn comments(state: &AppState, remark_hash: &str) -> Reading { - let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } }); +/// What may settle a ballot, asked of the indexer: the comments of the bound +/// account; unbound, those paid to the vote account — at least the amount. +pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value { + match author { + Some(a) => json!({ "authorId": { "equalTo": a } }), + None => json!({ "transfersByCommentId": { "some": { + "toId": { "equalTo": account }, + "amount": { "greaterThanOrEqualTo": amount.to_string() }, + } } }), + } +} + +/// Reads a body of at most `MAX_BODY` bytes as JSON. +async fn read_json(mut res: reqwest::Response) -> Option { + if res.content_length().is_some_and(|n| n > MAX_BODY as u64) { + return None; + } + let mut buf = Vec::new(); + while let Some(chunk) = res.chunk().await.ok()? { + if buf.len() + chunk.len() > MAX_BODY { + return None; + } + buf.extend_from_slice(&chunk); + } + serde_json::from_slice(&buf).ok() +} + +async fn page(state: &AppState, url: &str, body: &serde_json::Value) -> Option { + let res = state.http.post(url).timeout(TIMEOUT).json(body).send().await; + let value = match res { + Ok(r) if r.status().is_success() => read_json(r).await, + Ok(r) => { + tracing::debug!(%url, status = %r.status(), "indexer"); + None + } + Err(e) => { + tracing::debug!(%url, error = %e, "indexer"); + None + } + }; + value.as_ref().and_then(parse) +} + +/// The comments in final blocks that carry the remark of hash `remark_hash` +/// (`0x…`) and pass `filter`, page after page until `settled` says one settles +/// the ballot. +pub async fn comments( + state: &AppState, + remark_hash: &str, + filter: &serde_json::Value, + settled: impl Fn(&[Found]) -> bool, +) -> Reading { let mut answered = false; for url in &state.cfg.indexers { - let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await; - let value: Option = match res { - Ok(r) if r.status().is_success() => r.json().await.ok(), - Ok(r) => { - tracing::debug!(%url, status = %r.status(), "indexer"); - None + let mut kept: Vec = Vec::new(); + let mut unfinal = false; + let mut after: Option = None; + let mut finalized: Option = None; + for n in 0..MAX_PAGES { + let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } }); + let Some(p) = page(state, url, &body).await else { break }; + // The finality of the first page holds for the reading: later pages may only see more. + let last_final = *finalized.get_or_insert(p.finalized); + answered = true; + for f in p.comments { + if f.comment.position.block > last_final { + unfinal = true; + } else { + kept.push(f); + } } - Err(e) => { - tracing::debug!(%url, error = %e, "indexer"); - None + if unfinal || settled(&kept) { + break; } - }; - match value.as_ref().and_then(parse) { - Some(list) if !list.is_empty() => return Reading::Found(list), - Some(_) => answered = true, - None => {} + match p.next { + Some(cursor) => after = Some(cursor), + None => break, + } + if n + 1 == MAX_PAGES { + tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending"); + } + } + if !kept.is_empty() || unfinal { + return Reading::Found { comments: kept, unfinal }; } } - if answered { Reading::Found(Vec::new()) } else { Reading::Silent } + if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent } } #[cfg(test)] mod tests { use super::*; - /// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ». + /// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), for the remark « Merci ». const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json"); #[test] fn a_real_answer_reads() { let body: serde_json::Value = serde_json::from_str(REAL).expect("json"); - let list = parse(&body).expect("an answer"); - assert_eq!(list.len(), 4); + let page = parse(&body).expect("an answer"); + assert_eq!(page.finalized, 3_407_492); + assert_eq!(page.next, None, "one page: 26 comments, fewer than 50"); + let list = page.comments; + assert_eq!(list.len(), 26, "the old query stopped at 20"); let first = &list[0]; assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 }); assert_eq!(first.remark, "Merci "); @@ -198,6 +328,8 @@ mod tests { // An account without identity. assert_eq!(list[2].comment.identity, None); assert_eq!(list[2].name, None); + // In the order of the chain. + assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position)); } #[test] @@ -205,19 +337,48 @@ mod tests { assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none()); assert!(parse(&json!({ "data": null })).is_none()); assert!(parse(&json!({ "nope": 1 })).is_none()); - assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0)); + // Without the finality, an answer cannot settle anything: no answer. + assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none()); + let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } }); + assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None))); + let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { + "pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } }); + assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc")); } #[test] fn a_node_that_does_not_read_is_left_aside() { - let body = json!({ "data": { "txComments": { "nodes": [ + let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv"; + let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [ { "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x", - "author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null }, + "author": { "id": author, "identity": null }, "transfersByCommentId": { "nodes": [] } }, { "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x", "author": { "id": "not an address", "identity": null }, "transfersByCommentId": { "nodes": [] } }, + { "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x", + "author": { "id": "z".repeat(100_000), "identity": null }, + "transfersByCommentId": { "nodes": [] } }, + { "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x", + "author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } }, + "transfersByCommentId": { "nodes": [] } }, ] } } }); - assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address"); + assert_eq!( + parse(&body).map(|p| p.comments.len()), + Some(0), + "block and id disagree; a bad address; a long one; a name no identity has" + ); + } + + #[test] + fn what_is_asked_is_what_may_settle() { + let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; + let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu"; + assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } })); + assert_eq!( + filter(vote, 10, None), + json!({ "transfersByCommentId": { "some": { + "toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } }) + ); } } diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 1a1a614..3d3f765 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -121,7 +121,9 @@ pub fn api_router() -> Router { .route("/docs/{id}/proposals", post(proposals::create)) .route("/docs/{id}/votes", post(ballots::vote_many)) .route("/docs/{id}/ballots", post(ballots::create)) + .route("/ballots", get(ballots::mine)) .route("/ballots/{id}", get(ballots::status).delete(ballots::drop)) + .route("/ballots/{id}/receipt", get(ballots::receipt)) .route("/proposals/{id}", get(proposals::detail).patch(proposals::edit)) .route("/proposals/{id}/submit", post(proposals::submit)) .route("/proposals/{id}/withdraw", post(proposals::withdraw)) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 220c60f..e58c441 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -624,7 +624,7 @@ pub async fn detail( (Some(v), "text") => Some(edit::diff(&v.body, &p.body)), _ => None, }; - let eval = st.evaluation(unit, &p); + let eval = st.shown_evaluation(unit, &p); let voted_at = st.voting_level(unit, &p); let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse); let tr = docs::translations_for(&state, &[&p]).await?; @@ -636,7 +636,7 @@ pub async fn detail( "mine": p.author_id.as_deref() == Some(user.id.as_str()), "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", - "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, + "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), "myVote": my_vote.as_ref().map(|v| &v.choice), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision), @@ -653,7 +653,7 @@ pub async fn detail( "voteFloor": st.doc.vote_floor, "participation": st.participation_json(), // The five levels, with the *for* votes each would ask at this version's participation. - "levels": st.levels_json(st.tally(&p.id).cast()), + "levels": st.levels_json(st.shown_tally(&p.id).cast()), }))) } diff --git a/crates/ld-server/src/team.rs b/crates/ld-server/src/team.rs index 510b9ef..39fa32d 100644 --- a/crates/ld-server/src/team.rs +++ b/crates/ld-server/src/team.rs @@ -97,8 +97,13 @@ pub struct JoinBody { /// `name_key` settles two openings at the same instant. async fn free_name(state: &AppState, raw: &str) -> AppResult { let name = names::check(raw).ok_or(AppError::Invalid("name"))?; - // People already there, and those an invitation still waits for. - let mut taken: Vec = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?; + // People already there, and those an invitation still waits for. A Ğ1 + // identity known only from a vote on the chain (never signed in here) is not + // there: as for any member of the web of trust who never came, its name stays free. + let mut taken: Vec = + sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)") + .fetch_all(&state.db) + .await?; taken.extend( sqlx::query_scalar::<_, String>( "SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?", diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index b05e7d1..c3f7b4f 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -35,6 +35,12 @@ struct Chain { comments: Vec, silent: bool, asked: usize, + /// The last final block, served as `squidStatus.height`; `None`: every block is final. + finalized: Option, + /// An indexer that ignores the filter it is given (the hash only holds). + lax: bool, + /// The filters the server asked with, in order. + filters: Vec, } #[derive(Clone)] @@ -49,21 +55,66 @@ struct Signer<'a> { identity: Option<(u32, &'a str, bool)>, } +/// What the real indexer's `filter` keeps, for the two filters the server may ask. +fn kept_by(node: &Value, filter: &Value) -> bool { + if let Some(author) = filter["authorId"]["equalTo"].as_str() + && node["author"]["id"] != author + { + return false; + } + let some = &filter["transfersByCommentId"]["some"]; + if some.is_null() { + return true; + } + let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0); + node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| { + some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to) + && t["amount"].as_str().and_then(|a| a.parse::().ok()).is_some_and(|a| a >= min) + }) +} + +/// The size of a page: `first: N` in the query, as the server writes it. +fn page_size(query: &str) -> usize { + query + .split("first:") + .nth(1) + .and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|n| n.parse().ok()) + .unwrap_or(100) +} + async fn graphql(State(chain): State>>, Json(body): Json) -> Response { let mut c = chain.lock().expect("lock"); c.asked += 1; if c.silent { return StatusCode::BAD_GATEWAY.into_response(); } - let hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned(); + let vars = &body["variables"]; + let hash = vars["hash"].as_str().unwrap_or_default().to_owned(); + let filter = vars["filter"].clone(); + c.filters.push(filter.clone()); let mut nodes: Vec = c .comments .iter() .filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash) + .filter(|n| c.lax || kept_by(n, &filter)) .cloned() .collect(); - nodes.sort_by_key(|n| n["blockNumber"].as_u64()); - Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response() + nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned))); + // Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one. + let start: usize = + vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0); + let page: Vec = + nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect(); + let end = start + page.len(); + Json(json!({ "data": { + "squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) }, + "txComments": { + "pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") }, + "nodes": page, + }, + } })) + .into_response() } impl Indexer { @@ -103,6 +154,19 @@ impl Indexer { fn asked(&self) -> usize { self.chain.lock().expect("lock").asked } + + /// The chain is final up to this block. + fn finalized(&self, block: Option) { + self.chain.lock().expect("lock").finalized = block; + } + + fn lax(&self, on: bool) { + self.chain.lock().expect("lock").lax = on; + } + + fn filters(&self) -> Vec { + self.chain.lock().expect("lock").filters.clone() + } } fn alice() -> Signer<'static> { @@ -138,6 +202,16 @@ impl Client { } async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let (status, _, value) = self.call_with_headers(method, uri, body).await; + (status, value) + } + + async fn call_with_headers( + &mut self, + method: &str, + uri: &str, + body: Option, + ) -> (StatusCode, axum::http::HeaderMap, Value) { let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); if let Some(c) = &self.cookie { req = req.header(header::COOKIE, c); @@ -155,8 +229,9 @@ impl Client { self.cookie = Some(raw); } } + let headers = res.headers().clone(); let bytes = res.into_body().collect().await.expect("body").to_bytes(); - (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + (status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) } async fn ok(&mut self, method: &str, uri: &str, body: Option) -> Value { @@ -169,6 +244,10 @@ impl Client { self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await; } + async fn login_admin(&mut self, name: &str) { + self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await; + } + async fn doc(&mut self, group: &str) -> Value { let g = self.ok("GET", &format!("/api/groups/{group}"), None).await; let id = g["docs"][0]["id"].as_str().expect("doc").to_owned(); @@ -396,17 +475,22 @@ async fn a_wrong_amount_or_recipient_is_refused() { c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); - let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + // Bound to the wallet that signs: its own transaction without the transfer is refused, and told. + let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]); - let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]); - let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]); + // Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free. + let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]); ballots::sweep(&state).await.expect("sweep"); for b in [&short, &elsewhere, &remark_only] { let s = c.status(b).await; assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}"); } + assert_eq!(c.status(&gecko).await["status"], "pending"); assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); } @@ -559,8 +643,10 @@ async fn a_ballot_not_seen_in_time_is_let_go() { .execute(&state.db) .await .expect("age"); - assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0); + // Read one last time at its end: nothing on the chain, it is let go. + assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1); assert_eq!(c.status(&b).await["status"], "expired"); + assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read"); // Seen on the chain afterwards: too late, it no longer counts. indexer.pay(100, b["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); @@ -668,6 +754,391 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() { assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back"); } +// --------------------------------------------------------------------------- +// After the independent review of 74f96be (lot B2): its probes, kept as tests +// --------------------------------------------------------------------------- + +async fn db_status(state: &AppState, id: &str) -> (String, Option) { + sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row") +} + +fn id_of(b: &Value) -> &str { + b["id"].as_str().expect("id") +} + +fn comment_of(b: &Value) -> &str { + b["comment"].as_str().expect("comment") +} + +async fn age(state: &AppState, set: &str, seconds: i64, id: &str) { + sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?")) + .bind(ld_server::util::in_seconds(seconds)) + .bind(id) + .execute(&state.db) + .await + .expect("age"); +} + +/// P5: the `author` field was decoded in a time that grows as the square of its +/// length, on a thread of the server (250 000 characters: 32 s). +#[tokio::test] +async fn a_long_author_is_refused_at_once() { + let indexer = Indexer::start().await; + let (mut c, _) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let i1 = version(&doc, "I1", "inForce"); + // The body may hold 256 KiB: an author of 250 000 characters passes it. + for n in [80_000usize, 250_000] { + let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) }); + let t = std::time::Instant::now(); + let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author"))); + assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed()); + } +} + +/// P1: the background task read the same 50 oldest pending ballots at every pass; +/// a vote paid behind them was never confirmed, then expired. +#[tokio::test] +async fn the_background_task_reaches_every_pending_ballot() { + let indexer = Indexer::start().await; + let (mut a, state) = Client::new(chain_config(&indexer)).await; + a.login("Anna").await; + let doc = a.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let mut b = a.other(); + b.login("Bruno").await; + let mut c = a.other(); + c.login("Carla").await; + for _ in 0..20 { + a.ballot(&doc, &[(&i1, Some("for"))], None).await; + b.ballot(&doc, &[(&i1, Some("for"))], None).await; + } + for _ in 0..10 { + c.ballot(&doc, &[(&i1, Some("for"))], None).await; + } + let mut dora = a.other(); + dora.login("Dora").await; + let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await; + indexer.pay(100, comment_of(&mine), alice()); + // 51 pending, at most 50 read per pass: the least recently read come first. + ballots::sweep(&state).await.expect("sweep"); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed"); + let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL") + .fetch_one(&state.db) + .await + .expect("n"); + assert_eq!(unread, 0, "every pending ballot was read"); +} + +/// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines. +#[tokio::test] +async fn letting_ballots_go_does_not_lift_the_cap() { + let indexer = Indexer::start().await; + let (mut a, state) = Client::new(chain_config(&indexer)).await; + a.login("Anna").await; + let doc = a.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let i1 = version(&doc, "I1", "inForce"); + for _ in 0..20 { + let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await; + a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await; + } + let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] }); + let url = format!("/api/docs/{doc_id}/ballots"); + let (s, v) = a.call("POST", &url, Some(one.clone())).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots"))); + // The cap holds for the identity, whatever the session. + let mut again = a.other(); + again.login("Anna").await; + assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT); + // An hour later, there is room again. + sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'") + .bind(ld_server::util::in_seconds(-3601)) + .execute(&state.db) + .await + .expect("age"); + let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await; + // Let go more than a day ago: purged, lines and all; the pending one stays. + sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'") + .bind(ld_server::util::in_seconds(-(24 * 3600 + 1))) + .execute(&state.db) + .await + .expect("age"); + ballots::sweep(&state).await.expect("sweep"); + let count = || async { + sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)") + .fetch_one(&state.db) + .await + .expect("n") + }; + assert_eq!(count().await, (1, 1)); + // Not seen on the chain in time: it says so under the article for a week, then it is purged. + age(&state, "expires_at", -3601 - 5, id_of(&kept)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired"); + age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(count().await, (1, 1)); + age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(count().await, (0, 0)); +} + +/// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good. +#[tokio::test] +async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() { + let indexer = Indexer::start().await; + // An indexer that does not filter: the rule of the engine holds alone. + indexer.lax(true); + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]); + indexer.add( + 99, + 2, + comment_of(&b), + Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }, + &[(VOTE_ACCOUNT, 9)], + ); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing"); + indexer.pay(100, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + let s = c.status(&b).await; + assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}"); + assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0)); +} + +/// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes +/// an unbound ballot — its lines count for the one who paid. +#[tokio::test] +async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); + indexer.pay(100, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + let s = c.status(&b).await; + assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie"))); + let voters: Vec = + sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v"); + assert_eq!(voters, ["idty:7"]); +} + +/// Point 5 of the review: a pending ballot older than a confirmed vote on the same +/// line no longer shows under the article. +#[tokio::test] +async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + // A Ğecko code shown, never paid; then a vote from the wallet, confirmed. + c.ballot(&doc, &[(&i1, Some("against"))], None).await; + let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; + indexer.pay(100, comment_of(&newer), alice()); + ballots::sweep(&state).await.expect("sweep"); + let d = c.doc("monnaie-libre").await; + assert_eq!(find(&d, &i1)["myVote"], "for"); + assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]); + let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await; + assert!(p["proposal"]["myBallot"].is_null()); + // A ballot asked after the vote shows, waiting. + let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await; + assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]); +} + +/// Point 6: only a comment in a final block counts, or refuses. +#[tokio::test] +async fn a_comment_counts_once_its_block_is_final() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; + let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await; + indexer.finalized(Some(99)); + indexer.pay(100, comment_of(&b), alice()); + indexer.add( + 100, + 2, + comment_of(&r), + Signer { account: BOB, identity: Some((42, "Bob", false)) }, + &[(VOTE_ACCOUNT, 10)], + ); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&b)).await.0, "pending"); + assert_eq!(db_status(&state, id_of(&r)).await.0, "pending"); + assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); + indexer.finalized(Some(100)); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); + assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into()))); +} + +/// Point 6: the search by hash went no further than 20 comments; copies could hide the real one. +#[tokio::test] +async fn copies_never_hide_the_real_comment() { + let indexer = Indexer::start().await; + // An indexer that would not filter: pages, one after the other. + indexer.lax(true); + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; + for n in 0..120 { + indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); + } + indexer.pay(300, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + let s = c.status(&b).await; + assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}"); + // The indexer is asked for what may settle: the comments of the bound account; + // for a Ğecko code, those paid to the vote account. + indexer.lax(false); + c.ballot(&doc, &[(&i1, Some("against"))], None).await; + ballots::sweep(&state).await.expect("sweep"); + let filters = indexer.filters(); + assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}"); + assert!( + filters.iter().any(|f| { + let some = &f["transfersByCommentId"]["some"]; + some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10" + }), + "{filters:?}" + ); +} + +/// Point 5: a Ğecko code keeps its ballot; the background task settles it, the +/// page closed — even paid at the last moment, read once more at its end. +#[tokio::test] +async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + age(&state, "expires_at", -5, id_of(&b)).await; + indexer.pay(100, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); + // Its end come while no indexer answers: kept and read again, for an hour at most. + let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await; + age(&state, "expires_at", -5, id_of(&late)).await; + indexer.silent(true); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&late)).await.0, "pending"); + age(&state, "expires_at", -3601, id_of(&late)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&late)).await.0, "expired"); +} + +/// Point 7: the research note promises everyone can check their own vote with its +/// salt. The receipt goes to the one who asked, never to anyone else. +#[tokio::test] +async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0")); + let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await; + indexer.pay(100, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + let url = format!("/api/ballots/{}/receipt", id_of(&b)); + let (s, headers, r) = c.call_with_headers("GET", &url, None).await; + assert_eq!(s, StatusCode::OK, "{r}"); + let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default(); + assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}"); + assert_eq!( + (r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()), + (Some("ld1"), Some("confirmed"), Some(100), Some("Alice")) + ); + assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE))); + // It holds: its lines and its salt seal the remark the chain carries. + let lines: Vec = r["lines"] + .as_array() + .expect("lines") + .iter() + .map(|l| ld_engine::ballot::Line { + proposal_id: l["proposalId"].as_str().expect("p").into(), + revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"), + choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"), + }) + .collect(); + assert_eq!(lines.len(), 2); + let doc_id = r["doc"].as_str().expect("doc"); + let salt = r["salt"].as_str().expect("salt"); + let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal"); + assert_eq!( + (sealed.comment.as_str(), sealed.remark_hash.as_str()), + (comment_of(&b), r["remarkHash"].as_str().expect("h")) + ); + assert_eq!( + r["canonical"].as_str(), + Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str()) + ); + // Each line says which article it is. + assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1")); + // In her history, never in anyone else's. + let mine = c.ok("GET", "/api/ballots", None).await; + assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2))); + assert_eq!(mine["ballots"][0]["status"], "confirmed"); + let mut yvv = c.other(); + yvv.login("Yvv").await; + assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND); + assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([])); + let mut nobody = c.other(); + assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED); +} + +/// Point 8: an identity known only from a vote on the chain does not hold its name +/// against a person who joins by a team link; a Ğ1 identity that signed in does. +#[tokio::test] +async fn a_name_known_only_from_a_chain_vote_stays_free() { + let indexer = Indexer::start().await; + let (mut admin, state) = Client::new(chain_config(&indexer)).await; + admin.login_admin("Yvv").await; + let doc = admin.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "inForce"); + for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })] + { + let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await; + indexer.pay(block, comment_of(&b), signer); + } + ballots::sweep(&state).await.expect("sweep"); + // Charlie has since signed in with his Ğ1 identity. + sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'") + .bind(ld_server::util::now()) + .execute(&state.db) + .await + .expect("seen"); + let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 }); + let v = admin.ok("POST", "/api/access/team-links", Some(link)).await; + let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); + let mut charlie = admin.other(); + let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken"))); + let mut alice = admin.other(); + let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); +} + // --------------------------------------------------------------------------- // Without the chain, and in managed groups // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/fixtures/indexer-merci.json b/crates/ld-server/tests/fixtures/indexer-merci.json index f9ecc23..9240653 100644 --- a/crates/ld-server/tests/fixtures/indexer-merci.json +++ b/crates/ld-server/tests/fixtures/indexer-merci.json @@ -1,6 +1,13 @@ { "data": { + "squidStatus": { + "height": 3407492 + }, "txComments": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0=" + }, "nodes": [ { "id": "0000196944-60692-000003", @@ -85,6 +92,482 @@ } ] } + }, + { + "id": "0000821130-de04d-000008", + "blockNumber": 821130, + "remark": "Merci ", + "author": { + "id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB", + "identity": { + "index": 3947, + "name": "Jade971", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB", + "toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc", + "amount": "5000" + } + ] + } + }, + { + "id": "0001281169-c9002-000006", + "blockNumber": 1281169, + "remark": "Merci ", + "author": { + "id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM", + "identity": { + "index": 7210, + "name": "Tell", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM", + "toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq", + "amount": "18000" + } + ] + } + }, + { + "id": "0001303965-1b924-000006", + "blockNumber": 1303965, + "remark": "Merci ", + "author": { + "id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ", + "identity": { + "index": 13370, + "name": "Florence76", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ", + "toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J", + "amount": "1500" + } + ] + } + }, + { + "id": "0001810083-37092-000004", + "blockNumber": 1810083, + "remark": "Merci ", + "author": { + "id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj", + "identity": null + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj", + "toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK", + "amount": "58900" + } + ] + } + }, + { + "id": "0001810417-7e2d8-000003", + "blockNumber": 1810417, + "remark": "Merci ", + "author": { + "id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj", + "identity": null + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj", + "toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6", + "amount": "15000" + } + ] + } + }, + { + "id": "0001850707-782ab-000003", + "blockNumber": 1850707, + "remark": "Merci ", + "author": { + "id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp", + "identity": { + "index": 13722, + "name": "LibrA", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp", + "toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb", + "amount": "70680" + } + ] + } + }, + { + "id": "0002197191-e9abb-000006", + "blockNumber": 2197191, + "remark": "Merci ", + "author": { + "id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1", + "identity": { + "index": 17203, + "name": "Elodietexier", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1", + "toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn", + "amount": "3534" + } + ] + } + }, + { + "id": "0002727091-bd26d-000003", + "blockNumber": 2727091, + "remark": "Merci ", + "author": { + "id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc", + "identity": { + "index": 18509, + "name": "RobBiovoix", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc", + "toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r", + "amount": "5000" + } + ] + } + }, + { + "id": "0002825411-05f96-000003", + "blockNumber": 2825411, + "remark": "Merci ", + "author": { + "id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s", + "identity": { + "index": 18506, + "name": "BrunoAlmeida", + "isMember": false + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s", + "toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk", + "amount": "5000" + } + ] + } + }, + { + "id": "0002939378-e9fef-000006", + "blockNumber": 2939378, + "remark": "Merci ", + "author": { + "id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "identity": { + "index": 18467, + "name": "Vavafra", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA", + "amount": "15000" + } + ] + } + }, + { + "id": "0002945277-0f2cb-000003", + "blockNumber": 2945277, + "remark": "Merci ", + "author": { + "id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k", + "identity": { + "index": 18493, + "name": "moidann", + "isMember": false + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k", + "toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k", + "amount": "3000" + } + ] + } + }, + { + "id": "0003114157-3fc38-000006", + "blockNumber": 3114157, + "remark": "Merci ", + "author": { + "id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "identity": { + "index": 18467, + "name": "Vavafra", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB", + "amount": "4000" + } + ] + } + }, + { + "id": "0003124959-a6247-000003", + "blockNumber": 3124959, + "remark": "Merci ", + "author": { + "id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR", + "identity": { + "index": 7377, + "name": "Sylvaine", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR", + "toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3", + "amount": "5000" + } + ] + } + }, + { + "id": "0003128170-4521a-000003", + "blockNumber": 3128170, + "remark": "Merci ", + "author": { + "id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1", + "identity": { + "index": 8912, + "name": "Altheaagathe", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1", + "toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK", + "amount": "40000" + } + ] + } + }, + { + "id": "0003226425-d15c3-000009", + "blockNumber": 3226425, + "remark": "Merci ", + "author": { + "id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W", + "identity": { + "index": 11102, + "name": "Philp", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W", + "toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU", + "amount": "14604" + } + ] + } + }, + { + "id": "0003227886-5270d-000003", + "blockNumber": 3227886, + "remark": "Merci ", + "author": { + "id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b", + "identity": { + "index": 16572, + "name": "zola", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b", + "toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx", + "amount": "6000" + } + ] + } + }, + { + "id": "0003229458-31a61-000006", + "blockNumber": 3229458, + "remark": "Merci ", + "author": { + "id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W", + "identity": { + "index": 11102, + "name": "Philp", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W", + "toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU", + "amount": "12170" + } + ] + } + }, + { + "id": "0003297346-087b4-000006", + "blockNumber": 3297346, + "remark": "Merci ", + "author": { + "id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "identity": { + "index": 18467, + "name": "Vavafra", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb", + "toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA", + "amount": "10000" + } + ] + } + }, + { + "id": "0003360722-61edb-000006", + "blockNumber": 3360722, + "remark": "Merci ", + "author": { + "id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy", + "identity": { + "index": 14694, + "name": "Coca-Cola", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy", + "toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv", + "amount": "6085" + } + ] + } + }, + { + "id": "0003384655-8fbc0-000003", + "blockNumber": 3384655, + "remark": "Merci ", + "author": { + "id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL", + "identity": { + "index": 4018, + "name": "GeraldineGarrigues", + "isMember": true + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL", + "toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm", + "amount": "3000" + } + ] + } + }, + { + "id": "0003399415-51dbe-000003", + "blockNumber": 3399415, + "remark": "Merci ", + "author": { + "id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k", + "identity": { + "index": 18493, + "name": "moidann", + "isMember": false + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k", + "toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA", + "amount": "10000" + } + ] + } + }, + { + "id": "0003399566-d43e7-000003", + "blockNumber": 3399566, + "remark": "Merci ", + "author": { + "id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs", + "identity": { + "index": 18630, + "name": "moidann12", + "isMember": false + } + }, + "transfersByCommentId": { + "nodes": [ + { + "fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs", + "toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA", + "amount": "15000" + } + ] + } } ] }