diff --git a/web/e2e/bulletins.spec.ts b/web/e2e/bulletins.spec.ts new file mode 100644 index 0000000..89e7574 --- /dev/null +++ b/web/e2e/bulletins.spec.ts @@ -0,0 +1,137 @@ +import { readFileSync } from 'node:fs'; +import { expect, test, type Page } from '@playwright/test'; +import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers'; + +// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for +// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms +// it without the page; the receipt goes to the one who asked, who checks it here. +// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. + +type Entry = { id: string; status: string; comment: string }; +const mine = async (page: Page): Promise => (await (await page.request.get('/api/ballots')).json()).ballots; + +async function unfold(page: Page, title: string) { + const unit = page.locator('section.text article.unit', { hasText: title }); + await unit.hover(); + await unit.getByRole('button', { name: 'Le vote' }).click(); + return page.getByRole('region', { name: `Le vote de « ${title} »` }); +} + +const answer = (page: Page, yes: boolean) => + page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes); + +test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => { + // The server reads the indexer every 10 seconds. + test.setTimeout(120_000); + await signIn(page, 'Rosa'); + await wallet(page, [EVE]); + await page.goto(DOC); + const { control } = await chain(page); + const asked: string[] = []; + page.on('request', (r) => { + if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) { + asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`); + } + }); + const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length; + const fold = await unfold(page, 'Réciprocité'); + const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); + + // The node does not answer before anything is signed: nothing left, the ballot is let go. + await control('node?down=1'); + try { + await fold.getByRole('button', { name: 'Contre', exact: true }).click(); + await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible(); + await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']); + await dialog.getByRole('button', { name: 'Fermer' }).click(); + await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0); + await expect.poll(() => mine(page)).toEqual([]); + } finally { + // The next controls need the node. + await control('node?down=0'); + } + + // The window closed while the wallet still asks: kept until it answers; refused, let go. + await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true)); + await fold.getByRole('button', { name: 'Contre', exact: true }).click(); + await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible(); + await dialog.getByRole('button', { name: 'Fermer' }).click(); + expect(deleted()).toBe(1); + await answer(page, false); + await expect.poll(deleted).toBe(2); + await expect.poll(() => mine(page)).toEqual([]); + + // Signed once the window is closed: it may land, so it is kept; the server confirms it. + await fold.getByRole('button', { name: 'Contre', exact: true }).click(); + await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible(); + await dialog.getByRole('button', { name: 'Fermer' }).click(); + await answer(page, true); + await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']); + expect(deleted()).toBe(2); + await page.goto(DOC); + await expect((await unfold(page, 'Réciprocité')).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1'); + + // Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server. + const ugo = await person(browser, 'Ugo'); + await ugo.goto(DOC); + const theirs = await unfold(ugo, 'Réciprocité'); + await theirs.getByRole('button', { name: 'Pour', exact: true }).click(); + const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); + await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click(); + const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? ''; + await sheet.getByRole('button', { name: 'Fermer' }).click(); + await expect(theirs.getByText('En attente de la chaîne Ğ1 : pour.')).toBeVisible(); + const { node } = await chain(ugo); + const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' }; + await ugo.request.post(node, { + data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] }, + }); + await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']); + await ugo.goto(DOC); + await expect((await unfold(ugo, 'Réciprocité')).locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1'); +}); + +test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => { + test.setTimeout(90_000); + await signIn(page, 'Ines'); + await wallet(page, [ALICE]); + await page.goto(DOC); + const fold = await unfold(page, 'Unicité du compte'); + await fold.getByRole('button', { name: 'Pour', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); + await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 }); + const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]); + expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/); + const file = await download.path(); + const receipt = JSON.parse(readFileSync(file, 'utf8')); + expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address }); + expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/); + expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/); + expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'for', title: 'Unicité du compte' })]); + + // The same ballot, in her ballots. + await dialog.getByRole('link', { name: 'Tes bulletins' }).click(); + await expect(page).toHaveURL(/\/bulletins$/); + await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible(); + const entry = page.locator('li.ballot'); + await expect(entry).toHaveCount(1); + await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »"); + await expect(entry).toContainText(receipt.comment); + + // 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('Unicité du compte : pour')).toBeVisible(); + // One choice changed: it no longer holds. + const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'against' })) }; + await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed)); + await page.getByRole('button', { name: 'Vérifier', exact: true }).click(); + await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible(); + await shot(page, '7-bulletins-recu'); + + // Nobody else gets it: neither in their ballots, nor by its address. + const other = await person(browser, 'Lou'); + await other.goto('/bulletins'); + await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible(); + expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404); +}); diff --git a/web/e2e/chain-mock.mjs b/web/e2e/chain-mock.mjs index 7ea7e5a..6ccc283 100644 --- a/web/e2e/chain-mock.mjs +++ b/web/e2e/chain-mock.mjs @@ -6,11 +6,15 @@ // each one is included in a new block, as the transfer and the remark of the // real `utility.batchAll` would be; // - `/v1/graphql`, the « indexer »: answers the server's query -// `txComments(condition: {hash})` in the shape of indexer.duniter.org. +// `squidStatus { height } txComments(condition: {hash}, filter, first, after)` +// in the shape of indexer.duniter.org: the last final block (here, every +// block is final), the comments in the order of the chain, filtered (author, +// or a transfer to an account of at least an amount) and served page by page. // // And `/control/*` for the tests: `hide` (what is included next stays unseen by -// the indexer, as an indexer late on the chain), `show` (stop hiding), `release` -// (the indexer catches up), `silent` (the indexer stops answering) and `state`. +// the indexer, as an indexer behind the chain), `show` (stop hiding), `release` +// (the indexer catches up), `silent` (the indexer stops answering), `node` +// (`?down=1`: the node stops answering, before anything is sent) and `state`. // // Listens on an ephemeral port on 127.0.0.1, written to the file given as argument. @@ -27,7 +31,7 @@ const IDENTITIES = { g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true }, }; -const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 }; +const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 }; const hex = (bytes) => Buffer.from(bytes).toString('hex'); const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`; @@ -49,6 +53,20 @@ async function read(req) { } } +/** What the real indexer's `filter` keeps, for the two filters the server asks. */ +function kept(c, filter) { + if (!filter) return true; + const author = filter.authorId?.equalTo; + if (author && c.author.id !== author) return false; + const some = filter.transfersByCommentId?.some; + if (!some) return true; + const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0'); + return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min); +} + +/** The size of a page: `first: N` in the query, as the server writes it. */ +const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100); + /** One signed extrinsic of the test mode: a new block, its transfer and its remark. */ function include(fields) { state.block += 1; @@ -70,7 +88,9 @@ const server = createServer(async (req, res) => { if (req.method === 'OPTIONS') return send(res, 204, undefined, true); if (url.pathname === '/rpc' && req.method === 'POST') { + if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true); const body = await read(req); + if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true); if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true); let fields; try { @@ -90,11 +110,19 @@ const server = createServer(async (req, res) => { const hash = body.variables?.hash; // Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer. if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' }); - const nodes = state.comments - .filter((c) => !c.hidden && remarkHash(c.remark) === hash) - .sort((a, b) => a.blockNumber - b.blockNumber) + const all = state.comments + .filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter)) + .sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id)) .map(({ hidden: _, ...c }) => c); - return send(res, 200, { data: { txComments: { nodes } } }); + const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0); + const nodes = all.slice(start, start + pageSize(String(body.query))); + const end = start + nodes.length; + return send(res, 200, { + data: { + squidStatus: { height: state.block }, + txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes }, + }, + }); } if (url.pathname.startsWith('/control/') && req.method === 'POST') { @@ -105,6 +133,7 @@ const server = createServer(async (req, res) => { state.hiding = false; for (const c of state.comments) c.hidden = false; } else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0'; + else if (what === 'node') state.nodeDown = url.searchParams.get('down') === '1'; else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true); return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true); } diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index b9c95a8..edc36b8 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM'; export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' }; export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' }; export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' }; +export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' }; export async function signIn(page: Page, name: string, admin = false) { const r = await page.request.post('/api/auth/dev', { @@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro /** * A fake wallet extension, as duniter-connect offers itself to pages * (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given, - * recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse. + * recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse; + * `window.__ldHold = true` makes it wait, as a person who has not answered yet, + * until `window.__ldAnswer(true | false)` (sign, or refuse). * It signs nothing real: the page, in test mode, sends to the simulated node. */ export async function wallet(page: Page, accounts: { address: string; name: string }[]) { await page.addInitScript((list) => { - const w = window as unknown as Record & { __ldSigned: unknown[]; __ldRefuse?: boolean }; + const w = window as unknown as Record & { + __ldSigned: unknown[]; + __ldRefuse?: boolean; + __ldHold?: boolean; + __ldAnswer?: (yes: boolean) => void; + }; w.__ldSigned = []; w.injectedWeb3 = { 'duniter-connect': { @@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri accounts: { get: async () => list, subscribe: () => () => undefined }, signer: { signPayload: async (payload: Record) => { + if (w.__ldHold) { + const yes = await new Promise((resolve) => (w.__ldAnswer = resolve)); + if (!yes) throw new Error('Cancelled'); + } if (w.__ldRefuse) throw new Error('Cancelled'); w.__ldSigned.push(payload); return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` }; @@ -79,6 +91,7 @@ export async function chain(page: Page, docPath = DOC) { return { doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] }, node, - control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(), + control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') => + (await page.request.post(`${base}/control/${what}`)).json(), }; } diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index decdaf3..3773d93 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -200,6 +200,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded(); await shot(page, '6-vote-ferme'); + // The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ». + await page.goto(`${DOC}?onglet=panier`); + const basket = page.locator('aside.panel'); + await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible(); + await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0); + await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled(); // The test group still votes, directly. await page.goto(TEST_DOC); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); diff --git a/web/package-lock.json b/web/package-lock.json index f95f69b..1170081 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@fontsource-variable/literata": "^5.3.0", "@fontsource-variable/nunito": "^5.3.0", + "@noble/hashes": "2.4.0", "@playwright/test": "1.63.0", "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.70.0", diff --git a/web/package.json b/web/package.json index 749ea07..8534bc8 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@fontsource-variable/literata": "^5.3.0", "@fontsource-variable/nunito": "^5.3.0", + "@noble/hashes": "2.4.0", "@playwright/test": "1.63.0", "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.70.0", diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index ed10023..94bd622 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -159,9 +159,11 @@ -

- {#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if} -

+ {#if open} +

+ {#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if} +

+ {/if} {#if error}{/if} diff --git a/web/src/lib/components/ChainSign.svelte b/web/src/lib/components/ChainSign.svelte index 4db3321..d02c459 100644 --- a/web/src/lib/components/ChainSign.svelte +++ b/web/src/lib/components/ChainSign.svelte @@ -3,6 +3,11 @@ // the ballot, the person's wallet signs one transaction — the amount to the // vote account and the seal of the ballot —, the page waits for the chain to // show it. Without a wallet in the browser: Ğecko, with a link and a QR code. + // + // A ballot nothing left for is let go at once (`DELETE`): the wallet refused, + // the node did not answer before signing, the window was closed before it. + // Once signed, the transaction may land: the ballot is kept, and so is a + // Ğecko code — the server reads the indexer for them, the page open or not. import { onDestroy, onMount } from 'svelte'; import { api } from '$lib/api'; import type { DocState } from '$lib/doc.svelte'; @@ -10,7 +15,7 @@ import type { Ballot, ChainSettings } from '$lib/types'; import { announce, errorText, g1Amount, type Line } from '$lib/vote'; import { geckoLink, qrPath } from '$lib/wallet/gecko'; - import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected'; + import { failure, mayLetGo, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected'; let { doc, @@ -47,11 +52,19 @@ let ballot = $state(null); let timer: ReturnType | undefined; let closed = false; + /** The transaction of the current ballot is signed: it may reach the chain. */ + let left = false; + /** A wallet is asked to sign: its own window may stay open after this one closes. */ + let asking = false; + /** The current ballot was let go: nothing left for it. */ + let dropped = false; const amount = $derived(g1Amount(chain.amount)); const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`; /** Ğecko is offered where nothing is being signed nor waited for. */ const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind)); + /** The receipt of a ballot that left, or may still. */ + const receiptShown = $derived(['waiting', 'gecko', 'confirmed', 'refused'].includes(step.kind)); onMount(() => { dialog?.showModal(); @@ -64,9 +77,19 @@ timer = undefined; } + /** Nothing left for this ballot: it is let go, rather than shown as waiting for a day. */ + async function letGo(b: Ballot) { + if (dropped || ballot?.id !== b.id) return; + dropped = true; + await api.del(`/ballots/${b.id}`).catch(() => undefined); + } + function close() { closed = true; stop(); + // Closed before anything left (and no Ğecko code shown): let it go. A wallet still + // asking keeps it until it answers — signed, it may land; refused, `go` lets it go. + if (ballot?.status === 'pending' && !left && !asking && step.kind !== 'gecko') void letGo(ballot); dialog?.close(); onclose(); } @@ -113,6 +136,7 @@ step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) }; return; } + if (closed) return; const usual = accounts.find((a) => a.address === address); if (usual) return go(wallet, usual); const [only] = accounts; @@ -134,38 +158,42 @@ } } + /** Signed: from now on the transaction may reach the chain. */ + function onTheWay(b: Ballot) { + if (left) return; + left = true; + onsent(); + if (!closed) wait(b); + } + async function go(wallet: Wallet, account: WalletAccount) { remember(wallet, account); step = { kind: 'opening' }; const b = await seal(account.address); if (!b) return; ballot = b; - let sent = false; + left = false; + dropped = false; + if (closed) return letGo(b); + asking = true; try { step = { kind: 'signing' }; await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => { - if (s === 'signing') step = { kind: 'signing' }; - else if (!sent) { - sent = true; - onsent(); - wait(b); - } + if (s === 'signing') { + if (!closed) step = { kind: 'signing' }; + } else onTheWay(b); }); - if (!sent) { - sent = true; - onsent(); - wait(b); - } + onTheWay(b); } catch (e) { const f: Failure = failure(e); - // Once on its way, the chain may still take it: keep waiting. - if (sent && f === 'node') return; - if (nothingSent(f)) { - if (f === 'account') remember(null); - await api.del(`/ballots/${b.id}`).catch(() => undefined); - } + // Signed, then the node or the wallet went quiet: it may still land. Keep waiting. + if (!mayLetGo(f, left)) return; + if (f === 'account') remember(null); + await letGo(b); stop(); - step = { kind: 'failed', text: t(`sign.error.${f}`) }; + if (!closed) step = { kind: 'failed', text: t(`sign.error.${f}`) }; + } finally { + asking = false; } } @@ -174,6 +202,9 @@ const b = await seal(null); if (!b) return; ballot = b; + left = false; + dropped = false; + if (closed) return letGo(b); const link = geckoLink(b.account, b.amount, b.comment); step = { kind: 'gecko', link, qr: await qrPath(link) }; wait(b); @@ -271,6 +302,13 @@ {:else if step.kind === 'failed'} {/if} + {#if receiptShown && ballot} +

+ {t('sign.receipt')} + · {t('sign.history')} +

+

{t('sign.receipt.note')}

+ {/if}
@@ -393,6 +431,10 @@ .small { font-size: 0.84rem; } + .receipt a { + font-weight: 750; + color: var(--water); + } .actions { display: flex; flex-wrap: wrap; diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index 4dc4e33..fb65ad5 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -123,6 +123,7 @@ {:else if ballot?.status === 'expired'}

{t('vote.mine.expired')}

{/if} + {#if p.myVoteChain || ballot}{t('vote.mine.receipts')}{/if}
{#if !votable} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 07c867e..2a591ea 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -212,6 +212,7 @@ export const en: Messages = { 'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.', 'vote.mine.refused': 'Your last vote was not counted: {reason}', 'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.', + 'vote.mine.receipts': 'Your ballots and receipts', 'vote.choice.for': 'for', 'vote.choice.against': 'against', 'vote.choice.none': 'withdrawing your vote', @@ -273,6 +274,37 @@ export const en: Messages = { 'sign.close': 'Close', 'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.', 'sign.retry': 'Try again', + 'sign.error.chain': 'The Ğ1 chain refused the transaction: your vote did not leave. Try again whenever you like.', + 'sign.receipt': 'Download your receipt', + 'sign.history': 'Your ballots', + 'sign.receipt.note': 'Your receipt keeps your ballot and its salt: with it, you check for yourself that the chain carries your vote. Keep it to yourself.', + + 'ballots.title': 'Your ballots', + 'ballots.intro': 'Each vote on the Ğ1 chain leaves in a sealed ballot: the chain shows that you voted, never what you voted. Your receipt keeps the ballot and its salt; with it, you check your vote for yourself. Nobody else receives it.', + 'ballots.mine': 'Your ballots', + 'ballots.signin': 'Sign in to find your ballots.', + 'ballots.none': 'You have not sent any ballot on the Ğ1 chain yet.', + 'ballots.lines.one': '{n} vote', + 'ballots.lines.other': '{n} votes', + 'ballots.status.pending': 'Waiting for the Ğ1 chain', + 'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”', + 'ballots.status.refused': 'Not counted: {reason}', + 'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count', + 'ballots.block': 'block {n}', + 'ballots.receipt': 'Download the receipt', + 'ballots.verify.title': 'Check a receipt', + 'ballots.verify.intro': 'Choose the file of a receipt, or paste its content: the page recomputes the seal of the ballot right here, without asking the server anything.', + 'ballots.verify.file': 'Receipt file', + '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.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.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:', + 'ballots.verify.unreadable': 'This is not a readable vote receipt.', 'inertia.title': 'Inertia', 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', @@ -477,7 +509,7 @@ export const en: Messages = { 'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.', 'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.', 'error.server_vote': 'Here, voting is direct, without a wallet.', - 'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.', + 'error.too_many_ballots': 'You already have many ballots waiting, or left on the way within the hour: wait for them to be confirmed, or try again a little later.', 'error.lines': 'This vote no longer holds: reload the page, then start again.', 'error.choice': 'This choice does not exist.', 'error.author': 'This account is not a Ğ1 address.', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 854aac4..97835c6 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -212,6 +212,7 @@ export const es: Messages = { 'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.', 'vote.mine.refused': 'Tu último voto no se contó: {reason}', 'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.', + 'vote.mine.receipts': 'Tus papeletas y recibos', 'vote.choice.for': 'a favor', 'vote.choice.against': 'en contra', 'vote.choice.none': 'retirar tu voto', @@ -273,6 +274,37 @@ export const es: Messages = { 'sign.close': 'Cerrar', 'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.', 'sign.retry': 'Reintentar', + 'sign.error.chain': 'La cadena Ğ1 rechazó la transacción: tu voto no salió. Vuelve a intentarlo cuando quieras.', + 'sign.receipt': 'Descargar tu recibo', + 'sign.history': 'Tus papeletas', + 'sign.receipt.note': 'Tu recibo guarda tu papeleta y su sal: con él, compruebas por tu cuenta que la cadena lleva tu voto. Guárdalo para ti.', + + 'ballots.title': 'Tus papeletas de voto', + 'ballots.intro': 'Cada voto por la cadena Ğ1 sale en una papeleta sellada: la cadena muestra que votaste, nunca lo que votaste. Tu recibo guarda la papeleta y su sal; con él, compruebas tu voto por tu cuenta. Nadie más lo recibe.', + 'ballots.mine': 'Tus papeletas', + 'ballots.signin': 'Conéctate para encontrar tus papeletas.', + 'ballots.none': 'Aún no has enviado ninguna papeleta por la cadena Ğ1.', + 'ballots.lines.one': '{n} voto', + 'ballots.lines.other': '{n} votos', + 'ballots.status.pending': 'Esperando a la cadena Ğ1', + 'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»', + 'ballots.status.refused': 'No contado: {reason}', + 'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta', + 'ballots.block': 'bloque {n}', + 'ballots.receipt': 'Descargar el recibo', + 'ballots.verify.title': 'Comprobar un recibo', + 'ballots.verify.intro': 'Elige el archivo de un recibo, o pega su contenido: la página recalcula el sello de la papeleta aquí mismo, sin pedir nada al servidor.', + 'ballots.verify.file': 'Archivo del recibo', + '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.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.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:', + 'ballots.verify.unreadable': 'No es un recibo de voto legible.', 'inertia.title': 'Inercia', 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', @@ -477,7 +509,7 @@ export const es: Messages = { 'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.', 'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.', 'error.server_vote': 'Aquí se vota directamente, sin monedero.', - 'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.', + 'error.too_many_ballots': 'Ya tienes muchas papeletas esperando, o abandonadas en la última hora: espera a que se confirmen, o vuelve a intentarlo un poco más tarde.', 'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.', 'error.choice': 'Esta elección no existe.', 'error.author': 'Esta cuenta no es una dirección Ğ1.', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 6f65cad..507a81b 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -215,6 +215,7 @@ export const fr = { 'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.', 'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}", 'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.", + 'vote.mine.receipts': 'Tes bulletins et reçus', 'vote.choice.for': 'pour', 'vote.choice.against': 'contre', 'vote.choice.none': 'retrait de ton vote', @@ -276,6 +277,37 @@ export const fr = { 'sign.close': 'Fermer', 'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.", 'sign.retry': 'Réessayer', + 'sign.error.chain': "La chaîne Ğ1 a refusé la transaction : ton vote n'est pas parti. Réessaie quand tu veux.", + 'sign.receipt': 'Télécharger ton reçu', + 'sign.history': 'Tes bulletins', + 'sign.receipt.note': 'Ton reçu garde ton bulletin et son sel : avec lui, tu vérifies toi-même que la chaîne porte ton vote. Garde-le pour toi.', + + 'ballots.title': 'Tes bulletins de vote', + 'ballots.intro': "Chaque vote par la chaîne Ğ1 part dans un bulletin scellé : la chaîne montre que tu as voté, jamais ce que tu as voté. Ton reçu garde le bulletin et son sel ; avec lui, tu vérifies toi-même ton vote. Personne d'autre ne le reçoit.", + 'ballots.mine': 'Tes bulletins', + 'ballots.signin': 'Connecte-toi pour retrouver tes bulletins.', + 'ballots.none': "Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.", + 'ballots.lines.one': '{n} vote', + 'ballots.lines.other': '{n} votes', + 'ballots.status.pending': 'En attente de la chaîne Ğ1', + 'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »", + 'ballots.status.refused': 'Pas compté : {reason}', + 'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas', + 'ballots.block': 'bloc {n}', + 'ballots.receipt': 'Télécharger le reçu', + 'ballots.verify.title': 'Vérifier un reçu', + 'ballots.verify.intro': "Choisis le fichier d'un reçu, ou colle son contenu : la page recalcule l'empreinte du bulletin ici même, sans rien demander au serveur.", + 'ballots.verify.file': 'Fichier du reçu', + '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.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.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 :', + 'ballots.verify.unreadable': "Ce n'est pas un reçu de vote lisible.", 'inertia.title': 'Inertie', 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", @@ -480,7 +512,7 @@ export const fr = { 'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.", 'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.', 'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.', - 'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.', + 'error.too_many_ballots': 'Tu as déjà beaucoup de bulletins en attente, ou laissés en route depuis une heure : attends leur confirmation, ou réessaie un peu plus tard.', 'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.', 'error.choice': "Ce choix n'existe pas.", 'error.author': "Ce compte n'est pas une adresse Ğ1.", diff --git a/web/src/lib/receipt.ts b/web/src/lib/receipt.ts new file mode 100644 index 0000000..ea0d17b --- /dev/null +++ b/web/src/lib/receipt.ts @@ -0,0 +1,90 @@ +// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its +// lines and its salt — as the person who asked for it downloads it +// (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking +// the server anything: the canonical text, its blake2b-256 seal, and the comment +// `ld1:` + seal that the chain carries. The same rules as the engine +// (crates/ld-engine/src/ballot.rs, `canonical` and `seal`). + +import { blake2b } from '@noble/hashes/blake2.js'; + +export type ReceiptChoice = 'for' | 'against' | 'none'; + +export interface ReceiptLine { + proposalId: string; + revision: number; + choice: ReceiptChoice; + /** For reading only: the article and the title of the version voted. */ + article?: string; + title?: string | null; +} + +export interface Sealed { + canonical: string; + digest: string; + comment: string; + /** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */ + remarkHash: string; +} + +export type ReceiptCheck = + | { kind: 'unreadable' } + | { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] } + | { kind: 'mismatch'; comment: string; computed: string }; + +const CHOICES: readonly string[] = ['for', 'against', 'none']; +/** Printable ASCII without spaces: what reads back, one field per line. */ +const PLAIN = /^[\x21-\x7e]+$/; +const SALT = /^[0-9a-f]{32}$/; + +const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + +export function blake2_256(text: string): string { + return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 })); +} + +/** The ballot as it is hashed; `null` if it cannot be one. */ +export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null { + if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null; + const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0)); + if (!sorted.length) return null; + for (const [i, l] of sorted.entries()) { + if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null; + if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null; + } + let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`; + for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`; + return `${out}salt ${salt}\n`; +} + +export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null { + const text = canonical(doc, author, lines, salt); + if (text === null) return null; + const digest = blake2_256(text); + const comment = `ld1:${digest}`; + return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` }; +} + +const isLine = (l: unknown): l is ReceiptLine => { + const v = l as Partial | null; + return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string'; +}; + +/** Reads a receipt (its text, as downloaded) and recomputes its seal. */ +export function checkReceipt(text: string): ReceiptCheck { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return { kind: 'unreadable' }; + } + const r = raw as Record | null; + if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' }; + const { doc, author, lines, salt, comment, block } = r; + if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' }; + if (author !== null && typeof author !== 'string') return { kind: 'unreadable' }; + if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' }; + const s = seal(doc, author, lines, salt); + if (!s) return { kind: 'unreadable' }; + if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment }; + return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines }; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 01ff98d..b00881f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -153,6 +153,20 @@ export interface Ballot { expiresAt: string; } +/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */ +export interface BallotEntry { + id: string; + doc: { id: string; group: string; slug: string; title: LangMap | string }; + comment: string; + status: Ballot['status']; + reason: Ballot['reason']; + block: number | null; + voter: string | null; + lines: number; + createdAt: string; + settledAt: string | null; +} + export interface Unit { id: string; code: string; diff --git a/web/src/lib/wallet/injected.ts b/web/src/lib/wallet/injected.ts index 0b45252..a694de9 100644 --- a/web/src/lib/wallet/injected.ts +++ b/web/src/lib/wallet/injected.ts @@ -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 { } } +/** 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 | 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 | 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) => { diff --git a/web/src/routes/bulletins/+page.svelte b/web/src/routes/bulletins/+page.svelte new file mode 100644 index 0000000..3862b2a --- /dev/null +++ b/web/src/routes/bulletins/+page.svelte @@ -0,0 +1,210 @@ + + +
+

{t('ballots.title')}

+

{t('ballots.intro')}

+ +
+

{t('ballots.mine')}

+ {#if !session.me} +

+ {t('ballots.signin')} +

+ {:else if data.error} + data.load()} /> + {:else if data.value} +
    + {#each data.value.ballots as b (b.id)} +
  • +

    {i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}

    +

    + {standing(b)}{#if b.block !== null} · {t('ballots.block', { n: i18n.number(b.block) })}{/if} +

    + {b.comment} + {t('ballots.receipt')} +
  • + {:else} +
  • {t('ballots.none')}
  • + {/each} +
+ {/if} +
+ +
+

{t('ballots.verify.title')}

+
+

{t('ballots.verify.intro')}

+ +
+ + +
+
+ {#if check?.kind === 'ok'} +

{t('ballots.verify.ok')}

+ {check.comment} +
    + {#each check.lines as l (l.proposalId)} +
  • {t('ballots.verify.line', { article: l.article ?? '', title: l.title ?? l.proposalId, choice: t(`vote.choice.${l.choice}`) })}
  • + {/each} +
+

+ {check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')} +

+ {:else if check?.kind === 'mismatch'} + +

{t('ballots.verify.computed')} {check.computed}

+

{t('ballots.verify.expected')} {check.comment}

+ {:else if check?.kind === 'unreadable'} + + {/if} +
+
+
+
+ + diff --git a/web/tests/unit/deps.test.ts b/web/tests/unit/deps.test.ts new file mode 100644 index 0000000..31cef3d --- /dev/null +++ b/web/tests/unit/deps.test.ts @@ -0,0 +1,29 @@ +// What the end-to-end tests import is declared: a package found only as the +// dependency of another (as @noble/hashes under polkadot-api) may vanish with it. +import { readdirSync, readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const root = join(__dirname, '../..'); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; +}; +const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]); + +/** `@scope/name/sub.js` → `@scope/name`; `name/sub` → `name`. */ +const packageOf = (spec: string) => spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/'); + +describe('the end-to-end tests', () => { + it('import only declared packages', () => { + const dir = join(root, 'e2e'); + for (const file of readdirSync(dir).filter((f) => /\.(mjs|ts)$/.test(f))) { + const text = readFileSync(join(dir, file), 'utf8'); + for (const [, spec] of text.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)) { + if (!spec || spec.startsWith('.') || spec.startsWith('node:') || builtinModules.includes(spec)) continue; + expect(declared.has(packageOf(spec)), `${file}: ${spec}`).toBe(true); + } + } + }); +}); diff --git a/web/tests/unit/receipt.test.ts b/web/tests/unit/receipt.test.ts new file mode 100644 index 0000000..e0d80b9 --- /dev/null +++ b/web/tests/unit/receipt.test.ts @@ -0,0 +1,73 @@ +// The receipt of a vote on the Ğ1 chain, checked in the browser: the same +// canonical text and the same seal as the engine (crates/ld-engine/src/ballot.rs, +// whose vectors were computed apart with Python's hashlib.blake2b). +import { describe, expect, it } from 'vitest'; +import { canonical, checkReceipt, seal } from '../../src/lib/receipt'; + +const SALT = '00112233445566778899aabbccddeeff'; +const LINES = [ + { proposalId: 'p2', revision: 1, choice: 'against' as const }, + { proposalId: 'p1', revision: 3, choice: 'for' as const }, +]; + +describe('the sealed ballot', () => { + it('reads as the engine writes it, the lines in the order of their versions', () => { + expect(canonical('d0c', null, LINES, SALT)).toBe( + 'ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n', + ); + }); + + it('seals to the remark of the engine', () => { + const s = seal('d0c', null, LINES, SALT); + expect(s?.digest).toBe('20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340'); + expect(s?.comment).toBe('ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340'); + expect(s?.remarkHash).toBe('0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3'); + const bound = seal('d0c', 'g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv', [{ proposalId: 'p1', revision: 3, choice: 'none' }], 'ffeeddccbbaa99887766554433221100'); + expect(bound?.digest).toBe('6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e'); + expect(bound?.remarkHash).toBe('0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4'); + }); + + it('what cannot be sealed', () => { + expect(seal('d0c', null, [], SALT)).toBeNull(); + expect(seal('d0c', null, [LINES[0]!, { ...LINES[0]!, revision: 2 }], SALT)).toBeNull(); + expect(seal('d 0c', null, LINES, SALT)).toBeNull(); + expect(seal('d0c', null, LINES, SALT.toUpperCase())).toBeNull(); + expect(seal('d0c', null, LINES, '0011')).toBeNull(); + expect(seal('d0c', null, [{ proposalId: 'p1', revision: 1.5, choice: 'for' }], SALT)).toBeNull(); + }); +}); + +describe('a receipt', () => { + const receipt = { + format: 'ld1', + doc: 'd0c', + author: null, + lines: LINES.map((l) => ({ ...l, article: 'E1', title: 'Unicité du compte' })), + salt: SALT, + comment: 'ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340', + block: 3_400_000, + }; + + it('holds when its lines and its salt give its comment', () => { + expect(checkReceipt(JSON.stringify(receipt))).toEqual({ + kind: 'ok', + comment: receipt.comment, + remarkHash: '0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3', + block: 3_400_000, + lines: receipt.lines, + }); + }); + + it('does not hold once a choice is changed', () => { + const changed = { ...receipt, lines: [{ ...receipt.lines[0]!, choice: 'for' }, receipt.lines[1]!] }; + const check = checkReceipt(JSON.stringify(changed)); + expect(check.kind).toBe('mismatch'); + if (check.kind === 'mismatch') expect(check.computed).not.toBe(receipt.comment); + }); + + it('what is not a receipt', () => { + for (const text of ['', 'nope', '[]', '{}', JSON.stringify({ ...receipt, format: 'ld2' }), JSON.stringify({ ...receipt, lines: 'x' })]) { + expect(checkReceipt(text).kind, text).toBe('unreadable'); + } + }); +}); diff --git a/web/tests/unit/wallet.test.ts b/web/tests/unit/wallet.test.ts index e3dd253..41e92f3 100644 --- a/web/tests/unit/wallet.test.ts +++ b/web/tests/unit/wallet.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { amountText, geckoLink } from '../../src/lib/wallet/gecko'; -import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected'; +import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected'; const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM'; const COMMENT = `ld1:${'ab'.repeat(32)}`; @@ -30,6 +30,17 @@ describe('wallets of the page', () => { expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds'); expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds'); expect(failure(new Error('socket hang up'))).toBe('wallet'); + // Refused by the pool of the node: it will never be included. + expect(failure({ type: 'Invalid', value: { type: 'Stale' } })).toBe('chain'); + expect(failure({ code: 1010, message: 'Invalid Transaction' })).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); + // Signed and handed to the node: it may reach the chain, unless the chain refused it. + for (const f of ['funds', 'account', 'chain'] as const) expect(mayLetGo(f, true), f).toBe(true); + for (const f of ['node', 'wallet'] as const) expect(mayLetGo(f, true), f).toBe(false); }); it('in tests, the fake node receives what was signed, readable', () => {