forked from yvv/decision
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:
@@ -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<Entry[]> => (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);
|
||||
});
|
||||
+37
-8
@@ -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);
|
||||
}
|
||||
|
||||
+16
-3
@@ -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<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
|
||||
const w = window as unknown as Record<string, unknown> & {
|
||||
__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<string, unknown>) => {
|
||||
if (w.__ldHold) {
|
||||
const yes = await new Promise<boolean>((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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -159,9 +159,11 @@
|
||||
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
|
||||
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
|
||||
</button>
|
||||
<p class="faint small note-send">
|
||||
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
||||
</p>
|
||||
{#if open}
|
||||
<p class="faint small note-send">
|
||||
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -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<Ballot | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | 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'}
|
||||
<p class="warn" role="alert">{step.text}</p>
|
||||
{/if}
|
||||
{#if receiptShown && ballot}
|
||||
<p class="receipt small">
|
||||
<a href="/api/ballots/{ballot.id}/receipt" download>{t('sign.receipt')}</a>
|
||||
· <a href="/bulletins">{t('sign.history')}</a>
|
||||
</p>
|
||||
<p class="faint small">{t('sign.receipt.note')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -393,6 +431,10 @@
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.receipt a {
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
{:else if ballot?.status === 'expired'}
|
||||
<p class="warn">{t('vote.mine.expired')}</p>
|
||||
{/if}
|
||||
{#if p.myVoteChain || ballot}<a class="faint small" href="/bulletins">{t('vote.mine.receipts')}</a>{/if}
|
||||
</div>
|
||||
|
||||
{#if !votable}
|
||||
|
||||
+33
-1
@@ -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.',
|
||||
|
||||
+33
-1
@@ -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.',
|
||||
|
||||
+33
-1
@@ -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.",
|
||||
|
||||
@@ -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<ReceiptLine> | 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<string, unknown> | 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
||||
// note promises everyone can check their own vote with its salt: the receipt is
|
||||
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
||||
// it is checked here, in the browser, without asking the server anything.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { BallotEntry, LangMap } from '$lib/types';
|
||||
|
||||
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
||||
const MAX_FILE = 1_000_000;
|
||||
|
||||
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
||||
onMount(() => {
|
||||
if (session.me) void data.load();
|
||||
});
|
||||
|
||||
let pasted = $state('');
|
||||
let check = $state<ReceiptCheck | null>(null);
|
||||
|
||||
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
||||
|
||||
function standing(b: BallotEntry): string {
|
||||
if (b.status === 'pending') return t('ballots.status.pending');
|
||||
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||
return t('ballots.status.expired');
|
||||
}
|
||||
|
||||
async function fromFile(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
pasted = '';
|
||||
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
||||
}
|
||||
|
||||
function fromText(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
check = checkReceipt(pasted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('ballots.title')}</h1>
|
||||
<p class="lead">{t('ballots.intro')}</p>
|
||||
|
||||
<section aria-labelledby="ballots-mine">
|
||||
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
||||
{#if !session.me}
|
||||
<p class="muted">
|
||||
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
<ul class="list">
|
||||
{#each data.value.ballots as b (b.id)}
|
||||
<li class="card block ballot">
|
||||
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||
<p class="state {b.status}">
|
||||
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||
</p>
|
||||
<code class="comment">{b.comment}</code>
|
||||
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="faint">{t('ballots.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ballots-verify">
|
||||
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
||||
<div class="card block">
|
||||
<p class="muted">{t('ballots.verify.intro')}</p>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.file')}</span>
|
||||
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
||||
</label>
|
||||
<form class="paste" onsubmit={fromText}>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.paste')}</span>
|
||||
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
||||
</label>
|
||||
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
||||
</form>
|
||||
<div class="result" aria-live="polite">
|
||||
{#if check?.kind === 'ok'}
|
||||
<p class="ok">{t('ballots.verify.ok')}</p>
|
||||
<code class="comment">{check.comment}</code>
|
||||
<ul class="lines">
|
||||
{#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>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="faint small">
|
||||
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
||||
</p>
|
||||
{:else if check?.kind === 'mismatch'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
||||
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
||||
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
||||
{:else if check?.kind === 'unreadable'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.list,
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.ballot {
|
||||
justify-items: start;
|
||||
}
|
||||
.state {
|
||||
font-weight: 750;
|
||||
}
|
||||
.state.confirmed {
|
||||
color: var(--moss);
|
||||
}
|
||||
.state.pending {
|
||||
color: var(--water);
|
||||
}
|
||||
.state.refused,
|
||||
.state.expired {
|
||||
color: var(--ochre);
|
||||
}
|
||||
.comment {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
textarea {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.paste {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.paste .field,
|
||||
.paste textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lines {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ok {
|
||||
font-weight: 800;
|
||||
color: var(--moss);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user