forked from yvv/decision
v3 : vote par la chaîne, corrections de la relecture de 74f96be (serveur)
- adresse : refusée au-delà de 64 caractères avant tout décodage (le base58
coûte le carré de la longueur : 80 000 caractères prenaient 38 s) ;
- tâche de fond : 50 bulletins par passe, le moins récemment lu d'abord ;
lu une dernière fois à son échéance, une heure de grâce si l'indexeur se tait ;
- plafond de 20 par identité : en attente, plus lâchés depuis une heure ;
purge des lâchés après un jour, des expirés après une semaine ;
- bulletin Ğecko : seul un commentaire payé le tranche, une copie nue s'ignore ;
- finalité : seul un bloc ≤ squidStatus.height compte ; recherche filtrée
(auteur lié, ou virement vers le compte des votes) et paginée par 50 ;
- « ton vote » : un bulletin en attente plus ancien qu'un vote confirmé se tait ;
- reçu (GET /api/ballots/{id}/receipt) et historique (GET /api/ballots),
pour qui l'a demandé seulement ;
- un nom vu seulement par un vote sur la chaîne reste libre pour un lien d'équipe ;
- totaux affichés isolés (shown_tally) pour une publication par paliers.
Les sondes de la relecture deviennent des tests, vus rouges avant le correctif.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
/// The SS58 prefix of the Ğ1 chain.
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user