v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- 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>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// 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));
|
||||
}
|
||||
Reference in New Issue
Block a user