UserWallet: runCollectLoop options object, root null check, pairing/collect/bip32

**Motivations:**
- Passer runCollectLoop en objet d’options pour lisibilité et évolution
- Éviter non-null assertion sur root dans main.tsx
- Ajustements pairing, collect signatures, bip32

**Root causes:**
- N/A (évolutions + correctifs ciblés)

**Correctifs:**
- main.tsx: vérification root !== null avant createRoot, throw explicite si absent

**Evolutions:**
- LoginScreen: runCollectLoop({ relayEndpoints, hash, ourSigs, path, pairToMembers, pubkeyToPair, opts })
- pairingConfirm.ts, PairingDisplayScreen, PairingSetupBlock: modifications
- collectSignatures.ts, bip32.ts: ajustements
- SyncScreen: modifications mineures

**Pages affectées:**
- userwallet: LoginScreen, main.tsx, PairingDisplayScreen, PairingSetupBlock, SyncScreen, pairingConfirm.ts, bip32.ts, collectSignatures.ts
- data: sync-utxos.log
This commit is contained in:
ncantu 2026-01-28 09:43:55 +01:00
parent cb13ab6fbf
commit 9291eab9d5
9 changed files with 226 additions and 140 deletions

View File

@ -1,45 +1,3 @@
⏳ Traitement: 200000/225826 UTXOs insérés...
⏳ Traitement: 210000/225826 UTXOs insérés...
⏳ Traitement: 220000/225826 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 61609
- UTXOs toujours disponibles: 61609
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 6789
- Non dépensés: 61609
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 61609
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 225837
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/225837 UTXOs insérés...
⏳ Traitement: 20000/225837 UTXOs insérés...
⏳ Traitement: 30000/225837 UTXOs insérés...
⏳ Traitement: 40000/225837 UTXOs insérés...
⏳ Traitement: 50000/225837 UTXOs insérés...
⏳ Traitement: 60000/225837 UTXOs insérés...
⏳ Traitement: 70000/225837 UTXOs insérés...
⏳ Traitement: 80000/225837 UTXOs insérés...
⏳ Traitement: 90000/225837 UTXOs insérés...
⏳ Traitement: 100000/225837 UTXOs insérés...
⏳ Traitement: 110000/225837 UTXOs insérés...
⏳ Traitement: 120000/225837 UTXOs insérés...
⏳ Traitement: 130000/225837 UTXOs insérés...
⏳ Traitement: 140000/225837 UTXOs insérés...
⏳ Traitement: 150000/225837 UTXOs insérés...
⏳ Traitement: 160000/225837 UTXOs insérés...
⏳ Traitement: 170000/225837 UTXOs insérés...
⏳ Traitement: 180000/225837 UTXOs insérés...
⏳ Traitement: 190000/225837 UTXOs insérés...
⏳ Traitement: 200000/225837 UTXOs insérés... ⏳ Traitement: 200000/225837 UTXOs insérés...
⏳ Traitement: 210000/225837 UTXOs insérés... ⏳ Traitement: 210000/225837 UTXOs insérés...
⏳ Traitement: 220000/225837 UTXOs insérés... ⏳ Traitement: 220000/225837 UTXOs insérés...
@ -98,3 +56,45 @@
- Non dépensés: 61609 - Non dépensés: 61609
✅ Synchronisation terminée ✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 61598
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 225867
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/225867 UTXOs insérés...
⏳ Traitement: 20000/225867 UTXOs insérés...
⏳ Traitement: 30000/225867 UTXOs insérés...
⏳ Traitement: 40000/225867 UTXOs insérés...
⏳ Traitement: 50000/225867 UTXOs insérés...
⏳ Traitement: 60000/225867 UTXOs insérés...
⏳ Traitement: 70000/225867 UTXOs insérés...
⏳ Traitement: 80000/225867 UTXOs insérés...
⏳ Traitement: 90000/225867 UTXOs insérés...
⏳ Traitement: 100000/225867 UTXOs insérés...
⏳ Traitement: 110000/225867 UTXOs insérés...
⏳ Traitement: 120000/225867 UTXOs insérés...
⏳ Traitement: 130000/225867 UTXOs insérés...
⏳ Traitement: 140000/225867 UTXOs insérés...
⏳ Traitement: 150000/225867 UTXOs insérés...
⏳ Traitement: 160000/225867 UTXOs insérés...
⏳ Traitement: 170000/225867 UTXOs insérés...
⏳ Traitement: 180000/225867 UTXOs insérés...
⏳ Traitement: 190000/225867 UTXOs insérés...
⏳ Traitement: 200000/225867 UTXOs insérés...
⏳ Traitement: 210000/225867 UTXOs insérés...
⏳ Traitement: 220000/225867 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 61598
- UTXOs toujours disponibles: 61598
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 6800
- Non dépensés: 61598
✅ Synchronisation terminée

