- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
(enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
inertie et formule. Le reste d'une version (coquilles, traductions,
signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
pour tout le document, jamais pour un article à plusieurs versions ; un seul
geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
7.7 KiB
TypeScript
207 lines
7.7 KiB
TypeScript
// The wallets of the page: every browser extension that offers the standard
|
|
// interface of Substrate wallets (`window.injectedWeb3`, `signPayload`) —
|
|
// duniter-connect today; Ğ1Companion and trustinWallet the day they offer it.
|
|
// One adapter, one interface: the vote never knows which wallet signs.
|
|
//
|
|
// The transaction is one gesture (Yvv, 2026-10-30): `utility.batchAll` of the
|
|
// transfer to the vote account and the remark that seals the ballot, built by
|
|
// polkadot-api — loaded only now, at the moment of signing — signed by the
|
|
// extension (its own confirmation window), sent to a Ğ1 node. The server then
|
|
// finds it on the indexer.
|
|
|
|
import type { ChainSettings } from '../types';
|
|
|
|
export interface WalletAccount {
|
|
address: string;
|
|
name: string | null;
|
|
}
|
|
|
|
export interface VoteTransfer {
|
|
/** The vote account. */
|
|
dest: string;
|
|
/** Cents of Ğ1. */
|
|
amount: bigint;
|
|
/** `ld1:` + the seal of the ballot. */
|
|
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';
|
|
|
|
export class WalletError extends Error {
|
|
constructor(public code: Failure) {
|
|
super(code);
|
|
}
|
|
}
|
|
|
|
export interface Wallet {
|
|
/** The name the extension gives itself in `window.injectedWeb3`. */
|
|
id: string;
|
|
accounts(): Promise<WalletAccount[]>;
|
|
send(account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void): Promise<void>;
|
|
}
|
|
|
|
interface Enabled {
|
|
accounts: { get: () => Promise<{ address: string; name?: string }[]> };
|
|
signer: { signPayload?: (payload: Record<string, unknown>) => Promise<{ signature: string }> };
|
|
}
|
|
interface Injected {
|
|
enable?: (dapp: string) => Promise<Enabled>;
|
|
}
|
|
type Page = { injectedWeb3?: Record<string, unknown> };
|
|
|
|
const DAPP = 'libreDecision';
|
|
/** Wallets of the Ğ1 first, then the others, in the order they were found. */
|
|
const PREFERRED = ['duniter-connect', 'g1companion', 'trustinwallet'];
|
|
const rank = (name: string) => {
|
|
const i = PREFERRED.indexOf(name.toLowerCase());
|
|
return i < 0 ? PREFERRED.length : i;
|
|
};
|
|
|
|
export function injectedNames(page: Page): string[] {
|
|
return Object.entries(page.injectedWeb3 ?? {})
|
|
.filter(([, v]) => typeof (v as Injected | null)?.enable === 'function')
|
|
.map(([name]) => name)
|
|
.sort((a, b) => rank(a) - rank(b));
|
|
}
|
|
|
|
/** What went wrong, in one of a few words the interface translates. */
|
|
export function failure(e: unknown): Failure {
|
|
if (e instanceof WalletError) return e.code;
|
|
let text: string;
|
|
if (e instanceof Error) text = e.message;
|
|
else {
|
|
try {
|
|
text = JSON.stringify(e) ?? String(e);
|
|
} catch {
|
|
text = String(e);
|
|
}
|
|
}
|
|
text = text.toLowerCase();
|
|
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
|
|
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
|
|
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';
|
|
}
|
|
|
|
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. */
|
|
export function testExtrinsic(fields: Record<string, string>): string {
|
|
return `0x${hex(new TextEncoder().encode(JSON.stringify(fields)))}`;
|
|
}
|
|
|
|
async function enable(name: string): Promise<Enabled> {
|
|
const injected = (window as Page).injectedWeb3?.[name] as Injected | undefined;
|
|
if (!injected?.enable) throw new WalletError('wallet');
|
|
try {
|
|
return await injected.enable(DAPP);
|
|
} catch (e) {
|
|
throw new WalletError(failure(e));
|
|
}
|
|
}
|
|
|
|
/** 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');
|
|
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
|
|
on('signing');
|
|
let signature: string;
|
|
try {
|
|
({ signature } = await ext.signer.signPayload({ address: account.address, method: testExtrinsic(fields), version: 4 }));
|
|
} 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('sent');
|
|
}
|
|
|
|
/** The real thing: polkadot-api builds the transaction from the chain's own description. */
|
|
async function sendChain(name: string, nodes: string[], account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
|
|
const [{ createClient, Binary }, { getWsProvider }, { connectInjectedExtension }] = await Promise.all([
|
|
import('polkadot-api'),
|
|
import('polkadot-api/ws'),
|
|
import('polkadot-api/pjs-signer'),
|
|
]);
|
|
const ext = await connectInjectedExtension(name, DAPP).catch((e: unknown) => {
|
|
throw new WalletError(failure(e));
|
|
});
|
|
const client = createClient(getWsProvider(nodes));
|
|
try {
|
|
const signer = ext.getAccounts().find((a) => a.address === account.address);
|
|
if (!signer) throw new WalletError('account');
|
|
// 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!({
|
|
calls: [
|
|
calls.Balances!.transfer_keep_alive!({ dest: { type: 'Id', value: tx.dest }, value: tx.amount }).decodedCall,
|
|
calls.System!.remark_with_event!({ remark: Binary.fromText(tx.remark) }).decodedCall,
|
|
],
|
|
});
|
|
on('signing');
|
|
await new Promise<void>((resolve, reject) => {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
|
|
next: (ev) => {
|
|
if (ev.type === 'broadcasted') {
|
|
on('sent');
|
|
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
|
|
timer ??= setTimeout(() => {
|
|
sub.unsubscribe();
|
|
reject(new WalletError('node'));
|
|
}, 120_000);
|
|
}
|
|
if (ev.type === 'inBestBlock') {
|
|
clearTimeout(timer);
|
|
sub.unsubscribe();
|
|
if (ev.ok) {
|
|
on('included');
|
|
resolve();
|
|
} else reject(new WalletError(failure(ev.dispatchError)));
|
|
}
|
|
},
|
|
error: (e: unknown) => {
|
|
clearTimeout(timer);
|
|
reject(new WalletError(failure(e)));
|
|
},
|
|
});
|
|
});
|
|
} finally {
|
|
client.destroy();
|
|
ext.disconnect();
|
|
}
|
|
}
|
|
|
|
function injected(name: string, chain: ChainSettings): Wallet {
|
|
return {
|
|
id: name,
|
|
async accounts() {
|
|
const ext = await enable(name);
|
|
const list = await ext.accounts.get().catch((e: unknown) => {
|
|
throw new WalletError(failure(e));
|
|
});
|
|
return list.map((a) => ({ address: a.address, name: a.name ?? null }));
|
|
},
|
|
send(account, tx, on) {
|
|
return chain.test && chain.testNode
|
|
? sendTest(name, chain.testNode, account, tx, on)
|
|
: sendChain(name, chain.nodes, account, tx, on);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The wallets this page can talk to, the ones for Ğ1 first. */
|
|
export function wallets(chain: ChainSettings): Wallet[] {
|
|
return injectedNames(window as Page).map((name) => injected(name, chain));
|
|
}
|