forked from yvv/decision
polkadot-api lève InvalidTxError (JSON indenté) quand le nœud refuse la transaction : elle est désormais lue comme « la chaîne a dit non » (rien n'est parti, le bulletin est lâché), au lieu de rester en attente 24 h. La page /bulletins ne présente comme vérifiés que la version, la révision et le choix ; l'article, le titre et le bloc du reçu sont dits hors du sceau. ADR 0008 : trois affirmations corrigées (indexeur sans filtre, débit de la tâche de fond, lectures qui n'utilisent pas encore shown_tally). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
18 KiB
Markdown
287 lines
18 KiB
Markdown
# 0008 — Le vote par transaction sur la chaîne Ğ1
|
|
|
|
> 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. 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
|
|
(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`) :
|
|
|
|
```graphql
|
|
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).
|