From 9c8f4cc568691db17a702c930482fdfaef88c18a Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 05:46:28 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20relecture=20de=207ffa4fd=20=E2=80=94?= =?UTF-8?q?=20un=20refus=20du=20n=C5=93ud=20l=C3=A2che=20le=20bulletin,=20?= =?UTF-8?q?le=20re=C3=A7u=20ne=20dit=20v=C3=A9rifi=C3=A9=20que=20ce=20que?= =?UTF-8?q?=20scelle=20l'empreinte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0008-vote-par-transaction.md | 15 ++++++++++----- web/e2e/bulletins.spec.ts | 5 ++++- web/src/lib/i18n/en.ts | 5 +++-- web/src/lib/i18n/es.ts | 5 +++-- web/src/lib/i18n/fr.ts | 5 +++-- web/src/lib/wallet/injected.ts | 8 +++++++- web/src/routes/bulletins/+page.svelte | 8 +++++++- web/tests/unit/wallet.test.ts | 12 ++++++++++++ 8 files changed, 49 insertions(+), 14 deletions(-) diff --git a/docs/adr/0008-vote-par-transaction.md b/docs/adr/0008-vote-par-transaction.md index 2642f45..e21b1bd 100644 --- a/docs/adr/0008-vote-par-transaction.md +++ b/docs/adr/0008-vote-par-transaction.md @@ -117,8 +117,10 @@ le dernier bloc définitif traité. Relevé le 2026-10-31 sur indexer.duniter.or 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 +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 : @@ -138,8 +140,9 @@ 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). 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. +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 @@ -201,7 +204,9 @@ Recoupée avec les totaux affichés en direct, la chaîne peut laisser deviner u 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`. +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 diff --git a/web/e2e/bulletins.spec.ts b/web/e2e/bulletins.spec.ts index aef3235..8067954 100644 --- a/web/e2e/bulletins.spec.ts +++ b/web/e2e/bulletins.spec.ts @@ -126,7 +126,10 @@ test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins // 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(); + // Only what the seal covers is said verified: the version, its revision and the choice. + await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`); + // The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd). + await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).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)); diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 2a591ea..fb6fa40 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -298,9 +298,10 @@ export const en: Messages = { '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.line': 'Version {version}, revision {revision}: {choice}', + 'ballots.verify.line.label': '{label} — written in the receipt, outside the seal', '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.where.block': 'Look for this comment in the history of your wallet, or on the Ğ1 chain (the receipt says block {block}, outside the seal): if it is there, signed by your account, 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:', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 97835c6..50159a8 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -298,9 +298,10 @@ export const es: Messages = { '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.line': 'Versión {version}, revisión {revision}: {choice}', + 'ballots.verify.line.label': '{label} — anotado en el recibo, fuera del sello', '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.where.block': 'Busca este comentario en el historial de tu monedero, o en la cadena Ğ1 (el recibo indica el bloque {block}, fuera del sello): si figura allí, firmado por tu cuenta, 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:', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 507a81b..8dc8803 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -301,9 +301,10 @@ export const fr = { '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.line': 'Version {version}, révision {revision} : {choice}', + 'ballots.verify.line.label': '{label} — noté dans le reçu, hors du sceau', '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.where.block': "Cherche ce commentaire dans l'historique de ton portefeuille, ou sur la chaîne Ğ1 (le reçu indique le bloc {block}, hors du sceau) : s'il y figure, signé de ton compte, 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 :', diff --git a/web/src/lib/wallet/injected.ts b/web/src/lib/wallet/injected.ts index a694de9..9c7f01c 100644 --- a/web/src/lib/wallet/injected.ts +++ b/web/src/lib/wallet/injected.ts @@ -73,6 +73,12 @@ export function injectedNames(page: Page): string[] { /** What went wrong, in one of a few words the interface translates. */ export function failure(e: unknown): Failure { if (e instanceof WalletError) return e.code; + // polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent. + // Its message is indented JSON, so the reason is read from the error itself. + if (e instanceof Error && e.name === 'InvalidTxError') { + const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase(); + return /payment|funds|balance|existential/.test(reason) ? 'funds' : 'chain'; + } let text: string; if (e instanceof Error) text = e.message; else { @@ -86,7 +92,7 @@ export function failure(e: unknown): Failure { 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'; + if (/"type":\s*"invalid"|invalid transaction/.test(text)) return 'chain'; return 'wallet'; } diff --git a/web/src/routes/bulletins/+page.svelte b/web/src/routes/bulletins/+page.svelte index 3862b2a..5c8220d 100644 --- a/web/src/routes/bulletins/+page.svelte +++ b/web/src/routes/bulletins/+page.svelte @@ -96,7 +96,10 @@ {check.comment}

@@ -196,6 +199,9 @@ gap: 0.25rem; font-size: 0.9rem; } + .unsealed { + display: block; + } .ok { font-weight: 800; color: var(--moss); diff --git a/web/tests/unit/wallet.test.ts b/web/tests/unit/wallet.test.ts index 41e92f3..559b7fc 100644 --- a/web/tests/unit/wallet.test.ts +++ b/web/tests/unit/wallet.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { InvalidTxError } from 'polkadot-api'; import { amountText, geckoLink } from '../../src/lib/wallet/gecko'; import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected'; @@ -35,6 +36,17 @@ describe('wallets of the page', () => { expect(failure({ code: 1010, message: 'Invalid Transaction' })).toBe('chain'); }); + it('a refusal of the node, as polkadot-api raises it, is the chain saying no (relecture of 7ffa4fd)', () => { + // The real class: its message is indented JSON (`"type": "Invalid"`, with a space). + for (const reason of ['Stale', 'AncientBirthBlock', 'BadProof', 'ExhaustsResources', 'Future']) { + const e = new InvalidTxError({ type: 'Invalid', value: { type: reason } }); + expect(failure(e), reason).toBe('chain'); + expect(mayLetGo(failure(e), true), reason).toBe(true); + } + expect(failure(new InvalidTxError({ type: 'Invalid', value: { type: 'Payment' } }))).toBe('funds'); + expect(failure(new InvalidTxError({ type: 'Unknown', value: { type: 'CannotLookup' } }))).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);