// 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; 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) { super(code); } } export interface Wallet { /** The name the extension gives itself in `window.injectedWeb3`. */ id: string; accounts(): Promise; send(account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void): Promise; } interface Enabled { accounts: { get: () => Promise<{ address: string; name?: string }[]> }; signer: { signPayload?: (payload: Record) => Promise<{ signature: string }> }; } interface Injected { enable?: (dapp: string) => Promise; } type Page = { injectedWeb3?: Record }; 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; // polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent. // Its message is indented JSON, so the reason is read from the error itself. if (e instanceof Error && e.name === 'InvalidTxError') { const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase(); return /payment|funds|balance|existential/.test(reason) ? 'funds' : 'chain'; } 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'; // Refused by the node's pool (stale, bad proof…): it will never be included. if (/"type":\s*"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' || 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. */ export function testExtrinsic(fields: Record): string { return `0x${hex(new TextEncoder().encode(JSON.stringify(fields)))}`; } async function enable(name: string): Promise { 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: 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; try { ({ signature } = await ext.signer.signPayload({ address: account.address, method: testExtrinsic(fields), version: 4 })); } catch (e) { throw new WalletError(failure(e)); } 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'); } /** 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'); // 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!({ 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((resolve, reject) => { 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. 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) === 'funds' ? 'funds' : 'chain')); } }, 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)); }