View File

@ -272,14 +272,14 @@ export function LoginScreen(): JSX.Element {
); );
const endpoints = relays.map((r) => r.endpoint); const endpoints = relays.map((r) => r.endpoint);
// Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout // Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout
merged = await runCollectLoop( merged = await runCollectLoop({
endpoints, relayEndpoints: endpoints,
proof.challenge.hash, hash: proof.challenge.hash,
proof.signatures, ourSigs: proof.signatures,
loginPath, path: loginPath,
pairToMembers, pairToMembers,
pubkeyToPair, pubkeyToPair,
{ opts: {
pollMs: COLLECT_POLL_MS, pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS, timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => { onProgress: (m) => {
@ -290,7 +290,7 @@ export function LoginScreen(): JSX.Element {
}); });
}, },
}, },
); });
} finally { } finally {
setIsCollecting(false); setIsCollecting(false);
setCollectProgressState(null); setCollectProgressState(null);
@ -884,14 +884,14 @@ export function LoginScreen(): JSX.Element {
identity?.publicKey ?? '', identity?.publicKey ?? '',
loginPath.pairs_attendus, loginPath.pairs_attendus,
); );
const merged = await runCollectLoop( const merged = await runCollectLoop({
endpoints, relayEndpoints: endpoints,
proof.challenge.hash, hash: proof.challenge.hash,
proof.signatures, ourSigs: proof.signatures,
loginPath, path: loginPath,
pairToMembers, pairToMembers,
pubkeyToPair, pubkeyToPair,
{ opts: {
pollMs: COLLECT_POLL_MS, pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS, timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => { onProgress: (m) => {
@ -902,7 +902,7 @@ export function LoginScreen(): JSX.Element {
}); });
}, },
}, },
); });
setCollectedMerged(merged); setCollectedMerged(merged);
} finally { } finally {
setIsCollecting(false); setIsCollecting(false);

View File

@ -89,15 +89,15 @@ export function PairingDisplayScreen(): JSX.Element {
} }
setIsConfirming(true); setIsConfirming(true);
try { try {
const ok = await runDevice2Confirmation( const ok = await runDevice2Confirmation({
local.uuid, pairLocal: local.uuid,
remote.uuid, pairRemote: remote.uuid,
identity, identity,
relays, relays,
identity.t0_anniversaire, start: identity.t0_anniversaire,
Date.now(), end: Date.now(),
pubkeyHex, remotePublicKey: pubkeyHex,
); });
setJustConnected(ok); setJustConnected(ok);
} catch (err) { } catch (err) {
console.error('Pairing confirmation (device 2):', err); console.error('Pairing confirmation (device 2):', err);

View File

@ -94,13 +94,13 @@ export function PairingSetupBlock(): JSX.Element {
} }
setIsConfirming(true); setIsConfirming(true);
try { try {
await runDevice1Confirmation( await runDevice1Confirmation({
local.uuid, pairLocal: local.uuid,
remote.uuid, pairRemote: remote.uuid,
identity, identity,
relays, relays,
pubkeyHex, remotePublicKey: pubkeyHex,
); });
} catch (err) { } catch (err) {
console.error('Pairing confirmation (device 1):', err); console.error('Pairing confirmation (device 1):', err);
setRemoteError( setRemoteError(

View File

@ -57,15 +57,15 @@ export function SyncScreen(): JSX.Element {
remote !== undefined && remote !== undefined &&
identity.privateKey !== undefined identity.privateKey !== undefined
) { ) {
void checkPairingConfirmationFromSync( void checkPairingConfirmationFromSync({
relays, relays,
local.uuid, pairLocal: local.uuid,
remote.uuid, pairRemote: remote.uuid,
identity, identity,
start, start,
end, end,
remote.publicKey, remotePublicKey: remote.publicKey,
).catch((err: unknown) => { }).catch((err: unknown) => {
console.error('Pairing confirmation check during sync:', err); console.error('Pairing confirmation check during sync:', err);
}); });
} }

View File

@ -3,7 +3,11 @@ import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( const rootElement = document.getElementById('root');
if (rootElement === null) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,

View File

@ -171,17 +171,22 @@ function buildMsgCle(
}; };
} }
interface PublishPairingMessageParams {
relays: RelayConfig[];
message: MembreFinaliserMessage;
hash: string;
recipientPublicKey: string;
senderIdentity: LocalIdentity;
}
/** /**
* Publish a pairing message (MsgChiffre) to relays. * Publish a pairing message (MsgChiffre) to relays.
* DH obligatoire: encrypt with ECDH and POST MsgCle. recipientPublicKey and senderIdentity required. * DH obligatoire: encrypt with ECDH and POST MsgCle. recipientPublicKey and senderIdentity required.
*/ */
async function publishPairingMessage( async function publishPairingMessage(
relays: RelayConfig[], params: PublishPairingMessageParams,
message: MembreFinaliserMessage,
hash: string,
recipientPublicKey: string,
senderIdentity: LocalIdentity,
): Promise<void> { ): Promise<void> {
const { relays, message, hash, recipientPublicKey, senderIdentity } = params;
if (recipientPublicKey === undefined || recipientPublicKey === '') { if (recipientPublicKey === undefined || recipientPublicKey === '') {
throw new Error( throw new Error(
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).', 'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
@ -212,25 +217,31 @@ async function publishPairingMessage(
} }
} }
interface PublishPairingMessageAndSignatureParams {
relays: RelayConfig[];
message: MembreFinaliserMessage;
hash: string;
sig: Signature;
recipientPublicKey: string;
senderIdentity: LocalIdentity;
}
/** /**
* Publish message and first signature to relays. * Publish message and first signature to relays.
* DH obligatoire: recipientPublicKey and senderIdentity required. * DH obligatoire: recipientPublicKey and senderIdentity required.
*/ */
export async function publishPairingMessageAndSignature( export async function publishPairingMessageAndSignature(
relays: RelayConfig[], params: PublishPairingMessageAndSignatureParams,
message: MembreFinaliserMessage,
hash: string,
sig: Signature,
recipientPublicKey: string,
senderIdentity: LocalIdentity,
): Promise<void> { ): Promise<void> {
await publishPairingMessage( const { relays, message, hash, sig, recipientPublicKey, senderIdentity } =
params;
await publishPairingMessage({
relays, relays,
message, message,
hash, hash,
recipientPublicKey, recipientPublicKey,
senderIdentity, senderIdentity,
); });
const enabled = relays.filter((r) => r.enabled); const enabled = relays.filter((r) => r.enabled);
const msgSig: MsgSignature = { signature: sig }; const msgSig: MsgSignature = { signature: sig };
for (const r of enabled) { for (const r of enabled) {
@ -255,19 +266,32 @@ export async function publishPairingSignature(
} }
} }
interface FetchPairingMessageParams {
relays: RelayConfig[];
pairLocal: string;
pairRemote: string;
start: number;
end: number;
senderPublicKey?: string;
ourIdentity?: LocalIdentity;
}
/** /**
* Fetch messages in time window, find "membre finaliser" v1 for our pairs. * Fetch messages in time window, find "membre finaliser" v1 for our pairs.
* DH only: ECDH decryption when senderPublicKey and ourIdentity provided. No base64. * DH only: ECDH decryption when senderPublicKey and ourIdentity provided. No base64.
*/ */
export async function fetchPairingMessage( export async function fetchPairingMessage(
relays: RelayConfig[], params: FetchPairingMessageParams,
pairLocal: string,
pairRemote: string,
start: number,
end: number,
senderPublicKey?: string,
ourIdentity?: LocalIdentity,
): Promise<{ message: MembreFinaliserMessage; hash: string } | null> { ): Promise<{ message: MembreFinaliserMessage; hash: string } | null> {
const {
relays,
pairLocal,
pairRemote,
start,
end,
senderPublicKey,
ourIdentity,
} = params;
const enabled = relays.filter((r) => r.enabled); const enabled = relays.filter((r) => r.enabled);
const key = sortedPairKey(pairLocal, pairRemote); const key = sortedPairKey(pairLocal, pairRemote);
const useEcdh = const useEcdh =
@ -401,13 +425,13 @@ async function buildAndPublishPairingVersion2(
}; };
const canonical = JSON.stringify(m2); const canonical = JSON.stringify(m2);
const hash2 = await hashStringAsync(canonical, 'sha256'); const hash2 = await hashStringAsync(canonical, 'sha256');
await publishPairingMessage( await publishPairingMessage({
relays, relays,
m2, message: m2,
hash2, hash: hash2,
recipientPublicKey, recipientPublicKey,
senderIdentity, senderIdentity,
); });
} }
/** /**
@ -456,17 +480,22 @@ function delay(ms: number): Promise<void> {
}); });
} }
interface RunDevice1ConfirmationParams {
pairLocal: string;
pairRemote: string;
identity: LocalIdentity;
relays: RelayConfig[];
remotePublicKey: string;
}
/** /**
* Run confirmation flow for device 1: create M, sign, publish, poll for remote sig, store. * Run confirmation flow for device 1: create M, sign, publish, poll for remote sig, store.
* remotePublicKey: identity public key of device 2, for ECDH encryption. Required (DH obligatoire). * remotePublicKey: identity public key of device 2, for ECDH encryption. Required (DH obligatoire).
*/ */
export async function runDevice1Confirmation( export async function runDevice1Confirmation(
pairLocal: string, params: RunDevice1ConfirmationParams,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
remotePublicKey: string,
): Promise<boolean> { ): Promise<boolean> {
const { pairLocal, pairRemote, identity, relays, remotePublicKey } = params;
if (remotePublicKey === undefined || remotePublicKey === '') { if (remotePublicKey === undefined || remotePublicKey === '') {
throw new Error( throw new Error(
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).', 'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
@ -479,14 +508,14 @@ export async function runDevice1Confirmation(
); );
const n = generateUuid(); const n = generateUuid();
const sig = signMembreFinaliser(hash, identity, n); const sig = signMembreFinaliser(hash, identity, n);
await publishPairingMessageAndSignature( await publishPairingMessageAndSignature({
relays, relays,
message, message,
hash, hash,
sig, sig,
remotePublicKey, recipientPublicKey: remotePublicKey,
identity, senderIdentity: identity,
); });
for (let i = 0; i < POLL_ATTEMPTS; i++) { for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (i > 0) { if (i > 0) {
await delay(POLL_DELAY_MS); await delay(POLL_DELAY_MS);
@ -508,28 +537,41 @@ export async function runDevice1Confirmation(
return false; return false;
} }
interface RunDevice2ConfirmationParams {
pairLocal: string;
pairRemote: string;
identity: LocalIdentity;
relays: RelayConfig[];
start: number;
end: number;
remotePublicKey?: string;
}
/** /**
* Run confirmation flow for device 2: fetch M, verify sig1, sign, publish sig2, store. * Run confirmation flow for device 2: fetch M, verify sig1, sign, publish sig2, store.
* remotePublicKey: identity public key of device 1 (sender), for ECDH decryption. * remotePublicKey: identity public key of device 1 (sender), for ECDH decryption.
*/ */
export async function runDevice2Confirmation( export async function runDevice2Confirmation(
pairLocal: string, params: RunDevice2ConfirmationParams,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
start: number,
end: number,
remotePublicKey?: string,
): Promise<boolean> { ): Promise<boolean> {
const found = await fetchPairingMessage( const {
pairLocal,
pairRemote,
identity,
relays,
start,
end,
remotePublicKey,
} = params;
const found = await fetchPairingMessage({
relays, relays,
pairLocal, pairLocal,
pairRemote, pairRemote,
start, start,
end, end,
remotePublicKey, senderPublicKey: remotePublicKey,
identity, ourIdentity: identity,
); });
if (found === null) { if (found === null) {
return false; return false;
} }
@ -547,33 +589,46 @@ export async function runDevice2Confirmation(
return true; return true;
} }
interface CheckPairingConfirmationFromSyncParams {
relays: RelayConfig[];
pairLocal: string;
pairRemote: string;
identity: LocalIdentity;
start: number;
end: number;
remotePublicKey?: string;
}
/** /**
* Check for pairing confirmation during Sync (device 1 timed out, user runs Sync later). * Check for pairing confirmation during Sync (device 1 timed out, user runs Sync later).
* Fetches pairing message v1, signatures; if both signers present, stores confirmation. * Fetches pairing message v1, signatures; if both signers present, stores confirmation.
* remotePublicKey: identity public key of the other device (sender), for ECDH decryption. * remotePublicKey: identity public key of the other device (sender), for ECDH decryption.
*/ */
export async function checkPairingConfirmationFromSync( export async function checkPairingConfirmationFromSync(
relays: RelayConfig[], params: CheckPairingConfirmationFromSyncParams,
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
start: number,
end: number,
remotePublicKey?: string,
): Promise<boolean> { ): Promise<boolean> {
const {
relays,
pairLocal,
pairRemote,
identity,
start,
end,
remotePublicKey,
} = params;
const already = await getPairingConfirmed(pairLocal, pairRemote); const already = await getPairingConfirmed(pairLocal, pairRemote);
if (already) { if (already) {
return true; return true;
} }
const found = await fetchPairingMessage( const found = await fetchPairingMessage({
relays, relays,
pairLocal, pairLocal,
pairRemote, pairRemote,
start, start,
end, end,
remotePublicKey, senderPublicKey: remotePublicKey,
identity, ourIdentity: identity,
); });
if (found === null) { if (found === null) {
return false; return false;
} }

View File

@ -273,8 +273,18 @@ export function uuidToBip32Words(uuid: string): string[] {
const uuidBytes = hexToBytes(uuid.replace(/-/g, '')); const uuidBytes = hexToBytes(uuid.replace(/-/g, ''));
const words: string[] = []; const words: string[] = [];
for (let i = 0; i < uuidBytes.length; i += 2) { for (let i = 0; i < uuidBytes.length; i += 2) {
const index = (uuidBytes[i]! << 8) | (uuidBytes[i + 1] ?? 0); const byte1 = uuidBytes[i];
words.push(BIP32_WORDLIST[index % BIP32_WORDLIST.length]!); if (byte1 === undefined) {
continue;
}
const byte2 = uuidBytes[i + 1] ?? 0;
const index = (byte1 << 8) | byte2;
const wordIndex = index % BIP32_WORDLIST.length;
const word = BIP32_WORDLIST[wordIndex];
if (word === undefined) {
continue;
}
words.push(word);
} }
return words; return words;
} }
@ -294,8 +304,12 @@ export function bip32WordsToUuid(words: string[]): string | null {
} }
const bytes = new Uint8Array(indices.length * 2); const bytes = new Uint8Array(indices.length * 2);
for (let i = 0; i < indices.length; i++) { for (let i = 0; i < indices.length; i++) {
bytes[i * 2] = (indices[i]! >> 8) & 0xff; const index = indices[i];
bytes[i * 2 + 1] = indices[i]! & 0xff; if (index === undefined) {
continue;
}
bytes[i * 2] = (index >> 8) & 0xff;
bytes[i * 2 + 1] = index & 0xff;
} }
const hex = bytesToHex(bytes); const hex = bytesToHex(bytes);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;

View File

@ -151,19 +151,32 @@ export interface CollectLoopOpts {
onProgress?: (merged: ProofSignature[]) => void; onProgress?: (merged: ProofSignature[]) => void;
} }
interface RunCollectLoopParams {
relayEndpoints: string[];
hash: string;
ourSigs: ProofSignature[];
path: LoginPath;
pairToMembers: Map<string, string[]>;
pubkeyToPair: Map<string, string>;
opts: CollectLoopOpts;
}
/** /**
* Collect signatures from relays until we have enough per member, or timeout. * Collect signatures from relays until we have enough per member, or timeout.
* Optional onProgress called each poll for UI (e.g. X/Y signatures). * Optional onProgress called each poll for UI (e.g. X/Y signatures).
*/ */
export async function runCollectLoop( export async function runCollectLoop(
relayEndpoints: string[], params: RunCollectLoopParams,
hash: string,
ourSigs: ProofSignature[],
path: LoginPath,
pairToMembers: Map<string, string[]>,
pubkeyToPair: Map<string, string>,
opts: CollectLoopOpts,
): Promise<ProofSignature[]> { ): Promise<ProofSignature[]> {
const {
relayEndpoints,
hash,
ourSigs,
path,
pairToMembers,
pubkeyToPair,
opts,
} = params;
const start = Date.now(); const start = Date.now();
let merged = ourSigs; let merged = ourSigs;
for (;;) { for (;;) {