Files
decision/web/src/lib/wallet/injected.ts
T
YvvandClaude Opus 5.5 9c8f4cc568 v3 : relecture de 7ffa4fd — un refus du nœud lâche le bulletin, le reçu ne dit vérifié que ce que scelle l'empreinte
polkadot-api lève InvalidTxError (JSON indenté) quand le nœud refuse la transaction :
elle est désormais lue comme « la chaîne a dit non » (rien n'est parti, le bulletin est
lâché), au lieu de rester en attente 24 h. La page /bulletins ne présente comme vérifiés
que la version, la révision et le choix ; l'article, le titre et le bloc du reçu sont dits
hors du sceau. ADR 0008 : trois affirmations corrigées (indexeur sans filtre, débit de la
tâche de fond, lectures qui n'utilisent pas encore shown_tally).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:46:28 +01:00

256 lines
10 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; 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<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;
// 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, 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: 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<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!({
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) => {
// 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));
}