// 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; 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; 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 { 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: 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((resolve, reject) => { let timer: ReturnType | 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)); }