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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user