v3 : vote par la chaîne, corrections de la relecture de 74f96be (interface)

- rien n'est parti (nœud muet avant la signature, refus, fenêtre fermée avant) :
  la page lâche le bulletin ; signé, ou montré en code Ğecko, il est gardé et
  le serveur le confirme sans la page ; le refus de la chaîne se dit (« chain ») ;
- le reçu : téléchargeable depuis la fenêtre de vote, et la page « Tes
  bulletins » (/bulletins) qui garde l'historique et vérifie un reçu dans le
  navigateur, sans le serveur (lib/receipt.ts, mêmes vecteurs que le moteur) ;
- le panier ne dit plus « Tes votes comptent dès l'envoi » quand le vote est fermé ;
- @noble/hashes déclaré (utilisé par e2e/chain-mock.mjs et le reçu) ;
- chaîne simulée : finalité, pages, filtres, nœud qui tombe ; contrôles C32, C33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 03:11:54 +01:00
co-authored by Claude Opus 5.5
parent e63e2ec283
commit 51c9a4771d
19 changed files with 846 additions and 48 deletions
+53 -10
View File
@@ -25,9 +25,13 @@ export interface VoteTransfer {
remark: string;
}
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
export type SendStatus = 'signing' | 'sent' | 'included';
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
/**
* signing: the wallet asks the person; signed: signed and handed to the node —
* from now on the transaction may reach the chain; sent: on its way; included: in a block.
*/
export type SendStatus = 'signing' | 'signed' | 'sent' | 'included';
/** chain: included in a block, but the chain undid it (the transfer failed): no vote will come. */
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet' | 'chain';
export class WalletError extends Error {
constructor(public code: Failure) {
@@ -81,14 +85,29 @@ export function failure(e: unknown): Failure {
text = text.toLowerCase();
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';
return 'wallet';
}
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
export function nothingSent(f: Failure): boolean {
return f === 'cancelled' || f === 'funds' || f === 'account';
return f === 'cancelled' || f === 'funds' || f === 'account' || f === 'chain';
}
/**
* Whether the page may let a ballot go after a failure: nothing was signed yet
* (`left` false — a node down before sending, a refusal, the window closed), or
* the chain said no. Otherwise the transaction may still land: the ballot is
* kept, and the server reads the indexer for it until its end.
*/
export function mayLetGo(f: Failure, left: boolean): boolean {
return !left || nothingSent(f);
}
/** How long a node may take to answer before the wallet is asked to sign. */
const REACH_MS = 20_000;
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
@@ -106,10 +125,23 @@ async function enable(name: string): Promise<Enabled> {
}
}
/** Tests: one call to the fake node; `null` if it does not answer. */
async function rpc(node: string, method: string, params: unknown[] = []): Promise<{ error?: unknown } | null> {
const body = { jsonrpc: '2.0', id: 1, method, params };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) return null;
return (await res.json().catch(() => null)) as { error?: unknown } | null;
}
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const ext = await enable(name);
if (!ext.signer.signPayload) throw new WalletError('wallet');
// As polkadot-api does, the node is reached before the wallet is asked to sign.
const health = await rpc(node, 'system_health');
if (!health || health.error) throw new WalletError('node');
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
on('signing');
let signature: string;
@@ -118,11 +150,10 @@ async function sendTest(name: string, node: string, account: WalletAccount, tx:
} catch (e) {
throw new WalletError(failure(e));
}
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) throw new WalletError('node');
on('signed');
const submitted = await rpc(node, 'author_submitExtrinsic', [testExtrinsic({ ...fields, signature })]);
if (!submitted) throw new WalletError('node');
if (submitted.error) throw new WalletError(failure(submitted.error));
on('sent');
}
@@ -140,6 +171,16 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
try {
const signer = ext.getAccounts().find((a) => a.address === account.address);
if (!signer) throw new WalletError('account');
// A node must answer before the wallet is asked to sign: otherwise nothing leaves, and the page says so.
let wait: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
client.getFinalizedBlock(),
new Promise((_, reject) => (wait = setTimeout(() => reject(new WalletError('node')), REACH_MS))),
])
.catch(() => {
throw new WalletError('node');
})
.finally(() => clearTimeout(wait));
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
const { tx: calls } = client.getUnsafeApi();
const call = calls.Utility!.batch_all!({
@@ -153,6 +194,8 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
let timer: ReturnType<typeof setTimeout> | undefined;
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
next: (ev) => {
// Signed: from now on, the transaction may reach the chain.
if (ev.type === 'created') on('signed');
if (ev.type === 'broadcasted') {
on('sent');
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
@@ -167,7 +210,7 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
if (ev.ok) {
on('included');
resolve();
} else reject(new WalletError(failure(ev.dispatchError)));
} else reject(new WalletError(failure(ev.dispatchError) === 'funds' ? 'funds' : 'chain'));
}
},
error: (e: unknown) => {