Files
decision/docs/adr/0008-vote-par-transaction.md
T
YvvandClaude Opus 5.5 e7dda2ff47 ADR 0012 : le tampon des votes — l'intention tout de suite, la preuve de la chaîne, la réconciliation ; la simulation tant que la chaîne est fermée (lot V)
Demande d'Yvv du 02/11 : « je vois ce que j'ai fait sans attendre », contrôle a
posteriori (bien unique, bien le dernier qui vaut), et le vote simulé partout
dans Monnaie libre tant que LD_VOTE_ACCOUNT n'est pas posé. Révise une phrase de
0010 : un « pour » prouvé pendant une objection est écarté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:31:46 +01:00

18 KiB

0008 — Le vote par transaction sur la chaîne Ğ1

Complète 0002 (vote permanent) et 0006 (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. 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. Complétée par 0012 (2026-11-02, lot V) : le bulletin en attente est une intention, visible aussitôt pour la personne et dans les totaux montrés (jamais dans l'adoption) ; la réconciliation remplace « le dernier fait foi » ligne à ligne ; sans LD_VOTE_ACCOUNT, le vote est simulé au lieu d'être fermé.

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 (connect.monnaie-libre.fr) n'est pas active et toutes les identités viennent d'invitations : en pratique, personne ne pouvait voter. Yvv, 2026-10-30 : « une personne une voix, mais la voix peut s'exprimer plusieurs fois […] c'est le dernier qui fait foi. Pour garantir l'intégrité d'un vote, je crois qu'il n'y a que la blockchain, donc une transaction par vote » ; « 0,1 Ğ1 » ; « qu'un geste dans le vote » ; « Ğ1Companion ou Ğecko, à terme trustinWallet » ; « Je peux voter tout d'un coup, ou une section entière, ou un article avec son titre, ou séparément. »

Décision

Qui vote où

  • Où l'électorat est la toile (politique g1-members : Monnaie libre), chaque vote est une transaction de la chaîne Ğ1, signée par le portefeuille de la personne. Le serveur n'y écrit plus de vote direct (409 chain_only). La voix est celle de l'identité Ğ1 du compte qui signe, quelle que soit la session qui a préparé le vote (invitation, plus tard connexion Ğ1).
  • Dans un groupe géré (essai, Axiom…), le vote reste écrit par le serveur, sans chaîne ni portefeuille, comme avant (409 server_vote si on y demande un bulletin). Le geste groupé y marche aussi : une seule écriture.
  • La règle est dans trustinWallet : trustin_access::vote_way (chain / server), votes_in, asks_ballot, chain_vote_counts.

Le bulletin scellé (Q5) — crates/ld-engine/src/ballot.rs

Le serveur scelle un bulletin en attente : le document, le compte qui signera (quand la page le connaît), les lignes (version, révision votée, choix), un sel de 128 bits tiré au hasard. Sa forme canonique, en ASCII, les lignes triées par version :

ld1
doc <id du document>
author <adresse Ğ1 du compte qui signe, ou ->
line <id de la version> <révision> <for|against|none>
…
salt <32 chiffres hexadécimaux>

H = blake2b-256(forme canonique) ; la remarque de la transaction est ld1: suivi des 64 chiffres hexadécimaux de H, 68 octets ASCII. Exemple (vecteur des tests, calculé à part) :

ld1
doc d0c
author -
line p1 3 for
line p2 1 against
salt 00112233445566778899aabbccddeeff

→ remarque ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340, que l'indexeur retrouve par blake2b-256(remarque) = 0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3.

Le sel empêche de deviner le choix (trois choix par ligne se devineraient tous). L'adresse liée empêche qu'un autre compte, qui recopierait la remarque, prenne le vote (voir les limites).

La transaction (Q6) — web/src/lib/wallet/

utility.batchAll([
  balances.transferKeepAlive(LD_VOTE_ACCOUNT, LD_VOTE_AMOUNT),
  system.remarkWithEvent("ld1:" + H),
])

Un seul geste, atomique : le virement et la remarque passent ensemble ou pas du tout. La page la fait signer par l'extension de portefeuille du navigateur (interface standard injectedWeb3 / signPayload : duniter-connect aujourd'hui, Ğ1Companion ou trustinWallet le jour où elles 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 : 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, PostGraphile, LD_INDEXERS) :

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. 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 ignorerait le filtre ne change d'abord que le nombre de pages, mais au-delà de 1 000 copies nues placées avant le vrai commentaire (20 pages de 50), celui-ci n'est plus lu et le bulletin reste en attente (relecture de 7ffa4fd) : cas théorique, un PostGraphile sans ce filtre répond par une erreur. 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 (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.

Le vote compte alors pour idty:<index> (créée si besoin : sorte g1, membre), ligne par ligne, avec le bloc et le rang de l'évènement ; le balayage d'adoption tourne aussitôt. La session qui a 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.

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). Une lecture de l'indexeur prend 0,5 à 2,1 s (mesuré le 31/10), donc une passe de 50 bulletins dure 25 à 100 s : il faudrait de l'ordre de 40 000 à 170 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

Par version et par identité, dans l'ordre de la chaîne : le bloc, puis le rang de l'évènement (lu dans l'identifiant de l'évènement). Un bulletin confirmé tard ne remplace jamais un bulletin plus récent sur la chaîne. « Retirer mon vote » est un bulletin dont la ligne vaut 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.

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é

Un panier : article par article ; « tout pour » / « tout contre » pour une section ou tout le 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).

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

Qu'un compte (et son identité) a envoyé 0,1 Ğ1 au compte des votes avec une remarque ld1:…, à 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. Deux lectures n'y passent pas encore et lisent les totaux en direct : l'aperçu des propositions bien cotées (docs.rs, unit_states et proposal_states) et les textes publiés (publish.rs). Elles s'y brancheront avec la décision d'Yvv.

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.

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

  • LD_VOTE_ACCOUNT : le compte des votes, une adresse Ğ1 (toute forme SS58, ramenée au préfixe g1). Sans lui, le vote par la chaîne est fermé et l'interface le dit avec des mots simples ; les groupes gérés votent comme avant. Une adresse illisible est journalisée et laisse le vote fermé. Aucune adresse n'est écrite en dur.
  • LD_VOTE_AMOUNT : en centimes de Ğ1, 10 par défaut (0,1 Ğ1), au moins 1.
  • LD_VOTE_NODES : les nœuds où la page envoie la transaction (par défaut wss://rpc.duniter.org, wss://g1.axiom-team.fr/ws).
  • LD_VOTE_TEST_NODE : tests seulement, et seulement avec la connexion de développement : la page envoie ce que le faux portefeuille a signé à un faux nœud, sans polkadot-api.

Les données

Migration 0006, additive : ballots (le bulletin, son sel, H, 0x+blake2b-256 de la remarque, 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. 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

  • Voter coûte 0,1 Ğ1 par geste, plus les frais de la chaîne (non mesurés ici) ; le portefeuille doit garder au moins 1 Ğ1 (dépôt existentiel, transferKeepAlive). Le compte des votes accumule ces Ğ1 : à qui il appartient, et ce qu'on en fait, reste à décider par Yvv.
  • Le serveur fait confiance à l'indexeur pour la remarque, le virement, l'identité et l'appartenance à la toile : un indexeur menteur ferait compter ou refuser un vote. Plusieurs 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. 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).