forked from yvv/decision
v3 : vote par la chaîne, corrections de la relecture de 74f96be (serveur)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@
|
|||||||
/// The SS58 prefix of the Ğ1 chain.
|
/// The SS58 prefix of the Ğ1 chain.
|
||||||
pub const G1_PREFIX: u16 = 4450;
|
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";
|
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
||||||
@@ -72,6 +78,9 @@ fn prefix_bytes(prefix: u16) -> Vec<u8> {
|
|||||||
|
|
||||||
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
|
/// 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])> {
|
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
|
||||||
|
if addr.len() > MAX_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let data = base58_decode(addr)?;
|
let data = base58_decode(addr)?;
|
||||||
let (prefix, at) = match *data.first()? {
|
let (prefix, at) = match *data.first()? {
|
||||||
b @ 0..=63 => (u16::from(b), 1),
|
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]
|
#[test]
|
||||||
fn what_is_not_an_address() {
|
fn what_is_not_an_address() {
|
||||||
let (g1, _, _) = VECTORS[0];
|
let (g1, _, _) = VECTORS[0];
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
//! per identity, in the order of the chain — block, then rank of the event in
|
//! 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.
|
//! 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.
|
//! Pure: the salt comes from the caller, the facts of the chain too.
|
||||||
|
|
||||||
use crate::address::{self, G1_PREFIX};
|
use crate::address::{self, G1_PREFIX};
|
||||||
@@ -217,6 +222,10 @@ pub enum Verdict {
|
|||||||
Paid { identity: ChainIdentity },
|
Paid { identity: ChainIdentity },
|
||||||
/// Signed by another account than the one bound: a copy of the remark, ignored.
|
/// Signed by another account than the one bound: a copy of the remark, ignored.
|
||||||
OtherAuthor,
|
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.
|
/// The account is not that of an identity.
|
||||||
NoIdentity,
|
NoIdentity,
|
||||||
/// No transfer of the amount to the vote account in the same transaction.
|
/// 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.
|
/// Why a ballot is refused, as stored and translated.
|
||||||
pub fn reason(self) -> Option<&'static str> {
|
pub fn reason(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Paid { .. } | Self::OtherAuthor => None,
|
Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None,
|
||||||
Self::NoIdentity => Some("no_identity"),
|
Self::NoIdentity => Some("no_identity"),
|
||||||
Self::NoTransfer => Some("transfer"),
|
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 {
|
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
|
||||||
if e.author.is_some_and(|a| a != c.author) {
|
if e.author.is_some_and(|a| a != c.author) {
|
||||||
return Verdict::OtherAuthor;
|
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 };
|
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;
|
return Verdict::NoTransfer;
|
||||||
}
|
}
|
||||||
Verdict::Paid { identity }
|
Verdict::Paid { identity }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comment that settles a ballot: the first in the order of the chain that is
|
/// 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)> {
|
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
|
||||||
let mut sorted: Vec<&Comment> = comments.iter().collect();
|
let mut sorted: Vec<&Comment> = comments.iter().collect();
|
||||||
sorted.sort_by_key(|c| c.position);
|
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)]
|
#[cfg(test)]
|
||||||
@@ -400,15 +425,18 @@ mod tests {
|
|||||||
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
|
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, Some((1304, false)), &paid), &e), former);
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
|
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
|
||||||
// Too little, elsewhere, or nothing.
|
// Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound.
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
|
let bound = Expected { author: Some(key(SIMONE)), ..e };
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
|
for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] {
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
|
let c = comment(1, SIMONE, Some((1304, true)), unpaid);
|
||||||
// Paid from another account than the signer's.
|
assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}");
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
|
assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}");
|
||||||
|
}
|
||||||
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
||||||
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
||||||
assert_eq!(ok.reason(), None);
|
assert_eq!(ok.reason(), None);
|
||||||
|
assert_eq!(Verdict::Unpaid.reason(), None);
|
||||||
|
assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -429,4 +457,31 @@ mod tests {
|
|||||||
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
|
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
|
||||||
assert_eq!(judge(&[], &free), None);
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+272
-39
@@ -15,9 +15,12 @@
|
|||||||
//! - **The last one stands**, per proposal and per identity, in the order of
|
//! - **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.
|
//! the chain: a ballot confirmed late never overrides a more recent one.
|
||||||
//!
|
//!
|
||||||
//! A background task reads the indexer for pending ballots every 10 seconds
|
//! 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
|
//! at most 50 a pass, the least recently read first: every pending ballot is
|
||||||
//! adoption pass after each confirmation.
|
//! 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::AppState;
|
||||||
use crate::adoption::{self, Entered};
|
use crate::adoption::{self, Entered};
|
||||||
@@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading};
|
|||||||
use crate::util;
|
use crate::util;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderValue, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
use ld_engine::address;
|
use ld_engine::address;
|
||||||
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -40,15 +45,26 @@ use trustin_access::VoteWay;
|
|||||||
|
|
||||||
/// Every article of a long document, with room to spare.
|
/// Every article of a long document, with room to spare.
|
||||||
const MAX_LINES: usize = 200;
|
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 MAX_PENDING: i64 = 20;
|
||||||
|
const DROPPED_WINDOW_SECS: i64 = 3600;
|
||||||
/// A ballot not seen on the chain within this time is let go.
|
/// A ballot not seen on the chain within this time is let go.
|
||||||
pub const EXPIRES_HOURS: i64 = 24;
|
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.
|
/// The page asks often; the indexer is read at most this often per ballot.
|
||||||
const RECHECK_SECS: i64 = 3;
|
const RECHECK_SECS: i64 = 3;
|
||||||
const WATCH_EVERY: Duration = Duration::from_secs(10);
|
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;
|
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
|
/// 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.
|
/// 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"))?),
|
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let pending: i64 = sqlx::query_scalar(
|
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
||||||
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
|
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(&user.id)
|
||||||
.bind(util::now())
|
.bind(util::in_seconds(-DROPPED_WINDOW_SECS))
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if pending >= MAX_PENDING {
|
if held >= MAX_PENDING {
|
||||||
return Err(AppError::Conflict("too_many_ballots"));
|
return Err(AppError::Conflict("too_many_ballots"));
|
||||||
}
|
}
|
||||||
let lines: Vec<ballot::Line> = casts
|
let lines: Vec<ballot::Line> = casts
|
||||||
@@ -333,6 +351,160 @@ pub async fn drop(
|
|||||||
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
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::<serde_json::Value>(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<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: String,
|
||||||
|
doc_id: String,
|
||||||
|
group_slug: String,
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
digest: String,
|
||||||
|
status: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
block: Option<i64>,
|
||||||
|
voter: Option<String>,
|
||||||
|
lines: i64,
|
||||||
|
created_at: String,
|
||||||
|
settled_at: Option<String>,
|
||||||
|
}
|
||||||
|
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<serde_json::Value> = 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<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Response> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Extra {
|
||||||
|
salt: String,
|
||||||
|
signer: Option<String>,
|
||||||
|
event_id: Option<String>,
|
||||||
|
settled_at: Option<String>,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct LineRow {
|
||||||
|
proposal_id: String,
|
||||||
|
revision: i64,
|
||||||
|
choice: String,
|
||||||
|
code: String,
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
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<ballot::Line> = 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<String> = 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::<Vec<_>>(),
|
||||||
|
"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<serde_json::Value> {
|
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
||||||
let voter: Option<String> = match &row.voter_id {
|
let voter: Option<String> = match &row.voter_id {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
@@ -352,29 +524,47 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value>
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<()> {
|
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||||
let now = util::now();
|
let now = util::now();
|
||||||
if row.expires_at <= now {
|
let due = row.expires_at <= now;
|
||||||
expire(state).await?;
|
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
||||||
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<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
|
|
||||||
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
|
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());
|
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
|
||||||
};
|
};
|
||||||
let expected =
|
let expected =
|
||||||
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
|
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
|
||||||
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
|
let comment = row.comment();
|
||||||
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
|
// Only the comments that carry exactly the remark of the ballot.
|
||||||
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
|
let judged = |list: &[Found]| {
|
||||||
|
let mine: Vec<ballot::Comment> =
|
||||||
|
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 {
|
match verdict {
|
||||||
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
||||||
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
|
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(())
|
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<u64> {
|
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
||||||
let now = util::now();
|
|
||||||
let done = sqlx::query(
|
let done = sqlx::query(
|
||||||
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
||||||
)
|
)
|
||||||
.bind(&now)
|
.bind(util::now())
|
||||||
.bind(&now)
|
.bind(util::in_seconds(-GRACE_SECS))
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(done.rows_affected())
|
Ok(done.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pass of the background task: expire, then read the indexer for each
|
/// Purges the ballots let go long enough ago, lines and all. A ballot let go
|
||||||
/// pending ballot, oldest first. Returns how many were read.
|
/// never counted: nothing of a vote is lost.
|
||||||
|
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
||||||
|
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<usize> {
|
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||||
expire(state).await?;
|
expire(state).await?;
|
||||||
|
purge(state).await?;
|
||||||
let rows = sqlx::query_as::<_, BallotRow>(&format!(
|
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)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -600,9 +824,10 @@ pub struct MyBallot {
|
|||||||
pub choice: String,
|
pub choice: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per version: the most recent pending ballot of `me`; else its most recent
|
/// Per version: the most recent pending ballot of `me` asked after its last
|
||||||
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
|
/// confirmed one; else its most recent ballot if that one was refused or let go
|
||||||
/// A ballot the wallet never signed (dropped) says nothing.
|
/// (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<HashMap<String, MyBallot>> {
|
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -626,12 +851,20 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
|
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
|
||||||
if b.status == "pending" {
|
match b.status.as_str() {
|
||||||
pending.insert(r.proposal_id.clone(), b.clone());
|
// 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);
|
latest.extend(pending);
|
||||||
Ok(latest)
|
Ok(latest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,6 +396,20 @@ impl DocState {
|
|||||||
self.tallies.get(id).copied().unwrap_or_default()
|
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<Evaluation> {
|
||||||
|
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unit_states(&self) -> Vec<UnitState> {
|
pub fn unit_states(&self) -> Vec<UnitState> {
|
||||||
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||||
self.units
|
self.units
|
||||||
@@ -525,7 +539,7 @@ fn proposal_json(
|
|||||||
me: &str,
|
me: &str,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let (mine, ballot) = mine;
|
let (mine, ballot) = mine;
|
||||||
let eval = st.evaluation(unit, p);
|
let eval = st.shown_evaluation(unit, p);
|
||||||
let (kind, target) = match p.kind() {
|
let (kind, target) = match p.kind() {
|
||||||
ProposalKind::Text => ("text", None),
|
ProposalKind::Text => ("text", None),
|
||||||
ProposalKind::Remove => ("remove", None),
|
ProposalKind::Remove => ("remove", None),
|
||||||
@@ -542,7 +556,7 @@ fn proposal_json(
|
|||||||
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_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",
|
"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,
|
"evaluation": eval,
|
||||||
"votedAtInertia": voted_at,
|
"votedAtInertia": voted_at,
|
||||||
"blocked": st.blocked.contains(&p.id),
|
"blocked": st.blocked.contains(&p.id),
|
||||||
|
|||||||
+200
-39
@@ -7,6 +7,25 @@
|
|||||||
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
|
//! `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
|
//! 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.
|
//! 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 crate::AppState;
|
||||||
use ld_engine::address;
|
use ld_engine::address;
|
||||||
@@ -15,11 +34,19 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
|
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
|
||||||
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
|
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
|
||||||
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
|
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
|
||||||
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
|
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);
|
const TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -32,7 +59,28 @@ struct Answer {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct Data {
|
struct Data {
|
||||||
tx_comments: Nodes<Node>,
|
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<PageInfo>,
|
||||||
|
nodes: Vec<Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PageInfo {
|
||||||
|
has_next_page: bool,
|
||||||
|
end_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -85,12 +133,23 @@ pub struct Found {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One page of an answer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Page {
|
||||||
|
/// The last final block (`squidStatus.height`).
|
||||||
|
pub finalized: u32,
|
||||||
|
pub comments: Vec<Found>,
|
||||||
|
/// The cursor of the next page, if there is one.
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Reading {
|
pub enum Reading {
|
||||||
/// No indexer answered.
|
/// No indexer answered.
|
||||||
Silent,
|
Silent,
|
||||||
/// The comments found (possibly none).
|
/// What an indexer answered: the comments in final blocks, in the order of the
|
||||||
Found(Vec<Found>),
|
/// chain (possibly none), and whether others wait in blocks not yet final.
|
||||||
|
Found { comments: Vec<Found>, unfinal: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
fn amount(v: &serde_json::Value) -> Option<u64> {
|
fn amount(v: &serde_json::Value) -> Option<u64> {
|
||||||
@@ -106,6 +165,9 @@ fn found(node: Node) -> Option<Found> {
|
|||||||
if position.block != node.block_number {
|
if position.block != node.block_number {
|
||||||
return None;
|
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 (_, author) = address::decode(&node.author.id)?;
|
||||||
let transfers = node
|
let transfers = node
|
||||||
.transfers_by_comment_id
|
.transfers_by_comment_id
|
||||||
@@ -133,59 +195,127 @@ fn found(node: Node) -> Option<Found> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comments of one answer of the indexer; `None` if it is not one.
|
/// One page of an answer of the indexer; `None` if it is not one.
|
||||||
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
|
pub fn parse(body: &serde_json::Value) -> Option<Page> {
|
||||||
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
||||||
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let nodes = answer.data?.tx_comments.nodes;
|
let data = answer.data?;
|
||||||
|
let nodes = data.tx_comments.nodes;
|
||||||
let count = nodes.len();
|
let count = nodes.len();
|
||||||
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
||||||
if out.len() != count {
|
if comments.len() != count {
|
||||||
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
|
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…`).
|
/// What may settle a ballot, asked of the indexer: the comments of the bound
|
||||||
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
|
/// account; unbound, those paid to the vote account — at least the amount.
|
||||||
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
|
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<serde_json::Value> {
|
||||||
|
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<Page> {
|
||||||
|
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;
|
let mut answered = false;
|
||||||
for url in &state.cfg.indexers {
|
for url in &state.cfg.indexers {
|
||||||
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
|
let mut kept: Vec<Found> = Vec::new();
|
||||||
let value: Option<serde_json::Value> = match res {
|
let mut unfinal = false;
|
||||||
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
let mut after: Option<String> = None;
|
||||||
Ok(r) => {
|
let mut finalized: Option<u32> = None;
|
||||||
tracing::debug!(%url, status = %r.status(), "indexer");
|
for n in 0..MAX_PAGES {
|
||||||
None
|
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) => {
|
if unfinal || settled(&kept) {
|
||||||
tracing::debug!(%url, error = %e, "indexer");
|
break;
|
||||||
None
|
|
||||||
}
|
}
|
||||||
};
|
match p.next {
|
||||||
match value.as_ref().and_then(parse) {
|
Some(cursor) => after = Some(cursor),
|
||||||
Some(list) if !list.is_empty() => return Reading::Found(list),
|
None => break,
|
||||||
Some(_) => answered = true,
|
}
|
||||||
None => {}
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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");
|
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_real_answer_reads() {
|
fn a_real_answer_reads() {
|
||||||
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
||||||
let list = parse(&body).expect("an answer");
|
let page = parse(&body).expect("an answer");
|
||||||
assert_eq!(list.len(), 4);
|
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];
|
let first = &list[0];
|
||||||
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
||||||
assert_eq!(first.remark, "Merci ");
|
assert_eq!(first.remark, "Merci ");
|
||||||
@@ -198,6 +328,8 @@ mod tests {
|
|||||||
// An account without identity.
|
// An account without identity.
|
||||||
assert_eq!(list[2].comment.identity, None);
|
assert_eq!(list[2].comment.identity, None);
|
||||||
assert_eq!(list[2].name, 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]
|
#[test]
|
||||||
@@ -205,19 +337,48 @@ mod tests {
|
|||||||
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
||||||
assert!(parse(&json!({ "data": null })).is_none());
|
assert!(parse(&json!({ "data": null })).is_none());
|
||||||
assert!(parse(&json!({ "nope": 1 })).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]
|
#[test]
|
||||||
fn a_node_that_does_not_read_is_left_aside() {
|
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",
|
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
|
||||||
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
|
"author": { "id": author, "identity": null },
|
||||||
"transfersByCommentId": { "nodes": [] } },
|
"transfersByCommentId": { "nodes": [] } },
|
||||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||||
"author": { "id": "not an address", "identity": null },
|
"author": { "id": "not an address", "identity": null },
|
||||||
"transfersByCommentId": { "nodes": [] } },
|
"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" } } } })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/docs/{id}/proposals", post(proposals::create))
|
.route("/docs/{id}/proposals", post(proposals::create))
|
||||||
.route("/docs/{id}/votes", post(ballots::vote_many))
|
.route("/docs/{id}/votes", post(ballots::vote_many))
|
||||||
.route("/docs/{id}/ballots", post(ballots::create))
|
.route("/docs/{id}/ballots", post(ballots::create))
|
||||||
|
.route("/ballots", get(ballots::mine))
|
||||||
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
.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}", get(proposals::detail).patch(proposals::edit))
|
||||||
.route("/proposals/{id}/submit", post(proposals::submit))
|
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ pub async fn detail(
|
|||||||
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let eval = st.evaluation(unit, &p);
|
let eval = st.shown_evaluation(unit, &p);
|
||||||
let voted_at = st.voting_level(unit, &p);
|
let voted_at = st.voting_level(unit, &p);
|
||||||
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
||||||
let tr = docs::translations_for(&state, &[&p]).await?;
|
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()),
|
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_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",
|
"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),
|
"blocked": st.blocked.contains(&p.id),
|
||||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
||||||
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
"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,
|
"voteFloor": st.doc.vote_floor,
|
||||||
"participation": st.participation_json(),
|
"participation": st.participation_json(),
|
||||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
// 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()),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,13 @@ pub struct JoinBody {
|
|||||||
/// `name_key` settles two openings at the same instant.
|
/// `name_key` settles two openings at the same instant.
|
||||||
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||||
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
||||||
// People already there, and those an invitation still waits for.
|
// People already there, and those an invitation still waits for. A Ğ1
|
||||||
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
|
// 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<String> =
|
||||||
|
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(
|
taken.extend(
|
||||||
sqlx::query_scalar::<_, String>(
|
sqlx::query_scalar::<_, String>(
|
||||||
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ struct Chain {
|
|||||||
comments: Vec<Value>,
|
comments: Vec<Value>,
|
||||||
silent: bool,
|
silent: bool,
|
||||||
asked: usize,
|
asked: usize,
|
||||||
|
/// The last final block, served as `squidStatus.height`; `None`: every block is final.
|
||||||
|
finalized: Option<u32>,
|
||||||
|
/// 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<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -49,21 +55,66 @@ struct Signer<'a> {
|
|||||||
identity: Option<(u32, &'a str, bool)>,
|
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::<u64>().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<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
||||||
let mut c = chain.lock().expect("lock");
|
let mut c = chain.lock().expect("lock");
|
||||||
c.asked += 1;
|
c.asked += 1;
|
||||||
if c.silent {
|
if c.silent {
|
||||||
return StatusCode::BAD_GATEWAY.into_response();
|
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<Value> = c
|
let mut nodes: Vec<Value> = c
|
||||||
.comments
|
.comments
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
|
.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()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
|
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
|
||||||
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
|
// 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<Value> =
|
||||||
|
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 {
|
impl Indexer {
|
||||||
@@ -103,6 +154,19 @@ impl Indexer {
|
|||||||
fn asked(&self) -> usize {
|
fn asked(&self) -> usize {
|
||||||
self.chain.lock().expect("lock").asked
|
self.chain.lock().expect("lock").asked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The chain is final up to this block.
|
||||||
|
fn finalized(&self, block: Option<u32>) {
|
||||||
|
self.chain.lock().expect("lock").finalized = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lax(&self, on: bool) {
|
||||||
|
self.chain.lock().expect("lock").lax = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filters(&self) -> Vec<Value> {
|
||||||
|
self.chain.lock().expect("lock").filters.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn alice() -> Signer<'static> {
|
fn alice() -> Signer<'static> {
|
||||||
@@ -138,6 +202,16 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (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<Value>,
|
||||||
|
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
||||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
if let Some(c) = &self.cookie {
|
if let Some(c) = &self.cookie {
|
||||||
req = req.header(header::COOKIE, c);
|
req = req.header(header::COOKIE, c);
|
||||||
@@ -155,8 +229,9 @@ impl Client {
|
|||||||
self.cookie = Some(raw);
|
self.cookie = Some(raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let headers = res.headers().clone();
|
||||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
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>) -> Value {
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||||
@@ -169,6 +244,10 @@ impl Client {
|
|||||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
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 {
|
async fn doc(&mut self, group: &str) -> Value {
|
||||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
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;
|
c.login("Chiara").await;
|
||||||
let doc = c.doc("monnaie-libre").await;
|
let doc = c.doc("monnaie-libre").await;
|
||||||
let i1 = version(&doc, "I1", "inForce");
|
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)]);
|
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)]);
|
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(), &[]);
|
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");
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
for b in [&short, &elsewhere, &remark_only] {
|
for b in [&short, &elsewhere, &remark_only] {
|
||||||
let s = c.status(b).await;
|
let s = c.status(b).await;
|
||||||
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
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));
|
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)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.expect("age");
|
.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!(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.
|
// Seen on the chain afterwards: too late, it no longer counts.
|
||||||
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
||||||
ballots::sweep(&state).await.expect("sweep");
|
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");
|
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<String>) {
|
||||||
|
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<String> =
|
||||||
|
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<ld_engine::ballot::Line> = 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
|
// Without the chain, and in managed groups
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+483
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
"squidStatus": {
|
||||||
|
"height": 3407492
|
||||||
|
},
|
||||||
"txComments": {
|
"txComments": {
|
||||||
|
"pageInfo": {
|
||||||
|
"hasNextPage": false,
|
||||||
|
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
|
||||||
|
},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "0000196944-60692-000003",
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user