forked from yvv/decision
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:
@@ -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];
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" } } } })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 > ?",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user