v3 : relecture de 7ffa4fd — un refus du nœud lâche le bulletin, le reçu ne dit vérifié que ce que scelle l'empreinte

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>
This commit is contained in:
Yvv
2026-10-31 05:46:28 +01:00
co-authored by Claude Opus 5.5
parent 8ff5a2b573
commit 9c8f4cc568
8 changed files with 49 additions and 14 deletions
+10 -5
View File
@@ -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 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. 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 Le moteur (`ld_engine::ballot::judge`) juge à nouveau ce qui revient. Un indexeur qui ignorerait
filtre ne change que le nombre de pages. Il garde le premier commentaire, dans l'ordre de la 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 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 copie ; pour un bulletin Ğecko, **un commentaire sans le virement est une copie** (une remarque
seule ne coûte rien à envoyer). Puis il exige : 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 **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 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, 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 une fenêtre fermée). Une lecture de l'indexeur prend 0,5 à 2,1 s (mesuré le 31/10), donc une passe
lu dans ses 24 heures. La page, elle, fait lire son bulletin au plus toutes les 3 secondes. 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é ; 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 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 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, 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 ; `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 ### Ce que sait le serveur
+4 -1
View File
@@ -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. // Checked here, in the browser, without the server.
await page.getByLabel('Fichier du reçu').setInputFiles(file); 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('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. // One choice changed: it no longer holds.
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) }; 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.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
+3 -2
View File
@@ -298,9 +298,10 @@ export const en: Messages = {
'ballots.verify.paste': 'Or paste the receipt', 'ballots.verify.paste': 'Or paste the receipt',
'ballots.verify.go': 'Check', 'ballots.verify.go': 'Check',
'ballots.verify.ok': 'The receipt holds: its votes and its salt give this very comment.', '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': '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.bad': 'The receipt does not hold: its votes and its salt do not give its comment.',
'ballots.verify.computed': 'Recomputed:', 'ballots.verify.computed': 'Recomputed:',
'ballots.verify.expected': 'In the receipt:', 'ballots.verify.expected': 'In the receipt:',
+3 -2
View File
@@ -298,9 +298,10 @@ export const es: Messages = {
'ballots.verify.paste': 'O pega el recibo', 'ballots.verify.paste': 'O pega el recibo',
'ballots.verify.go': 'Comprobar', 'ballots.verify.go': 'Comprobar',
'ballots.verify.ok': 'El recibo es válido: sus votos y su sal dan exactamente este comentario.', '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': '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.bad': 'El recibo no es válido: sus votos y su sal no dan su comentario.',
'ballots.verify.computed': 'Recalculado:', 'ballots.verify.computed': 'Recalculado:',
'ballots.verify.expected': 'En el recibo:', 'ballots.verify.expected': 'En el recibo:',
+3 -2
View File
@@ -301,9 +301,10 @@ export const fr = {
'ballots.verify.paste': 'Ou colle le reçu', 'ballots.verify.paste': 'Ou colle le reçu',
'ballots.verify.go': 'Vérifier', 'ballots.verify.go': 'Vérifier',
'ballots.verify.ok': 'Le reçu tient : ses votes et son sel donnent bien ce commentaire.', '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': "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.bad': 'Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.',
'ballots.verify.computed': 'Recalculé :', 'ballots.verify.computed': 'Recalculé :',
'ballots.verify.expected': 'Dans le reçu :', 'ballots.verify.expected': 'Dans le reçu :',
+7 -1
View File
@@ -73,6 +73,12 @@ export function injectedNames(page: Page): string[] {
/** What went wrong, in one of a few words the interface translates. */ /** What went wrong, in one of a few words the interface translates. */
export function failure(e: unknown): Failure { export function failure(e: unknown): Failure {
if (e instanceof WalletError) return e.code; 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; let text: string;
if (e instanceof Error) text = e.message; if (e instanceof Error) text = e.message;
else { else {
@@ -86,7 +92,7 @@ export function failure(e: unknown): Failure {
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled'; if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds'; 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. // 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'; return 'wallet';
} }
+7 -1
View File
@@ -96,7 +96,10 @@
<code class="comment">{check.comment}</code> <code class="comment">{check.comment}</code>
<ul class="lines"> <ul class="lines">
{#each check.lines as l (l.proposalId)} {#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> <li>
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
</li>
{/each} {/each}
</ul> </ul>
<p class="faint small"> <p class="faint small">
@@ -196,6 +199,9 @@
gap: 0.25rem; gap: 0.25rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.unsealed {
display: block;
}
.ok { .ok {
font-weight: 800; font-weight: 800;
color: var(--moss); color: var(--moss);
+12
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { InvalidTxError } from 'polkadot-api';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko'; import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected'; 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'); 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', () => { 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. // 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); for (const f of ['node', 'wallet', 'cancelled', 'funds', 'account', 'chain'] as const) expect(mayLetGo(f, false), f).toBe(true);