Fusion du lot B2 : corrections du vote avant de l'ouvrir (adresse bornée, tâche de fond, plafond, copies non payées, finalité, reçu)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 04:02:21 +01:00
co-authored by Claude Opus 5.5
32 changed files with 2534 additions and 181 deletions
+25
View File
@@ -7,6 +7,12 @@
/// The SS58 prefix of the Ğ1 chain.
pub const G1_PREFIX: u16 = 4450;
/// The longest text read as an address. An SS58 address of a 32-byte key is
/// about 48 characters; base58 decoding costs the square of the length, so a
/// longer text is refused before it is decoded (review of 74f96be: 250 000
/// characters took 32 s on a thread of the server).
pub const MAX_LEN: usize = 64;
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn base58_decode(s: &str) -> Option<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.
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
if addr.len() > MAX_LEN {
return None;
}
let data = base58_decode(addr)?;
let (prefix, at) = match *data.first()? {
b @ 0..=63 => (u16::from(b), 1),
@@ -146,6 +155,22 @@ mod tests {
}
}
#[test]
fn a_long_text_is_refused_before_it_is_decoded() {
// Base58 decoding costs the square of the length, on a thread of the server:
// 250 000 characters took 32 s (review of 74f96be). An SS58 address is about 48.
for (g1, _, generic) in VECTORS {
assert!(g1.len() <= 64 && generic.len() <= 64);
}
let long = "z".repeat(40_000);
let t = std::time::Instant::now();
assert_eq!(decode(&long), None);
assert_eq!(to_g1(&long), None);
assert!(t.elapsed() < std::time::Duration::from_millis(20), "{:?}", t.elapsed());
// One character too many, even of leading ones (zero bytes), is refused.
assert_eq!(decode(&format!("{}{}", "1".repeat(65 - VECTORS[0].0.len()), VECTORS[0].0)), None);
}
#[test]
fn what_is_not_an_address() {
let (g1, _, _) = VECTORS[0];
+65 -10
View File
@@ -17,6 +17,11 @@
//! per identity, in the order of the chain — block, then rank of the event in
//! the block. A ballot confirmed late never overrides a more recent one.
//!
//! Anyone who sees a remark may copy it. A ballot bound to an account is settled
//! by that account's comment only; a ballot bound to none (a Ğecko code) by a
//! **paid** comment only — a bare remark costs nothing to send, a transfer of
//! the amount does.
//!
//! Pure: the salt comes from the caller, the facts of the chain too.
use crate::address::{self, G1_PREFIX};
@@ -217,6 +222,10 @@ pub enum Verdict {
Paid { identity: ChainIdentity },
/// Signed by another account than the one bound: a copy of the remark, ignored.
OtherAuthor,
/// A ballot bound to no account (a Ğecko code), and a comment without the
/// transfer: a copy of the remark anyone may send for free, ignored. Only a
/// paid comment settles such a ballot.
Unpaid,
/// The account is not that of an identity.
NoIdentity,
/// No transfer of the amount to the vote account in the same transaction.
@@ -227,30 +236,46 @@ impl Verdict {
/// Why a ballot is refused, as stored and translated.
pub fn reason(self) -> Option<&'static str> {
match self {
Self::Paid { .. } | Self::OtherAuthor => None,
Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None,
Self::NoIdentity => Some("no_identity"),
Self::NoTransfer => Some("transfer"),
}
}
/// Whether this comment settles the ballot (counted or refused), or is a copy to pass over.
pub fn settles(self) -> bool {
!matches!(self, Self::OtherAuthor | Self::Unpaid)
}
}
/// Whether the comment's transaction carries the transfer the ballot asks:
/// from its signer, to the vote account, at least the amount.
pub fn paid(c: &Comment, e: &Expected) -> bool {
c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount)
}
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
if e.author.is_some_and(|a| a != c.author) {
return Verdict::OtherAuthor;
}
let paid = paid(c, e);
if e.author.is_none() && !paid {
return Verdict::Unpaid;
}
let Some(identity) = c.identity else { return Verdict::NoIdentity };
if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) {
if !paid {
return Verdict::NoTransfer;
}
Verdict::Paid { identity }
}
/// The comment that settles a ballot: the first in the order of the chain that is
/// not a copy by another account. `None`: nothing yet, keep waiting.
/// not a copy — by another account than the one bound, or, unbound, without the
/// transfer. `None`: nothing yet, keep waiting.
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
let mut sorted: Vec<&Comment> = comments.iter().collect();
sorted.sort_by_key(|c| c.position);
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| *v != Verdict::OtherAuthor)
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles())
}
#[cfg(test)]
@@ -400,15 +425,18 @@ mod tests {
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
// Too little, elsewhere, or nothing.
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
// Paid from another account than the signer's.
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
// Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound.
let bound = Expected { author: Some(key(SIMONE)), ..e };
for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] {
let c = comment(1, SIMONE, Some((1304, true)), unpaid);
assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}");
assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}");
}
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
assert_eq!(ok.reason(), None);
assert_eq!(Verdict::Unpaid.reason(), None);
assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles());
}
#[test]
@@ -429,4 +457,31 @@ mod tests {
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
assert_eq!(judge(&[], &free), None);
}
#[test]
fn an_unbound_ballot_is_settled_by_a_paid_comment_only() {
// A Ğecko code binds no account. A remark alone costs nothing to send:
// a bare copy must not settle the ballot (review of 74f96be, P3).
let free = Expected { account: key(ALICE), amount: 10, author: None };
let bare = comment(8, SIMONE, None, &[]);
let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]);
assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting");
let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
assert_eq!(
judge(&[paid.clone(), elsewhere, short, bare], &free),
Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER }))
);
// Paid by an account without identity: paid, so it settles — refused.
let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]);
assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity)));
// The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot.
let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
let theirs = ChainIdentity { index: 1, member: true };
assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs })));
// Bound to an account: its own comment without the transfer is refused, and told.
let bound = Expected { author: Some(key(SIMONE)), ..free };
let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer)));
}
}
+272 -39
View File
@@ -15,9 +15,12 @@
//! - **The last one stands**, per proposal and per identity, in the order of
//! the chain: a ballot confirmed late never overrides a more recent one.
//!
//! A background task reads the indexer for pending ballots every 10 seconds
//! while there are some, lets them expire after 24 hours, and runs the
//! adoption pass after each confirmation.
//! A background task reads the indexer for pending ballots every 10 seconds,
//! at most 50 a pass, the least recently read first: every pending ballot is
//! read at least every ⌈n / 50⌉ passes, whether a page waits for it or not (a
//! Ğecko code). A ballot is read one last time at its end (24 hours), then let
//! go; the adoption pass runs after each confirmation. Ballots let go are purged
//! after a while (a day for those the wallet never signed, a week for the others).
use crate::AppState;
use crate::adoption::{self, Entered};
@@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, header};
use axum::response::{IntoResponse, Response};
use ld_engine::address;
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
use rand::RngCore;
@@ -40,15 +45,26 @@ use trustin_access::VoteWay;
/// Every article of a long document, with room to spare.
const MAX_LINES: usize = 200;
/// Pending ballots one session may hold at once.
/// Ballots one identity may hold at once: those pending, and those let go within
/// the last hour (`DELETE`, the wallet did not sign) — whatever the session.
const MAX_PENDING: i64 = 20;
const DROPPED_WINDOW_SECS: i64 = 3600;
/// A ballot not seen on the chain within this time is let go.
pub const EXPIRES_HOURS: i64 = 24;
/// At its end, a ballot no indexer could be read for — or whose comment waits
/// for a final block — is kept and read again, this long at most.
const GRACE_SECS: i64 = 3600;
/// Let go, a ballot is purged, lines and all: a day after the wallet did not sign
/// it, a week after it was not seen in time (under the article, it says so meanwhile).
const PURGE_DROPPED_SECS: i64 = 24 * 3600;
const PURGE_EXPIRED_SECS: i64 = 7 * 24 * 3600;
/// The page asks often; the indexer is read at most this often per ballot.
const RECHECK_SECS: i64 = 3;
const WATCH_EVERY: Duration = Duration::from_secs(10);
/// Pending ballots read per pass of the background task.
/// Pending ballots read per pass of the background task, the least recently read first.
const PER_PASS: i64 = 50;
/// Ballots a history shows, the most recent first.
const HISTORY: i64 = 200;
/// One confirmation at a time: « the last one stands » reads the other ballots of
/// the identity, then writes; two at once could both read before either writes.
@@ -226,14 +242,16 @@ pub async fn create(
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
None => None,
};
let pending: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
let held: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ballots WHERE requested_by = ?
AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))",
)
.bind(&user.id)
.bind(util::now())
.bind(util::in_seconds(-DROPPED_WINDOW_SECS))
.fetch_one(&state.db)
.await?;
if pending >= MAX_PENDING {
if held >= MAX_PENDING {
return Err(AppError::Conflict("too_many_ballots"));
}
let lines: Vec<ballot::Line> = casts
@@ -333,6 +351,160 @@ pub async fn drop(
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
}
// ---------------------------------------------------------------------------
// The receipt: the ballot itself, for the one who asked — never anyone else
// ---------------------------------------------------------------------------
/// A title as stored (`{lang: text}`), as JSON; a bare text if it is not a map.
fn title_json(raw: &str) -> serde_json::Value {
serde_json::from_str::<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> {
let voter: Option<String> = match &row.voter_id {
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<()> {
let now = util::now();
if row.expires_at <= now {
expire(state).await?;
return Ok(());
}
let reading = indexer::comments(state, &row.remark_hash).await;
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
let Reading::Found(list) = reading else {
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
return Ok(());
};
let comment = row.comment();
let list: Vec<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
let due = row.expires_at <= now;
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
};
let expected =
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
let comment = row.comment();
// Only the comments that carry exactly the remark of the ballot.
let judged = |list: &[Found]| {
let mine: Vec<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 {
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
@@ -516,25 +706,59 @@ async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -
Ok(())
}
/// Lets go of the pending ballots not seen on the chain in time.
/// Lets go of one pending ballot not seen on the chain in time.
async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> {
sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'")
.bind(util::now())
.bind(&row.id)
.execute(&state.db)
.await?;
Ok(())
}
/// Lets go of the pending ballots past their end and the grace after it: those
/// the indexer could not be read for in time.
pub async fn expire(state: &AppState) -> AppResult<u64> {
let now = util::now();
let done = sqlx::query(
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
)
.bind(&now)
.bind(&now)
.bind(util::now())
.bind(util::in_seconds(-GRACE_SECS))
.execute(&state.db)
.await?;
Ok(done.rows_affected())
}
/// One pass of the background task: expire, then read the indexer for each
/// pending ballot, oldest first. Returns how many were read.
/// Purges the ballots let go long enough ago, lines and all. A ballot let go
/// never counted: nothing of a vote is lost.
pub async fn purge(state: &AppState) -> AppResult<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> {
expire(state).await?;
purge(state).await?;
let rows = sqlx::query_as::<_, BallotRow>(&format!(
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
"{BALLOT_SELECT} WHERE status = 'pending'
ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}"
))
.fetch_all(&state.db)
.await?;
@@ -600,9 +824,10 @@ pub struct MyBallot {
pub choice: String,
}
/// Per version: the most recent pending ballot of `me`; else its most recent
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
/// A ballot the wallet never signed (dropped) says nothing.
/// Per version: the most recent pending ballot of `me` asked after its last
/// confirmed one; else its most recent ballot if that one was refused or let go
/// (a confirmed one shows as its vote). A ballot the wallet never signed
/// (dropped) says nothing; one asked before a confirmed vote says nothing either.
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
#[derive(sqlx::FromRow)]
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();
for r in rows {
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
if b.status == "pending" {
pending.insert(r.proposal_id.clone(), b.clone());
match b.status.as_str() {
// A confirmed vote: what was asked before it says nothing more.
"confirmed" => {
latest.remove(&r.proposal_id);
pending.remove(&r.proposal_id);
}
"pending" => {
pending.insert(r.proposal_id, b);
}
_ => {
latest.insert(r.proposal_id, b);
}
}
latest.insert(r.proposal_id, b);
}
latest.retain(|_, b| b.status != "confirmed");
latest.extend(pending);
Ok(latest)
}
+16 -2
View File
@@ -396,6 +396,20 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The totals shown to people: today the live count, as `tally`. The one
/// place where a publication by steps would plug in — on the chain, who
/// voted and when is public, and live totals tell what (the secret of the
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
/// `tally`; the published texts (`publish.rs`) would follow this one.
pub fn shown_tally(&self, id: &str) -> Tally {
self.tally(id)
}
/// How far the threshold is, as shown: on `shown_tally`.
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
}
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
self.units
@@ -525,7 +539,7 @@ fn proposal_json(
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let eval = st.shown_evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
@@ -542,7 +556,7 @@ fn proposal_json(
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"tally": st.shown_tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
+200 -39
View File
@@ -7,6 +7,25 @@
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
//! errors, is skipped; one that answers « nothing » may be behind, so the next
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
//!
//! **Finality.** The indexer serves the best chain, blocks not yet final
//! included. `squidStatus` exposes `height` and `hash` only (no
//! `finalizedHeight`); `height` is the last final block it processed — relevé
//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block
//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the
//! finality of Duniter v2. Only a comment at or below it settles a ballot: a
//! comment above may still vanish with its block.
//!
//! **What is asked.** Only what may settle the ballot: for a ballot bound to an
//! account, that account's comments; for one bound to none (a Ğecko code), the
//! comments paid to the vote account — a bare copy of a remark costs nothing to
//! send, and must never hide the real one. The engine judges again what comes
//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes
//! nothing but the number of pages.
//!
//! **Pages.** In the order of the chain, 50 comments a page, page after page
//! until a comment settles the ballot, a block not yet final is reached, or the
//! last page — at most 20 pages a reading.
use crate::AppState;
use ld_engine::address;
@@ -15,11 +34,19 @@ use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \
nodes { id blockNumber remark author { id identity { index name isMember } } \
transfersByCommentId { nodes { fromId toId amount } } } } }";
/// Pages read at most for one ballot, one reading.
pub const MAX_PAGES: usize = 20;
/// The largest answer read: a page of 50 comments weighs about 30 KB.
const MAX_BODY: usize = 1 << 20;
/// A Ğ1 identity name is 42 characters at most.
const MAX_NAME: usize = 64;
const TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Deserialize)]
@@ -32,7 +59,28 @@ struct Answer {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
tx_comments: Nodes<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)]
@@ -85,12 +133,23 @@ pub struct Found {
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)]
pub enum Reading {
/// No indexer answered.
Silent,
/// The comments found (possibly none).
Found(Vec<Found>),
/// What an indexer answered: the comments in final blocks, in the order of the
/// chain (possibly none), and whether others wait in blocks not yet final.
Found { comments: Vec<Found>, unfinal: bool },
}
fn amount(v: &serde_json::Value) -> Option<u64> {
@@ -106,6 +165,9 @@ fn found(node: Node) -> Option<Found> {
if position.block != node.block_number {
return None;
}
if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) {
return None;
}
let (_, author) = address::decode(&node.author.id)?;
let transfers = node
.transfers_by_comment_id
@@ -133,59 +195,127 @@ fn found(node: Node) -> Option<Found> {
})
}
/// The comments of one answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
/// One page of an answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Page> {
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
return None;
}
let nodes = answer.data?.tx_comments.nodes;
let data = answer.data?;
let nodes = data.tx_comments.nodes;
let count = nodes.len();
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
if out.len() != count {
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
if comments.len() != count {
tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside");
}
Some(out)
let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor);
Some(Page { finalized: data.squid_status.height, comments, next })
}
/// The comments carrying the remark of hash `remark_hash` (`0x…`).
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
/// What may settle a ballot, asked of the indexer: the comments of the bound
/// account; unbound, those paid to the vote account — at least the amount.
pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value {
match author {
Some(a) => json!({ "authorId": { "equalTo": a } }),
None => json!({ "transfersByCommentId": { "some": {
"toId": { "equalTo": account },
"amount": { "greaterThanOrEqualTo": amount.to_string() },
} } }),
}
}
/// Reads a body of at most `MAX_BODY` bytes as JSON.
async fn read_json(mut res: reqwest::Response) -> Option<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;
for url in &state.cfg.indexers {
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
Ok(r) => {
tracing::debug!(%url, status = %r.status(), "indexer");
None
let mut kept: Vec<Found> = Vec::new();
let mut unfinal = false;
let mut after: Option<String> = None;
let mut finalized: Option<u32> = None;
for n in 0..MAX_PAGES {
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } });
let Some(p) = page(state, url, &body).await else { break };
// The finality of the first page holds for the reading: later pages may only see more.
let last_final = *finalized.get_or_insert(p.finalized);
answered = true;
for f in p.comments {
if f.comment.position.block > last_final {
unfinal = true;
} else {
kept.push(f);
}
}
Err(e) => {
tracing::debug!(%url, error = %e, "indexer");
None
if unfinal || settled(&kept) {
break;
}
};
match value.as_ref().and_then(parse) {
Some(list) if !list.is_empty() => return Reading::Found(list),
Some(_) => answered = true,
None => {}
match p.next {
Some(cursor) => after = Some(cursor),
None => break,
}
if n + 1 == MAX_PAGES {
tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending");
}
}
if !kept.is_empty() || unfinal {
return Reading::Found { comments: kept, unfinal };
}
}
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent }
}
#[cfg(test)]
mod tests {
use super::*;
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
/// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), for the remark « Merci ».
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
#[test]
fn a_real_answer_reads() {
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
let list = parse(&body).expect("an answer");
assert_eq!(list.len(), 4);
let page = parse(&body).expect("an answer");
assert_eq!(page.finalized, 3_407_492);
assert_eq!(page.next, None, "one page: 26 comments, fewer than 50");
let list = page.comments;
assert_eq!(list.len(), 26, "the old query stopped at 20");
let first = &list[0];
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
assert_eq!(first.remark, "Merci ");
@@ -198,6 +328,8 @@ mod tests {
// An account without identity.
assert_eq!(list[2].comment.identity, None);
assert_eq!(list[2].name, None);
// In the order of the chain.
assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position));
}
#[test]
@@ -205,19 +337,48 @@ mod tests {
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
assert!(parse(&json!({ "data": null })).is_none());
assert!(parse(&json!({ "nope": 1 })).is_none());
assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0));
// Without the finality, an answer cannot settle anything: no answer.
assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none());
let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } });
assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None)));
let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": {
"pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } });
assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc"));
}
#[test]
fn a_node_that_does_not_read_is_left_aside() {
let body = json!({ "data": { "txComments": { "nodes": [
let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
"author": { "id": author, "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "not an address", "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "z".repeat(100_000), "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } },
"transfersByCommentId": { "nodes": [] } },
] } } });
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
assert_eq!(
parse(&body).map(|p| p.comments.len()),
Some(0),
"block and id disagree; a bad address; a long one; a name no identity has"
);
}
#[test]
fn what_is_asked_is_what_may_settle() {
let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM";
let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } }));
assert_eq!(
filter(vote, 10, None),
json!({ "transfersByCommentId": { "some": {
"toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } })
);
}
}
+2
View File
@@ -121,7 +121,9 @@ pub fn api_router() -> Router<AppState> {
.route("/docs/{id}/proposals", post(proposals::create))
.route("/docs/{id}/votes", post(ballots::vote_many))
.route("/docs/{id}/ballots", post(ballots::create))
.route("/ballots", get(ballots::mine))
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
.route("/ballots/{id}/receipt", get(ballots::receipt))
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
.route("/proposals/{id}/submit", post(proposals::submit))
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
+3 -3
View File
@@ -624,7 +624,7 @@ pub async fn detail(
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
};
let eval = st.evaluation(unit, &p);
let eval = st.shown_evaluation(unit, &p);
let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?;
@@ -636,7 +636,7 @@ pub async fn detail(
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
@@ -653,7 +653,7 @@ pub async fn detail(
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.tally(&p.id).cast()),
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
})))
}
+7 -2
View File
@@ -97,8 +97,13 @@ pub struct JoinBody {
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
// People already there, and those an invitation still waits for.
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
// People already there, and those an invitation still waits for. A Ğ1
// identity known only from a vote on the chain (never signed in here) is not
// there: as for any member of the web of trust who never came, its name stays free.
let mut taken: Vec<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(
sqlx::query_scalar::<_, String>(
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
+479 -8
View File
@@ -35,6 +35,12 @@ struct Chain {
comments: Vec<Value>,
silent: bool,
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)]
@@ -49,21 +55,66 @@ struct Signer<'a> {
identity: Option<(u32, &'a str, bool)>,
}
/// What the real indexer's `filter` keeps, for the two filters the server may ask.
fn kept_by(node: &Value, filter: &Value) -> bool {
if let Some(author) = filter["authorId"]["equalTo"].as_str()
&& node["author"]["id"] != author
{
return false;
}
let some = &filter["transfersByCommentId"]["some"];
if some.is_null() {
return true;
}
let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0);
node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| {
some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to)
&& t["amount"].as_str().and_then(|a| a.parse::<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 {
let mut c = chain.lock().expect("lock");
c.asked += 1;
if c.silent {
return StatusCode::BAD_GATEWAY.into_response();
}
let hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
let vars = &body["variables"];
let hash = vars["hash"].as_str().unwrap_or_default().to_owned();
let filter = vars["filter"].clone();
c.filters.push(filter.clone());
let mut nodes: Vec<Value> = c
.comments
.iter()
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
.filter(|n| c.lax || kept_by(n, &filter))
.cloned()
.collect();
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
// Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one.
let start: usize =
vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0);
let page: Vec<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 {
@@ -103,6 +154,19 @@ impl Indexer {
fn asked(&self) -> usize {
self.chain.lock().expect("lock").asked
}
/// The chain is final up to this block.
fn finalized(&self, block: Option<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> {
@@ -138,6 +202,16 @@ impl Client {
}
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);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
@@ -155,8 +229,9 @@ impl Client {
self.cookie = Some(raw);
}
}
let headers = res.headers().clone();
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
@@ -169,6 +244,10 @@ impl Client {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
}
async fn login_admin(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
@@ -396,17 +475,22 @@ async fn a_wrong_amount_or_recipient_is_refused() {
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
// Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free.
let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
ballots::sweep(&state).await.expect("sweep");
for b in [&short, &elsewhere, &remark_only] {
let s = c.status(b).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
}
assert_eq!(c.status(&gecko).await["status"], "pending");
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
}
@@ -559,8 +643,10 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
.execute(&state.db)
.await
.expect("age");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
// Read one last time at its end: nothing on the chain, it is let go.
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
assert_eq!(c.status(&b).await["status"], "expired");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
// Seen on the chain afterwards: too late, it no longer counts.
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
@@ -668,6 +754,391 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
}
// ---------------------------------------------------------------------------
// After the independent review of 74f96be (lot B2): its probes, kept as tests
// ---------------------------------------------------------------------------
async fn db_status(state: &AppState, id: &str) -> (String, Option<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
// ---------------------------------------------------------------------------
+483
View File
@@ -1,6 +1,13 @@
{
"data": {
"squidStatus": {
"height": 3407492
},
"txComments": {
"pageInfo": {
"hasNextPage": false,
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
},
"nodes": [
{
"id": "0000196944-60692-000003",
@@ -85,6 +92,482 @@
}
]
}
},
{
"id": "0000821130-de04d-000008",
"blockNumber": 821130,
"remark": "Merci ",
"author": {
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
"identity": {
"index": 3947,
"name": "Jade971",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
"toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc",
"amount": "5000"
}
]
}
},
{
"id": "0001281169-c9002-000006",
"blockNumber": 1281169,
"remark": "Merci ",
"author": {
"id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
"identity": {
"index": 7210,
"name": "Tell",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
"toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq",
"amount": "18000"
}
]
}
},
{
"id": "0001303965-1b924-000006",
"blockNumber": 1303965,
"remark": "Merci ",
"author": {
"id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
"identity": {
"index": 13370,
"name": "Florence76",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
"toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J",
"amount": "1500"
}
]
}
},
{
"id": "0001810083-37092-000004",
"blockNumber": 1810083,
"remark": "Merci ",
"author": {
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
"identity": null
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
"toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK",
"amount": "58900"
}
]
}
},
{
"id": "0001810417-7e2d8-000003",
"blockNumber": 1810417,
"remark": "Merci ",
"author": {
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
"identity": null
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
"toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6",
"amount": "15000"
}
]
}
},
{
"id": "0001850707-782ab-000003",
"blockNumber": 1850707,
"remark": "Merci ",
"author": {
"id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
"identity": {
"index": 13722,
"name": "LibrA",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
"toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb",
"amount": "70680"
}
]
}
},
{
"id": "0002197191-e9abb-000006",
"blockNumber": 2197191,
"remark": "Merci ",
"author": {
"id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
"identity": {
"index": 17203,
"name": "Elodietexier",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
"toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn",
"amount": "3534"
}
]
}
},
{
"id": "0002727091-bd26d-000003",
"blockNumber": 2727091,
"remark": "Merci ",
"author": {
"id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
"identity": {
"index": 18509,
"name": "RobBiovoix",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
"toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r",
"amount": "5000"
}
]
}
},
{
"id": "0002825411-05f96-000003",
"blockNumber": 2825411,
"remark": "Merci ",
"author": {
"id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
"identity": {
"index": 18506,
"name": "BrunoAlmeida",
"isMember": false
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
"toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk",
"amount": "5000"
}
]
}
},
{
"id": "0002939378-e9fef-000006",
"blockNumber": 2939378,
"remark": "Merci ",
"author": {
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"identity": {
"index": 18467,
"name": "Vavafra",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
"amount": "15000"
}
]
}
},
{
"id": "0002945277-0f2cb-000003",
"blockNumber": 2945277,
"remark": "Merci ",
"author": {
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
"identity": {
"index": 18493,
"name": "moidann",
"isMember": false
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
"toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k",
"amount": "3000"
}
]
}
},
{
"id": "0003114157-3fc38-000006",
"blockNumber": 3114157,
"remark": "Merci ",
"author": {
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"identity": {
"index": 18467,
"name": "Vavafra",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB",
"amount": "4000"
}
]
}
},
{
"id": "0003124959-a6247-000003",
"blockNumber": 3124959,
"remark": "Merci ",
"author": {
"id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
"identity": {
"index": 7377,
"name": "Sylvaine",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
"toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3",
"amount": "5000"
}
]
}
},
{
"id": "0003128170-4521a-000003",
"blockNumber": 3128170,
"remark": "Merci ",
"author": {
"id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
"identity": {
"index": 8912,
"name": "Altheaagathe",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
"toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK",
"amount": "40000"
}
]
}
},
{
"id": "0003226425-d15c3-000009",
"blockNumber": 3226425,
"remark": "Merci ",
"author": {
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
"identity": {
"index": 11102,
"name": "Philp",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
"amount": "14604"
}
]
}
},
{
"id": "0003227886-5270d-000003",
"blockNumber": 3227886,
"remark": "Merci ",
"author": {
"id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
"identity": {
"index": 16572,
"name": "zola",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
"toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx",
"amount": "6000"
}
]
}
},
{
"id": "0003229458-31a61-000006",
"blockNumber": 3229458,
"remark": "Merci ",
"author": {
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
"identity": {
"index": 11102,
"name": "Philp",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
"amount": "12170"
}
]
}
},
{
"id": "0003297346-087b4-000006",
"blockNumber": 3297346,
"remark": "Merci ",
"author": {
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"identity": {
"index": 18467,
"name": "Vavafra",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
"amount": "10000"
}
]
}
},
{
"id": "0003360722-61edb-000006",
"blockNumber": 3360722,
"remark": "Merci ",
"author": {
"id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
"identity": {
"index": 14694,
"name": "Coca-Cola",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
"toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv",
"amount": "6085"
}
]
}
},
{
"id": "0003384655-8fbc0-000003",
"blockNumber": 3384655,
"remark": "Merci ",
"author": {
"id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
"identity": {
"index": 4018,
"name": "GeraldineGarrigues",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
"toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm",
"amount": "3000"
}
]
}
},
{
"id": "0003399415-51dbe-000003",
"blockNumber": 3399415,
"remark": "Merci ",
"author": {
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
"identity": {
"index": 18493,
"name": "moidann",
"isMember": false
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
"amount": "10000"
}
]
}
},
{
"id": "0003399566-d43e7-000003",
"blockNumber": 3399566,
"remark": "Merci ",
"author": {
"id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
"identity": {
"index": 18630,
"name": "moidann12",
"isMember": false
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
"amount": "15000"
}
]
}
}
]
}
+118 -24
View File
@@ -3,7 +3,9 @@
> Complète [0002](0002-vote-permanent.md) (vote permanent) et [0006](0006-acces-connect-trustinwallet.md)
> (accès). Décision d'Yvv du 2026-10-30 (revue, L9) ; le format du bulletin, la voie du
> portefeuille et les réglages sont les choix par défaut d'orch (Q5, Q6, Q7), isolés pour être
> changés sans toucher au reste.
> changés sans toucher au reste. Corrigé le 2026-10-31 après la relecture indépendante de
> `74f96be` (lot B2) : finalité, recherche filtrée et paginée, tâche de fond, plafond, bulletin
> Ğecko, bulletins lâchés, reçu.
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
@@ -77,27 +79,53 @@ fait signer par l'extension de portefeuille du navigateur (interface standard `i
l'offrent), avec **polkadot-api chargé au seul moment de signer**, et l'envoie à un nœud Ğ1
(`LD_VOTE_NODES`). Sans portefeuille dans le navigateur : **Ğecko**, par un lien et un QR code
`g1://<compte>?amount=0.1&comment=ld1:<H>` (un virement commenté, ce que Ğecko sait faire) ;
ce bulletin-là n'est lié à aucun compte, et la page attend.
ce bulletin-là n'est lié à aucun compte : seul un commentaire **payé** le tranche (voir la
confirmation), et le serveur le guette, la page ouverte ou non.
La page joint un nœud **avant** de demander la signature (comme polkadot-api, qui lit le nonce
et le runtime) : un nœud muet ne reçoit jamais rien, la page le dit et lâche le bulletin.
### La confirmation — `crates/ld-server/src/ballots.rs`, `indexer.rs`
Le serveur lit l'indexeur (duniter-squid, `LD_INDEXERS`) :
Le serveur lit l'indexeur (duniter-squid, PostGraphile, `LD_INDEXERS`) :
```graphql
query Ballot($hash: String!) {
txComments(condition: {hash: $hash}, orderBy: BLOCK_NUMBER_ASC, first: 20) {
query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) {
squidStatus { height }
txComments(condition: {hash: $hash}, filter: $filter,
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { id blockNumber remark
author { id identity { index name isMember } }
transfersByCommentId { nodes { fromId toId amount } } } }
}
```
`hash` = `0x` + blake2b-256 des octets de la remarque. Il garde la première occurrence, dans
l'ordre de la chaîne, qui ne vient pas d'un autre compte que celui lié, puis exige :
`hash` = `0x` + blake2b-256 des octets de la remarque. Le `filter` ne demande que ce qui peut
trancher : pour un bulletin lié, `{authorId: {equalTo: <compte lié>}}` ; pour un bulletin Ğecko,
`{transfersByCommentId: {some: {toId: {equalTo: <compte des votes>}, amount:
{greaterThanOrEqualTo: "<montant>"}}}}`. Les commentaires viennent dans l'ordre de la chaîne,
**50 par page, page après page** (au plus 20 pages par lecture), jusqu'à celui qui tranche :
des copies ne masquent jamais le vrai commentaire (l'ancienne requête s'arrêtait à 20 ; la
remarque « Merci » en compte déjà 26 sur la chaîne).
**La finalité.** L'indexeur sert la meilleure chaîne, blocs pas encore définitifs compris.
`squidStatus` n'expose que `name`, `height` et `hash` (pas de `finalizedHeight`) ; `height` est
le dernier bloc définitif traité. Relevé le 2026-10-31 sur indexer.duniter.org, par
`{ squidStatus { height } blocks(orderBy: HEIGHT_DESC, first: 1) { nodes { height } } }` :
`height` reste **3 blocs** derrière le plus récent bloc servi (3 407 340 contre 3 407 343, puis
3 407 342 contre 3 407 345…), les 2 à 3 blocs de la finalité de Duniter v2. Seul un commentaire
d'un bloc ≤ `height` compte, ou refuse ; au-dessus, le bulletin attend.
Le moteur (`ld_engine::ballot::judge`) juge à nouveau ce qui revient — un indexeur qui ignore le
filtre ne change que le nombre de pages. Il garde le premier commentaire, dans l'ordre de la
chaîne, qui n'est pas une copie : pour un bulletin lié, un commentaire d'un autre compte est une
copie ; pour un bulletin Ğecko, **un commentaire sans le virement est une copie** (une remarque
seule ne coûte rien à envoyer). Puis il exige :
1. la remarque exacte ;
2. dans la même transaction, un virement **du signataire vers le compte des votes**, d'au moins
le montant ;
le montant (`transfer` sinon — seulement pour un bulletin lié : son propre compte s'est trompé) ;
3. une identité Ğ1 au signataire (`no_identity` sinon) ;
4. que cette identité soit **membre maintenant** (`not_member` sinon) — par la règle de
trustinWallet.
@@ -107,12 +135,19 @@ avec le bloc et le rang de l'évènement ; le balayage d'adoption tourne aussit
demandé le bulletin voit ces votes comme « ton vote » — et seulement ceux de ses bulletins : une
autre session ne voit jamais ce qu'a voté la même identité ailleurs.
Le serveur relit l'indexeur pour chaque bulletin en attente toutes les 10 secondes (tâche de
fond) et quand la page demande (au plus une lecture toutes les 3 secondes par bulletin). Un
bulletin non vu en **24 heures** est lâché. Cas d'erreur, chacun testé : indexeur muet (le
bulletin reste en attente), signataire non membre ou sans identité (refusé, et dit), mauvais
montant ou mauvais destinataire (refusé), déjà confirmé (rien ne change), révision changée entre
temps (le vote porte sur la révision votée : « à reconfirmer »).
**La tâche de fond** passe toutes les 10 secondes et lit **50 bulletins en attente au plus, le
moins récemment lu d'abord** (jamais lu, puis la plus ancienne lecture) : chaque bulletin en
attente est lu au moins toutes les ⌈n / 50⌉ passes, que la page l'attende ou non (un code Ğecko,
une fenêtre fermée). Il faudrait plus de 430 000 bulletins en attente pour qu'un seul ne soit pas
lu dans ses 24 heures. La page, elle, fait lire son bulletin au plus toutes les 3 secondes.
Un bulletin non vu en **24 heures** est **lu une dernière fois à son échéance**, puis lâché ;
s'aucun indexeur ne répond, ou si son commentaire attend un bloc définitif, il reste en attente
et relu, une heure au plus. Cas d'erreur, chacun testé : indexeur muet (le bulletin reste en
attente), signataire non membre ou sans identité (refusé, et dit), mauvais montant ou mauvais
destinataire (refusé si lié, ignoré si Ğecko), copie nue ou mal payée d'un bulletin Ğecko
(ignorée), bloc pas encore définitif (en attente), déjà confirmé (rien ne change), révision
changée entre temps (le vote porte sur la révision votée : « à reconfirmer »).
### Le dernier fait foi
@@ -122,9 +157,24 @@ un bulletin plus récent sur la chaîne. « Retirer mon vote » est un bulletin
`none`. Les transactions d'un même compte gardent l'ordre de ses envois (le nonce) ; le désordre
ne vient que de l'ordre de lecture de l'indexeur, et la règle l'absorbe.
Un bulletin que le portefeuille n'a pas signé (refus, fonds insuffisants, compte absent) est lâché
aussitôt par la page (`DELETE /api/ballots/{id}` : `expired`, raison `dropped`), pour ne pas
rester « en attente » une journée. Jamais un bulletin confirmé.
### Lâcher ou garder un bulletin
**Rien n'est parti** : la page lâche le bulletin aussitôt (`DELETE /api/ballots/{id}` :
`expired`, raison `dropped`), pour ne pas le montrer « en attente » une journée. C'est le cas :
- d'un refus du portefeuille, d'un compte absent, de fonds insuffisants ;
- d'un nœud muet **avant** la signature (la page joint le nœud d'abord) ;
- d'une fenêtre fermée avant la signature ; si le portefeuille demande encore à ce moment (sa
propre fenêtre peut rester ouverte), le bulletin attend sa réponse : refusé, il est lâché ;
- d'un refus de la chaîne : rejet du nœud (transaction invalide), ou transaction incluse mais
défaite (le virement a échoué, `batchAll` défait tout : aucune remarque).
**La transaction a pu partir** — signée, puis le nœud ou le portefeuille se tait — : le bulletin
est gardé, et la tâche de fond le guette jusqu'à son échéance. Un **code Ğecko** montré est gardé
aussi, fenêtre fermée : on peut payer plus tard, depuis le téléphone. Jamais un bulletin confirmé
n'est lâché.
« Ton vote », sous l'article : un bulletin en attente demandé **avant** un vote confirmé sur la
même ligne ne s'y montre plus ; un bulletin demandé après, si.
### Le vote groupé
@@ -132,7 +182,14 @@ Un panier : article par article ; « tout pour » / « tout contre » pour une s
document — jamais pour un article dont plusieurs versions sont en concurrence : l'interface le
montre, on choisit sa version. Puis **un geste** : une écriture (groupe géré), ou **une
transaction** (chaîne) pour toutes les lignes, au même prix qu'une seule (0,1 Ğ1 par transaction,
pas par ligne ; 200 lignes au plus, 20 bulletins en attente par session).
pas par ligne ; 200 lignes au plus).
**Le plafond vaut par identité, quelle que soit la session** : 20 bulletins au plus à la fois,
en attente **ou lâchés depuis moins d'une heure** — lâcher un bulletin ne libère pas sa place
aussitôt (la relecture avait créé 100 bulletins et 3 500 lignes en une session, par `DELETE`).
La tâche de fond **purge** les bulletins lâchés, lignes comprises : un jour après qu'un
portefeuille ne les a pas signés, une semaine après qu'ils n'ont pas été vus à temps (sous
l'article, il le dit d'ici là). Un bulletin lâché n'a jamais compté : rien d'un vote ne se perd.
### Ce que voit la chaîne
@@ -140,13 +197,37 @@ Qu'un compte (et son identité) a envoyé 0,1 Ğ1 au compte des votes avec une r
tel bloc : **qui a voté et quand, jamais quoi** — ni le document, ni le nombre de lignes, ni les
choix. Le solde du compte des votes est public : il dit combien de votes ont été envoyés.
Recoupée avec les totaux affichés en direct, la chaîne peut laisser deviner un choix (un total
qui bouge juste après un vote) : une décision d'Yvv attend (secret par paliers, ou totaux en
direct). En attendant, les totaux affichés passent par une seule fonction,
`DocState::shown_tally` (et `shown_evaluation`), où une publication par paliers se branchera ;
l'adoption lit `tally`, et les textes publiés (`publish.rs`) suivraient `shown_tally`.
### Ce que sait le serveur
Les bulletins, leurs lignes, leurs sels, la session qui les a demandés et l'identité qui les a
signés : il sait qui a voté quoi, comme pour un vote direct, et ne montre que les totaux. Il ne
peut pas inventer un vote d'une identité (il faudrait sa signature sur la chaîne) ; il pourrait en
taire un. Chaque vote compté se vérifie contre la chaîne en recalculant H depuis les lignes et le
sel — ce que personne d'autre ne peut faire tant que les sels restent au serveur.
sel.
### Le reçu
La note de recherche promet que chacun peut vérifier son propre vote avec son sel. Le serveur
rend donc le bulletin **à l'identité qui l'a demandé, et à elle seule** (404 pour toute autre) :
- `GET /api/ballots/{id}/receipt` — le reçu, à télécharger (`recu-vote-<date>-<id>.json`) : le
document, le compte lié, les lignes (version, révision, choix, et pour lire : l'article et son
titre), le sel, la forme canonique, H, la remarque, son empreinte pour l'indexeur, et où en est
le bulletin (état, bloc, évènement, signataire, identité comptée) ;
- `GET /api/ballots` — son historique, le plus récent d'abord (sans les bulletins jamais signés).
L'interface les montre dans la fenêtre de vote (« Télécharger ton reçu », « Tes bulletins ») et
sur la page **« Tes bulletins »** (`/bulletins`), qui **vérifie un reçu dans le navigateur**, sans
rien demander au serveur : elle recalcule la forme canonique et H (`web/src/lib/receipt.ts`, mêmes
vecteurs que le moteur) et dit si le reçu donne bien sa remarque — à chercher dans l'historique
de son portefeuille, ou sur la chaîne au bloc indiqué. Garder son reçu pour soi : avec le sel,
n'importe qui recalculerait le choix.
### Les réglages (Q7) — `crates/ld-server/src/config.rs`
@@ -166,7 +247,9 @@ Migration `0006`, additive : `ballots` (le bulletin, son sel, H, `0x`+blake2b-25
le compte et le montant du moment, l'état `pending | confirmed | refused | expired`, la raison,
l'identité qui a voté, le signataire, le bloc, le rang, l'évènement, ce qui est entré en vigueur,
les dates) et `ballot_lines` (version, révision, choix). `votes` gagne `source` (`server` par
défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `ballot_id`.
défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `ballot_id`. Les
corrections du 2026-10-31 n'ajoutent pas de migration ; la purge efface les bulletins lâchés
(`expired`) passé leur délai, jamais un bulletin confirmé ni refusé, jamais un vote.
## Coût
@@ -178,10 +261,21 @@ défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `b
indexeurs se configurent ; lire les évènements sur un nœud serait l'étape suivante.
- Le droit de vote se vérifie **au moment de la confirmation sur la chaîne** : un membre sorti
de la toile ensuite garde ses voix.
- Par Ğecko, le bulletin n'est lié à aucun compte : un autre compte qui recopierait la remarque
et la ferait inclure avant (en payant 0,1 Ğ1) prendrait ce bulletin — ses lignes compteraient
pour lui. Par l'extension, le bulletin est lié au compte et la copie est ignorée.
- Un paiement Ğecko fait après 24 heures ne compte pas (il est perdu pour le vote).
- Par Ğecko, le bulletin n'est lié à aucun compte. **Une copie nue de la remarque** (sans
virement, gratuite) **est ignorée** : elle ne refuse plus le bulletin. Seul un commentaire payé
le tranche. **Reste possible, la variante « prise par une copie payée »** : un autre compte qui
voit la remarque (dans la file des transactions, ou sur l'écran) et fait inclure avant une
copie **payée** (0,1 Ğ1 vers le compte des votes) prend ce bulletin — ses lignes comptent pour
lui, le paiement de la personne arrive après et ne compte plus. Elle coûte 0,1 Ğ1 à qui la tente
et se voit sur la chaîne ; un compte sans identité qui paie la copie fait refuser le bulletin
(`no_identity`). Par l'extension, le bulletin est lié au compte et toute copie est ignorée.
- Par Ğecko, un virement d'un mauvais montant ou vers un autre compte laisse le bulletin en
attente (au lieu d'un refus), jusqu'à son échéance : on ne distingue pas une erreur d'une copie.
- Un bulletin est lu une dernière fois à son échéance (24 heures) : ce qui est alors sur la
chaîne, en bloc définitif, compte encore ; un paiement Ğecko plus tardif est perdu pour le vote.
- Une identité Ğ1 créée par un vote sur la chaîne, jamais connectée ici, ne retient pas son nom
face à une personne qui arrive par un lien d'équipe (comme tout membre de la toile qui n'est
jamais venu) ; une identité Ğ1 qui s'est connectée le garde.
- polkadot-api : environ 180 Ko (66 Ko compressés) chargés au moment de signer seulement ;
`node_modules` grossit d'environ 77 Mo (son outillage).
- La chaîne montre la participation de chacun (qui a voté, quand, combien de fois).
@@ -229,7 +229,9 @@ du bulletin (`ld1:` + 64 chiffres hexadécimaux). La voix est celle de **l'ident
qui signe**, membre au moment où le serveur confirme la transaction sur l'indexeur ; quelle que
soit la session (invitation ou connexion Ğ1), la même identité n'a qu'une voix par version, et
**le dernier vote fait foi**, dans l'ordre de la chaîne. La chaîne montre qui a voté et quand,
jamais quoi ; chacun ne voit que les totaux. Sans compte des votes configuré
jamais quoi ; chacun ne voit que les totaux. Chacun reçoit **le reçu de son bulletin** (ses votes
et son sel), le retrouve sur « Tes bulletins » et le vérifie dans son navigateur ; personne
d'autre ne le reçoit (ADR 0008, « Le reçu »). Sans compte des votes configuré
(`LD_VOTE_ACCOUNT`), le vote par la chaîne est fermé et l'interface le dit. Dans les groupes
gérés (essai, Axiom…), tout membre vote directement, sans portefeuille.
@@ -259,9 +261,11 @@ Les raisons longues sont dans `docs/adr/`.
de la toile, ou dont l'accès est révoqué, garde les voix déjà confirmées.
- Le serveur fait confiance à l'indexeur pour lire la transaction (remarque, virement, identité,
appartenance à la toile) ; plusieurs indexeurs se configurent (ADR 0008).
- Par Ğecko, le bulletin n'est lié à aucun compte : une copie de son sceau incluse avant lui par
un autre compte le prendrait. Par l'extension, il est lié au compte qui signe.
- Un bulletin non vu sur la chaîne en 24 heures est lâché : un paiement plus tardif ne compte pas.
- Par Ğecko, le bulletin n'est lié à aucun compte : une copie nue de son sceau est ignorée, mais
une copie **payée** (0,1 Ğ1) incluse avant lui par un autre compte le prendrait. Par
l'extension, il est lié au compte qui signe.
- Un bulletin non vu sur la chaîne en 24 heures est lu une dernière fois, puis lâché : un
paiement plus tardif ne compte pas.
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
@@ -6,7 +6,7 @@ sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22) se font dans le **groupe
d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les
votes par la chaîne Ğ1 (C11, C17, C23) ne se jouent, pour l'automate, que sur une **chaîne
votes par la chaîne Ğ1 (C11, C17, C23, C32, C33) ne se jouent, pour l'automate, que sur une **chaîne
simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ;
pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
@@ -36,7 +36,9 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction. », pas de bouton, les totaux et le seuil restent ; dans le groupe d'essai, le panier et les boutons sont là |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction. », pas de bouton, les totaux et le seuil restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là |
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
## Au-delà de la liste
@@ -66,3 +68,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts |
| 2026-10-31 | automate | local, lot B (le vote déplié, le panier, le vote par la chaîne Ğ1 simulée) | C1–C24, M1 | 25 / 25 verts |
| 2026-10-31 | automate | local, lot B2 (vote par la chaîne : corrections de la relecture de `74f96be`, reçu), `c7d2791` | C1–C24, C32, C33, M1 | 27 / 27 verts |
+142
View File
@@ -0,0 +1,142 @@
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for
// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms
// it without the page; the receipt goes to the one who asked, who checks it here.
// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go
// to open proposals no other control reads, and only « contre »: nothing enters into
// force, nothing becomes well rated, the other controls find the text as they expect.
type Entry = { id: string; status: string; comment: string };
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
/** Unfolds the vote of an open proposal, from « Propositions en cours ». */
async function unfold(page: Page, title: string) {
// By its title only: the excerpt of another proposal may name it.
await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click();
return page.getByRole('region', { name: `Le vote de « ${title} »` });
}
const REVOCATION = 'Document de révocation';
const NEVER_ALONE = 'Ne jamais certifier seul';
const answer = (page: Page, yes: boolean) =>
page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes);
test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => {
// The server reads the indexer every 10 seconds.
test.setTimeout(120_000);
await signIn(page, 'Rosa');
await wallet(page, [EVE]);
await page.goto(DOC);
const { control } = await chain(page);
const asked: string[] = [];
page.on('request', (r) => {
if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) {
asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`);
}
});
const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length;
const fold = await unfold(page, REVOCATION);
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
// The node does not answer before anything is signed: nothing left, the ballot is let go.
await control('node?down=1');
try {
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible();
await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']);
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0);
await expect.poll(() => mine(page)).toEqual([]);
} finally {
// The next controls need the node.
await control('node?down=0');
}
// The window closed while the wallet still asks: kept until it answers; refused, let go.
await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true));
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
await dialog.getByRole('button', { name: 'Fermer' }).click();
expect(deleted()).toBe(1);
await answer(page, false);
await expect.poll(deleted).toBe(2);
await expect.poll(() => mine(page)).toEqual([]);
// Signed once the window is closed: it may land, so it is kept; the server confirms it.
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
await dialog.getByRole('button', { name: 'Fermer' }).click();
await answer(page, true);
await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
expect(deleted()).toBe(2);
await page.goto(DOC);
await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
// Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server.
const ugo = await person(browser, 'Ugo');
await ugo.goto(DOC);
const theirs = await unfold(ugo, REVOCATION);
await theirs.getByRole('button', { name: 'Contre', exact: true }).click();
const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
await sheet.getByRole('button', { name: 'Fermer' }).click();
await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
const { node } = await chain(ugo);
const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' };
await ugo.request.post(node, {
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
});
await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
await ugo.goto(DOC);
await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
});
test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => {
test.setTimeout(90_000);
await signIn(page, 'Ines');
await wallet(page, [ALICE]);
await page.goto(DOC);
const fold = await unfold(page, NEVER_ALONE);
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]);
expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/);
const file = await download.path();
const receipt = JSON.parse(readFileSync(file, 'utf8'));
expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address });
expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/);
expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/);
expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]);
// The same ballot, in her ballots.
await dialog.getByRole('link', { name: 'Tes bulletins' }).click();
await expect(page).toHaveURL(/\/bulletins$/);
await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible();
const entry = page.locator('li.ballot');
await expect(entry).toHaveCount(1);
await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »");
await expect(entry).toContainText(receipt.comment);
// Checked here, in the browser, without the server.
await page.getByLabel('Fichier du reçu').setInputFiles(file);
await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible();
await expect(page.getByText(`${NEVER_ALONE} : contre`)).toBeVisible();
// One choice changed: it no longer holds.
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) };
await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
await page.getByRole('button', { name: 'Vérifier', exact: true }).click();
await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible();
await shot(page, '7-bulletins-recu');
// Nobody else gets it: neither in their ballots, nor by its address.
const other = await person(browser, 'Lou');
await other.goto('/bulletins');
await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible();
expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404);
});
+37 -8
View File
@@ -6,11 +6,15 @@
// each one is included in a new block, as the transfer and the remark of the
// real `utility.batchAll` would be;
// - `/v1/graphql`, the « indexer »: answers the server's query
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
// `squidStatus { height } txComments(condition: {hash}, filter, first, after)`
// in the shape of indexer.duniter.org: the last final block (here, every
// block is final), the comments in the order of the chain, filtered (author,
// or a transfer to an account of at least an amount) and served page by page.
//
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
// the indexer, as an indexer behind the chain), `show` (stop hiding), `release`
// (the indexer catches up), `silent` (the indexer stops answering), `node`
// (`?down=1`: the node stops answering, before anything is sent) and `state`.
//
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
@@ -27,7 +31,7 @@ const IDENTITIES = {
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
};
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 };
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
@@ -49,6 +53,20 @@ async function read(req) {
}
}
/** What the real indexer's `filter` keeps, for the two filters the server asks. */
function kept(c, filter) {
if (!filter) return true;
const author = filter.authorId?.equalTo;
if (author && c.author.id !== author) return false;
const some = filter.transfersByCommentId?.some;
if (!some) return true;
const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0');
return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min);
}
/** The size of a page: `first: N` in the query, as the server writes it. */
const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100);
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
function include(fields) {
state.block += 1;
@@ -70,7 +88,9 @@ const server = createServer(async (req, res) => {
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
if (url.pathname === '/rpc' && req.method === 'POST') {
if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true);
const body = await read(req);
if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true);
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
let fields;
try {
@@ -90,11 +110,19 @@ const server = createServer(async (req, res) => {
const hash = body.variables?.hash;
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
const nodes = state.comments
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
.sort((a, b) => a.blockNumber - b.blockNumber)
const all = state.comments
.filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter))
.sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id))
.map(({ hidden: _, ...c }) => c);
return send(res, 200, { data: { txComments: { nodes } } });
const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0);
const nodes = all.slice(start, start + pageSize(String(body.query)));
const end = start + nodes.length;
return send(res, 200, {
data: {
squidStatus: { height: state.block },
txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes },
},
});
}
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
@@ -105,6 +133,7 @@ const server = createServer(async (req, res) => {
state.hiding = false;
for (const c of state.comments) c.hidden = false;
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
else if (what === 'node') state.nodeDown = url.searchParams.get('down') === '1';
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
}
+16 -3
View File
@@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' };
export async function signIn(page: Page, name: string, admin = false) {
const r = await page.request.post('/api/auth/dev', {
@@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro
/**
* A fake wallet extension, as duniter-connect offers itself to pages
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse;
* `window.__ldHold = true` makes it wait, as a person who has not answered yet,
* until `window.__ldAnswer(true | false)` (sign, or refuse).
* It signs nothing real: the page, in test mode, sends to the simulated node.
*/
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
await page.addInitScript((list) => {
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
const w = window as unknown as Record<string, unknown> & {
__ldSigned: unknown[];
__ldRefuse?: boolean;
__ldHold?: boolean;
__ldAnswer?: (yes: boolean) => void;
};
w.__ldSigned = [];
w.injectedWeb3 = {
'duniter-connect': {
@@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri
accounts: { get: async () => list, subscribe: () => () => undefined },
signer: {
signPayload: async (payload: Record<string, unknown>) => {
if (w.__ldHold) {
const yes = await new Promise<boolean>((resolve) => (w.__ldAnswer = resolve));
if (!yes) throw new Error('Cancelled');
}
if (w.__ldRefuse) throw new Error('Cancelled');
w.__ldSigned.push(payload);
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
@@ -79,6 +91,7 @@ export async function chain(page: Page, docPath = DOC) {
return {
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
node,
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') =>
(await page.request.post(`${base}/control/${what}`)).json(),
};
}
+6
View File
@@ -200,6 +200,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
await shot(page, '6-vote-ferme');
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
await page.goto(`${DOC}?onglet=panier`);
const basket = page.locator('aside.panel');
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
// The test group still votes, directly.
await page.goto(TEST_DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
+1
View File
@@ -10,6 +10,7 @@
"devDependencies": {
"@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
"@noble/hashes": "2.4.0",
"@playwright/test": "1.63.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.70.0",
+1
View File
@@ -14,6 +14,7 @@
"devDependencies": {
"@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
"@noble/hashes": "2.4.0",
"@playwright/test": "1.63.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.70.0",
+5 -3
View File
@@ -159,9 +159,11 @@
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
</button>
<p class="faint small note-send">
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
</p>
{#if open}
<p class="faint small note-send">
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
</p>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</footer>
</section>
+62 -20
View File
@@ -3,6 +3,11 @@
// the ballot, the person's wallet signs one transaction — the amount to the
// vote account and the seal of the ballot —, the page waits for the chain to
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
//
// A ballot nothing left for is let go at once (`DELETE`): the wallet refused,
// the node did not answer before signing, the window was closed before it.
// Once signed, the transaction may land: the ballot is kept, and so is a
// Ğecko code — the server reads the indexer for them, the page open or not.
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
@@ -10,7 +15,7 @@
import type { Ballot, ChainSettings } from '$lib/types';
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
import { geckoLink, qrPath } from '$lib/wallet/gecko';
import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
import { failure, mayLetGo, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
let {
doc,
@@ -47,11 +52,19 @@
let ballot = $state<Ballot | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
let closed = false;
/** The transaction of the current ballot is signed: it may reach the chain. */
let left = false;
/** A wallet is asked to sign: its own window may stay open after this one closes. */
let asking = false;
/** The current ballot was let go: nothing left for it. */
let dropped = false;
const amount = $derived(g1Amount(chain.amount));
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
/** Ğecko is offered where nothing is being signed nor waited for. */
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
/** The receipt of a ballot that left, or may still. */
const receiptShown = $derived(['waiting', 'gecko', 'confirmed', 'refused'].includes(step.kind));
onMount(() => {
dialog?.showModal();
@@ -64,9 +77,19 @@
timer = undefined;
}
/** Nothing left for this ballot: it is let go, rather than shown as waiting for a day. */
async function letGo(b: Ballot) {
if (dropped || ballot?.id !== b.id) return;
dropped = true;
await api.del(`/ballots/${b.id}`).catch(() => undefined);
}
function close() {
closed = true;
stop();
// Closed before anything left (and no Ğecko code shown): let it go. A wallet still
// asking keeps it until it answers — signed, it may land; refused, `go` lets it go.
if (ballot?.status === 'pending' && !left && !asking && step.kind !== 'gecko') void letGo(ballot);
dialog?.close();
onclose();
}
@@ -113,6 +136,7 @@
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
return;
}
if (closed) return;
const usual = accounts.find((a) => a.address === address);
if (usual) return go(wallet, usual);
const [only] = accounts;
@@ -134,38 +158,42 @@
}
}
/** Signed: from now on the transaction may reach the chain. */
function onTheWay(b: Ballot) {
if (left) return;
left = true;
onsent();
if (!closed) wait(b);
}
async function go(wallet: Wallet, account: WalletAccount) {
remember(wallet, account);
step = { kind: 'opening' };
const b = await seal(account.address);
if (!b) return;
ballot = b;
let sent = false;
left = false;
dropped = false;
if (closed) return letGo(b);
asking = true;
try {
step = { kind: 'signing' };
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
if (s === 'signing') step = { kind: 'signing' };
else if (!sent) {
sent = true;
onsent();
wait(b);
}
if (s === 'signing') {
if (!closed) step = { kind: 'signing' };
} else onTheWay(b);
});
if (!sent) {
sent = true;
onsent();
wait(b);
}
onTheWay(b);
} catch (e) {
const f: Failure = failure(e);
// Once on its way, the chain may still take it: keep waiting.
if (sent && f === 'node') return;
if (nothingSent(f)) {
if (f === 'account') remember(null);
await api.del(`/ballots/${b.id}`).catch(() => undefined);
}
// Signed, then the node or the wallet went quiet: it may still land. Keep waiting.
if (!mayLetGo(f, left)) return;
if (f === 'account') remember(null);
await letGo(b);
stop();
step = { kind: 'failed', text: t(`sign.error.${f}`) };
if (!closed) step = { kind: 'failed', text: t(`sign.error.${f}`) };
} finally {
asking = false;
}
}
@@ -174,6 +202,9 @@
const b = await seal(null);
if (!b) return;
ballot = b;
left = false;
dropped = false;
if (closed) return letGo(b);
const link = geckoLink(b.account, b.amount, b.comment);
step = { kind: 'gecko', link, qr: await qrPath(link) };
wait(b);
@@ -271,6 +302,13 @@
{:else if step.kind === 'failed'}
<p class="warn" role="alert">{step.text}</p>
{/if}
{#if receiptShown && ballot}
<p class="receipt small">
<a href="/api/ballots/{ballot.id}/receipt" download>{t('sign.receipt')}</a>
· <a href="/bulletins">{t('sign.history')}</a>
</p>
<p class="faint small">{t('sign.receipt.note')}</p>
{/if}
</div>
<div class="actions">
@@ -393,6 +431,10 @@
.small {
font-size: 0.84rem;
}
.receipt a {
font-weight: 750;
color: var(--water);
}
.actions {
display: flex;
flex-wrap: wrap;
+1
View File
@@ -123,6 +123,7 @@
{:else if ballot?.status === 'expired'}
<p class="warn">{t('vote.mine.expired')}</p>
{/if}
{#if p.myVoteChain || ballot}<a class="faint small" href="/bulletins">{t('vote.mine.receipts')}</a>{/if}
</div>
{#if !votable}
+33 -1
View File
@@ -212,6 +212,7 @@ export const en: Messages = {
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
'vote.mine.refused': 'Your last vote was not counted: {reason}',
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
'vote.mine.receipts': 'Your ballots and receipts',
'vote.choice.for': 'for',
'vote.choice.against': 'against',
'vote.choice.none': 'withdrawing your vote',
@@ -273,6 +274,37 @@ export const en: Messages = {
'sign.close': 'Close',
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
'sign.retry': 'Try again',
'sign.error.chain': 'The Ğ1 chain refused the transaction: your vote did not leave. Try again whenever you like.',
'sign.receipt': 'Download your receipt',
'sign.history': 'Your ballots',
'sign.receipt.note': 'Your receipt keeps your ballot and its salt: with it, you check for yourself that the chain carries your vote. Keep it to yourself.',
'ballots.title': 'Your ballots',
'ballots.intro': 'Each vote on the Ğ1 chain leaves in a sealed ballot: the chain shows that you voted, never what you voted. Your receipt keeps the ballot and its salt; with it, you check your vote for yourself. Nobody else receives it.',
'ballots.mine': 'Your ballots',
'ballots.signin': 'Sign in to find your ballots.',
'ballots.none': 'You have not sent any ballot on the Ğ1 chain yet.',
'ballots.lines.one': '{n} vote',
'ballots.lines.other': '{n} votes',
'ballots.status.pending': 'Waiting for the Ğ1 chain',
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
'ballots.status.refused': 'Not counted: {reason}',
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
'ballots.block': 'block {n}',
'ballots.receipt': 'Download the receipt',
'ballots.verify.title': 'Check a receipt',
'ballots.verify.intro': 'Choose the file of a receipt, or paste its content: the page recomputes the seal of the ballot right here, without asking the server anything.',
'ballots.verify.file': 'Receipt file',
'ballots.verify.paste': 'Or paste the receipt',
'ballots.verify.go': 'Check',
'ballots.verify.ok': 'The receipt holds: its votes and its salt give this very comment.',
'ballots.verify.line': '{article} · {title}: {choice}',
'ballots.verify.where': 'Look for this comment in the history of your wallet: it is your vote, written on the Ğ1 chain.',
'ballots.verify.where.block': 'Look for this comment in the history of your wallet, or on the Ğ1 chain at block {block}: it is your vote.',
'ballots.verify.bad': 'The receipt does not hold: its votes and its salt do not give its comment.',
'ballots.verify.computed': 'Recomputed:',
'ballots.verify.expected': 'In the receipt:',
'ballots.verify.unreadable': 'This is not a readable vote receipt.',
'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
@@ -477,7 +509,7 @@ export const en: Messages = {
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
'error.server_vote': 'Here, voting is direct, without a wallet.',
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
'error.too_many_ballots': 'You already have many ballots waiting, or left on the way within the hour: wait for them to be confirmed, or try again a little later.',
'error.lines': 'This vote no longer holds: reload the page, then start again.',
'error.choice': 'This choice does not exist.',
'error.author': 'This account is not a Ğ1 address.',
+33 -1
View File
@@ -212,6 +212,7 @@ export const es: Messages = {
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
'vote.mine.receipts': 'Tus papeletas y recibos',
'vote.choice.for': 'a favor',
'vote.choice.against': 'en contra',
'vote.choice.none': 'retirar tu voto',
@@ -273,6 +274,37 @@ export const es: Messages = {
'sign.close': 'Cerrar',
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
'sign.retry': 'Reintentar',
'sign.error.chain': 'La cadena Ğ1 rechazó la transacción: tu voto no salió. Vuelve a intentarlo cuando quieras.',
'sign.receipt': 'Descargar tu recibo',
'sign.history': 'Tus papeletas',
'sign.receipt.note': 'Tu recibo guarda tu papeleta y su sal: con él, compruebas por tu cuenta que la cadena lleva tu voto. Guárdalo para ti.',
'ballots.title': 'Tus papeletas de voto',
'ballots.intro': 'Cada voto por la cadena Ğ1 sale en una papeleta sellada: la cadena muestra que votaste, nunca lo que votaste. Tu recibo guarda la papeleta y su sal; con él, compruebas tu voto por tu cuenta. Nadie más lo recibe.',
'ballots.mine': 'Tus papeletas',
'ballots.signin': 'Conéctate para encontrar tus papeletas.',
'ballots.none': 'Aún no has enviado ninguna papeleta por la cadena Ğ1.',
'ballots.lines.one': '{n} voto',
'ballots.lines.other': '{n} votos',
'ballots.status.pending': 'Esperando a la cadena Ğ1',
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
'ballots.status.refused': 'No contado: {reason}',
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
'ballots.block': 'bloque {n}',
'ballots.receipt': 'Descargar el recibo',
'ballots.verify.title': 'Comprobar un recibo',
'ballots.verify.intro': 'Elige el archivo de un recibo, o pega su contenido: la página recalcula el sello de la papeleta aquí mismo, sin pedir nada al servidor.',
'ballots.verify.file': 'Archivo del recibo',
'ballots.verify.paste': 'O pega el recibo',
'ballots.verify.go': 'Comprobar',
'ballots.verify.ok': 'El recibo es válido: sus votos y su sal dan exactamente este comentario.',
'ballots.verify.line': '{article} · {title}: {choice}',
'ballots.verify.where': 'Busca este comentario en el historial de tu monedero: es tu voto, inscrito en la cadena Ğ1.',
'ballots.verify.where.block': 'Busca este comentario en el historial de tu monedero, o en la cadena Ğ1 en el bloque {block}: es tu voto.',
'ballots.verify.bad': 'El recibo no es válido: sus votos y su sal no dan su comentario.',
'ballots.verify.computed': 'Recalculado:',
'ballots.verify.expected': 'En el recibo:',
'ballots.verify.unreadable': 'No es un recibo de voto legible.',
'inertia.title': 'Inercia',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
@@ -477,7 +509,7 @@ export const es: Messages = {
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
'error.too_many_ballots': 'Ya tienes muchas papeletas esperando, o abandonadas en la última hora: espera a que se confirmen, o vuelve a intentarlo un poco más tarde.',
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
'error.choice': 'Esta elección no existe.',
'error.author': 'Esta cuenta no es una dirección Ğ1.',
+33 -1
View File
@@ -215,6 +215,7 @@ export const fr = {
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
'vote.mine.receipts': 'Tes bulletins et reçus',
'vote.choice.for': 'pour',
'vote.choice.against': 'contre',
'vote.choice.none': 'retrait de ton vote',
@@ -276,6 +277,37 @@ export const fr = {
'sign.close': 'Fermer',
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
'sign.retry': 'Réessayer',
'sign.error.chain': "La chaîne Ğ1 a refusé la transaction : ton vote n'est pas parti. Réessaie quand tu veux.",
'sign.receipt': 'Télécharger ton reçu',
'sign.history': 'Tes bulletins',
'sign.receipt.note': 'Ton reçu garde ton bulletin et son sel : avec lui, tu vérifies toi-même que la chaîne porte ton vote. Garde-le pour toi.',
'ballots.title': 'Tes bulletins de vote',
'ballots.intro': "Chaque vote par la chaîne Ğ1 part dans un bulletin scellé : la chaîne montre que tu as voté, jamais ce que tu as voté. Ton reçu garde le bulletin et son sel ; avec lui, tu vérifies toi-même ton vote. Personne d'autre ne le reçoit.",
'ballots.mine': 'Tes bulletins',
'ballots.signin': 'Connecte-toi pour retrouver tes bulletins.',
'ballots.none': "Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.",
'ballots.lines.one': '{n} vote',
'ballots.lines.other': '{n} votes',
'ballots.status.pending': 'En attente de la chaîne Ğ1',
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
'ballots.status.refused': 'Pas compté : {reason}',
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
'ballots.block': 'bloc {n}',
'ballots.receipt': 'Télécharger le reçu',
'ballots.verify.title': 'Vérifier un reçu',
'ballots.verify.intro': "Choisis le fichier d'un reçu, ou colle son contenu : la page recalcule l'empreinte du bulletin ici même, sans rien demander au serveur.",
'ballots.verify.file': 'Fichier du reçu',
'ballots.verify.paste': 'Ou colle le reçu',
'ballots.verify.go': 'Vérifier',
'ballots.verify.ok': 'Le reçu tient : ses votes et son sel donnent bien ce commentaire.',
'ballots.verify.line': '{article} · {title} : {choice}',
'ballots.verify.where': "Cherche ce commentaire dans l'historique de ton portefeuille : c'est ton vote, inscrit sur la chaîne Ğ1.",
'ballots.verify.where.block': "Cherche ce commentaire dans l'historique de ton portefeuille, ou sur la chaîne Ğ1 au bloc {block} : c'est ton vote.",
'ballots.verify.bad': 'Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.',
'ballots.verify.computed': 'Recalculé :',
'ballots.verify.expected': 'Dans le reçu :',
'ballots.verify.unreadable': "Ce n'est pas un reçu de vote lisible.",
'inertia.title': 'Inertie',
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
@@ -480,7 +512,7 @@ export const fr = {
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
'error.too_many_ballots': 'Tu as déjà beaucoup de bulletins en attente, ou laissés en route depuis une heure : attends leur confirmation, ou réessaie un peu plus tard.',
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
'error.choice': "Ce choix n'existe pas.",
'error.author': "Ce compte n'est pas une adresse Ğ1.",
+90
View File
@@ -0,0 +1,90 @@
// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its
// lines and its salt — as the person who asked for it downloads it
// (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking
// the server anything: the canonical text, its blake2b-256 seal, and the comment
// `ld1:` + seal that the chain carries. The same rules as the engine
// (crates/ld-engine/src/ballot.rs, `canonical` and `seal`).
import { blake2b } from '@noble/hashes/blake2.js';
export type ReceiptChoice = 'for' | 'against' | 'none';
export interface ReceiptLine {
proposalId: string;
revision: number;
choice: ReceiptChoice;
/** For reading only: the article and the title of the version voted. */
article?: string;
title?: string | null;
}
export interface Sealed {
canonical: string;
digest: string;
comment: string;
/** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */
remarkHash: string;
}
export type ReceiptCheck =
| { kind: 'unreadable' }
| { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] }
| { kind: 'mismatch'; comment: string; computed: string };
const CHOICES: readonly string[] = ['for', 'against', 'none'];
/** Printable ASCII without spaces: what reads back, one field per line. */
const PLAIN = /^[\x21-\x7e]+$/;
const SALT = /^[0-9a-f]{32}$/;
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
export function blake2_256(text: string): string {
return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 }));
}
/** The ballot as it is hashed; `null` if it cannot be one. */
export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null {
if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null;
const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0));
if (!sorted.length) return null;
for (const [i, l] of sorted.entries()) {
if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null;
if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null;
}
let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`;
for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`;
return `${out}salt ${salt}\n`;
}
export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null {
const text = canonical(doc, author, lines, salt);
if (text === null) return null;
const digest = blake2_256(text);
const comment = `ld1:${digest}`;
return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` };
}
const isLine = (l: unknown): l is ReceiptLine => {
const v = l as Partial<ReceiptLine> | null;
return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string';
};
/** Reads a receipt (its text, as downloaded) and recomputes its seal. */
export function checkReceipt(text: string): ReceiptCheck {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { kind: 'unreadable' };
}
const r = raw as Record<string, unknown> | null;
if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' };
const { doc, author, lines, salt, comment, block } = r;
if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' };
if (author !== null && typeof author !== 'string') return { kind: 'unreadable' };
if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' };
const s = seal(doc, author, lines, salt);
if (!s) return { kind: 'unreadable' };
if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment };
return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines };
}
+14
View File
@@ -153,6 +153,20 @@ export interface Ballot {
expiresAt: string;
}
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
export interface BallotEntry {
id: string;
doc: { id: string; group: string; slug: string; title: LangMap | string };
comment: string;
status: Ballot['status'];
reason: Ballot['reason'];
block: number | null;
voter: string | null;
lines: number;
createdAt: string;
settledAt: string | null;
}
export interface Unit {
id: string;
code: string;
+53 -10
View File
@@ -25,9 +25,13 @@ export interface VoteTransfer {
remark: string;
}
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
export type SendStatus = 'signing' | 'sent' | 'included';
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
/**
* signing: the wallet asks the person; signed: signed and handed to the node —
* from now on the transaction may reach the chain; sent: on its way; included: in a block.
*/
export type SendStatus = 'signing' | 'signed' | 'sent' | 'included';
/** chain: included in a block, but the chain undid it (the transfer failed): no vote will come. */
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet' | 'chain';
export class WalletError extends Error {
constructor(public code: Failure) {
@@ -81,14 +85,29 @@ export function failure(e: unknown): Failure {
text = text.toLowerCase();
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
// Refused by the node's pool (stale, bad proof…): it will never be included.
if (/"type":"invalid"|invalid transaction/.test(text)) return 'chain';
return 'wallet';
}
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
export function nothingSent(f: Failure): boolean {
return f === 'cancelled' || f === 'funds' || f === 'account';
return f === 'cancelled' || f === 'funds' || f === 'account' || f === 'chain';
}
/**
* Whether the page may let a ballot go after a failure: nothing was signed yet
* (`left` false — a node down before sending, a refusal, the window closed), or
* the chain said no. Otherwise the transaction may still land: the ballot is
* kept, and the server reads the indexer for it until its end.
*/
export function mayLetGo(f: Failure, left: boolean): boolean {
return !left || nothingSent(f);
}
/** How long a node may take to answer before the wallet is asked to sign. */
const REACH_MS = 20_000;
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
@@ -106,10 +125,23 @@ async function enable(name: string): Promise<Enabled> {
}
}
/** Tests: one call to the fake node; `null` if it does not answer. */
async function rpc(node: string, method: string, params: unknown[] = []): Promise<{ error?: unknown } | null> {
const body = { jsonrpc: '2.0', id: 1, method, params };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) return null;
return (await res.json().catch(() => null)) as { error?: unknown } | null;
}
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const ext = await enable(name);
if (!ext.signer.signPayload) throw new WalletError('wallet');
// As polkadot-api does, the node is reached before the wallet is asked to sign.
const health = await rpc(node, 'system_health');
if (!health || health.error) throw new WalletError('node');
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
on('signing');
let signature: string;
@@ -118,11 +150,10 @@ async function sendTest(name: string, node: string, account: WalletAccount, tx:
} catch (e) {
throw new WalletError(failure(e));
}
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) throw new WalletError('node');
on('signed');
const submitted = await rpc(node, 'author_submitExtrinsic', [testExtrinsic({ ...fields, signature })]);
if (!submitted) throw new WalletError('node');
if (submitted.error) throw new WalletError(failure(submitted.error));
on('sent');
}
@@ -140,6 +171,16 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
try {
const signer = ext.getAccounts().find((a) => a.address === account.address);
if (!signer) throw new WalletError('account');
// A node must answer before the wallet is asked to sign: otherwise nothing leaves, and the page says so.
let wait: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
client.getFinalizedBlock(),
new Promise((_, reject) => (wait = setTimeout(() => reject(new WalletError('node')), REACH_MS))),
])
.catch(() => {
throw new WalletError('node');
})
.finally(() => clearTimeout(wait));
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
const { tx: calls } = client.getUnsafeApi();
const call = calls.Utility!.batch_all!({
@@ -153,6 +194,8 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
let timer: ReturnType<typeof setTimeout> | undefined;
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
next: (ev) => {
// Signed: from now on, the transaction may reach the chain.
if (ev.type === 'created') on('signed');
if (ev.type === 'broadcasted') {
on('sent');
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
@@ -167,7 +210,7 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
if (ev.ok) {
on('included');
resolve();
} else reject(new WalletError(failure(ev.dispatchError)));
} else reject(new WalletError(failure(ev.dispatchError) === 'funds' ? 'funds' : 'chain'));
}
},
error: (e: unknown) => {
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
// note promises everyone can check their own vote with its salt: the receipt is
// the ballot itself — its lines and its salt —, for the one who asked only, and
// it is checked here, in the browser, without asking the server anything.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
import { session } from '$lib/session.svelte';
import type { BallotEntry, LangMap } from '$lib/types';
/** A receipt weighs a few kilobytes: a larger file is not one. */
const MAX_FILE = 1_000_000;
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
onMount(() => {
if (session.me) void data.load();
});
let pasted = $state('');
let check = $state<ReceiptCheck | null>(null);
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
function standing(b: BallotEntry): string {
if (b.status === 'pending') return t('ballots.status.pending');
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
return t('ballots.status.expired');
}
async function fromFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
pasted = '';
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
}
function fromText(e: SubmitEvent) {
e.preventDefault();
check = checkReceipt(pasted);
}
</script>
<div class="page">
<h1>{t('ballots.title')}</h1>
<p class="lead">{t('ballots.intro')}</p>
<section aria-labelledby="ballots-mine">
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
{#if !session.me}
<p class="muted">
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
</p>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<ul class="list">
{#each data.value.ballots as b (b.id)}
<li class="card block ballot">
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
<p class="state {b.status}">
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
</p>
<code class="comment">{b.comment}</code>
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
</li>
{:else}
<li class="faint">{t('ballots.none')}</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="ballots-verify">
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
<div class="card block">
<p class="muted">{t('ballots.verify.intro')}</p>
<label class="field">
<span class="label">{t('ballots.verify.file')}</span>
<input type="file" accept="application/json,.json" onchange={fromFile} />
</label>
<form class="paste" onsubmit={fromText}>
<label class="field">
<span class="label">{t('ballots.verify.paste')}</span>
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
</label>
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
</form>
<div class="result" aria-live="polite">
{#if check?.kind === 'ok'}
<p class="ok">{t('ballots.verify.ok')}</p>
<code class="comment">{check.comment}</code>
<ul class="lines">
{#each check.lines as l (l.proposalId)}
<li>{t('ballots.verify.line', { article: l.article ?? '', title: l.title ?? l.proposalId, choice: t(`vote.choice.${l.choice}`) })}</li>
{/each}
</ul>
<p class="faint small">
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
</p>
{:else if check?.kind === 'mismatch'}
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
{:else if check?.kind === 'unreadable'}
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
{/if}
</div>
</div>
</section>
</div>
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
.lead {
color: var(--ink-soft);
}
section {
display: grid;
gap: 0.7rem;
}
.list,
.lines {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.block {
display: grid;
gap: 0.6rem;
padding: 1rem 1.1rem;
}
.ballot {
justify-items: start;
}
.state {
font-weight: 750;
}
.state.confirmed {
color: var(--moss);
}
.state.pending {
color: var(--water);
}
.state.refused,
.state.expired {
color: var(--ochre);
}
.comment {
font-size: 0.8rem;
word-break: break-all;
}
.field {
display: grid;
gap: 0.3rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
}
textarea {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.paste {
display: grid;
gap: 0.6rem;
justify-items: start;
}
.paste .field,
.paste textarea {
width: 100%;
}
.result {
display: grid;
gap: 0.5rem;
}
.lines {
gap: 0.25rem;
font-size: 0.9rem;
}
.ok {
font-weight: 800;
color: var(--moss);
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
// What the end-to-end tests import is declared: a package found only as the
// dependency of another (as @noble/hashes under polkadot-api) may vanish with it.
import { readdirSync, readFileSync } from 'node:fs';
import { builtinModules } from 'node:module';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = join(__dirname, '../..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
/** `@scope/name/sub.js` → `@scope/name`; `name/sub` → `name`. */
const packageOf = (spec: string) => spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/');
describe('the end-to-end tests', () => {
it('import only declared packages', () => {
const dir = join(root, 'e2e');
for (const file of readdirSync(dir).filter((f) => /\.(mjs|ts)$/.test(f))) {
const text = readFileSync(join(dir, file), 'utf8');
for (const [, spec] of text.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)) {
if (!spec || spec.startsWith('.') || spec.startsWith('node:') || builtinModules.includes(spec)) continue;
expect(declared.has(packageOf(spec)), `${file}: ${spec}`).toBe(true);
}
}
});
});
+73
View File
@@ -0,0 +1,73 @@
// The receipt of a vote on the Ğ1 chain, checked in the browser: the same
// canonical text and the same seal as the engine (crates/ld-engine/src/ballot.rs,
// whose vectors were computed apart with Python's hashlib.blake2b).
import { describe, expect, it } from 'vitest';
import { canonical, checkReceipt, seal } from '../../src/lib/receipt';
const SALT = '00112233445566778899aabbccddeeff';
const LINES = [
{ proposalId: 'p2', revision: 1, choice: 'against' as const },
{ proposalId: 'p1', revision: 3, choice: 'for' as const },
];
describe('the sealed ballot', () => {
it('reads as the engine writes it, the lines in the order of their versions', () => {
expect(canonical('d0c', null, LINES, SALT)).toBe(
'ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n',
);
});
it('seals to the remark of the engine', () => {
const s = seal('d0c', null, LINES, SALT);
expect(s?.digest).toBe('20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
expect(s?.comment).toBe('ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
expect(s?.remarkHash).toBe('0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3');
const bound = seal('d0c', 'g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv', [{ proposalId: 'p1', revision: 3, choice: 'none' }], 'ffeeddccbbaa99887766554433221100');
expect(bound?.digest).toBe('6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e');
expect(bound?.remarkHash).toBe('0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4');
});
it('what cannot be sealed', () => {
expect(seal('d0c', null, [], SALT)).toBeNull();
expect(seal('d0c', null, [LINES[0]!, { ...LINES[0]!, revision: 2 }], SALT)).toBeNull();
expect(seal('d 0c', null, LINES, SALT)).toBeNull();
expect(seal('d0c', null, LINES, SALT.toUpperCase())).toBeNull();
expect(seal('d0c', null, LINES, '0011')).toBeNull();
expect(seal('d0c', null, [{ proposalId: 'p1', revision: 1.5, choice: 'for' }], SALT)).toBeNull();
});
});
describe('a receipt', () => {
const receipt = {
format: 'ld1',
doc: 'd0c',
author: null,
lines: LINES.map((l) => ({ ...l, article: 'E1', title: 'Unicité du compte' })),
salt: SALT,
comment: 'ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340',
block: 3_400_000,
};
it('holds when its lines and its salt give its comment', () => {
expect(checkReceipt(JSON.stringify(receipt))).toEqual({
kind: 'ok',
comment: receipt.comment,
remarkHash: '0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3',
block: 3_400_000,
lines: receipt.lines,
});
});
it('does not hold once a choice is changed', () => {
const changed = { ...receipt, lines: [{ ...receipt.lines[0]!, choice: 'for' }, receipt.lines[1]!] };
const check = checkReceipt(JSON.stringify(changed));
expect(check.kind).toBe('mismatch');
if (check.kind === 'mismatch') expect(check.computed).not.toBe(receipt.comment);
});
it('what is not a receipt', () => {
for (const text of ['', 'nope', '[]', '{}', JSON.stringify({ ...receipt, format: 'ld2' }), JSON.stringify({ ...receipt, lines: 'x' })]) {
expect(checkReceipt(text).kind, text).toBe('unreadable');
}
});
});
+12 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected';
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
const COMMENT = `ld1:${'ab'.repeat(32)}`;
@@ -30,6 +30,17 @@ describe('wallets of the page', () => {
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
expect(failure(new Error('socket hang up'))).toBe('wallet');
// Refused by the pool of the node: it will never be included.
expect(failure({ type: 'Invalid', value: { type: 'Stale' } })).toBe('chain');
expect(failure({ code: 1010, message: 'Invalid Transaction' })).toBe('chain');
});
it('a ballot is let go when nothing left the page, or when the chain said no — kept when it may still land', () => {
// Before the signature (the node down before sending, a refusal, the window closed): nothing left.
for (const f of ['node', 'wallet', 'cancelled', 'funds', 'account', 'chain'] as const) expect(mayLetGo(f, false), f).toBe(true);
// Signed and handed to the node: it may reach the chain, unless the chain refused it.
for (const f of ['funds', 'account', 'chain'] as const) expect(mayLetGo(f, true), f).toBe(true);
for (const f of ['node', 'wallet'] as const) expect(mayLetGo(f, true), f).toBe(false);
});
it('in tests, the fake node receives what was signed, readable', () => {