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: 210000/225837 UTXOs insérés...
⏳ Traitement: 220000/225837 UTXOs insérés...
@ -98,3 +56,45 @@
- Non dépensés: 61609
✅ 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);
// Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout
merged = await runCollectLoop(
endpoints,
proof.challenge.hash,
proof.signatures,
loginPath,
merged = await runCollectLoop({
relayEndpoints: endpoints,
hash: proof.challenge.hash,
ourSigs: proof.signatures,
path: loginPath,
pairToMembers,
pubkeyToPair,
{
opts: {
pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => {
@ -290,7 +290,7 @@ export function LoginScreen(): JSX.Element {
});
},
},
);
});
} finally {
setIsCollecting(false);
setCollectProgressState(null);
@ -884,14 +884,14 @@ export function LoginScreen(): JSX.Element {
identity?.publicKey ?? '',
loginPath.pairs_attendus,
);
const merged = await runCollectLoop(
endpoints,
proof.challenge.hash,
proof.signatures,
loginPath,
const merged = await runCollectLoop({
relayEndpoints: endpoints,
hash: proof.challenge.hash,
ourSigs: proof.signatures,
path: loginPath,
pairToMembers,
pubkeyToPair,
{
opts: {
pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => {
@ -902,7 +902,7 @@ export function LoginScreen(): JSX.Element {
});
},
},
);
});
setCollectedMerged(merged);
} finally {
setIsCollecting(false);

View File

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

View File

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

View File

@ -57,15 +57,15 @@ export function SyncScreen(): JSX.Element {
remote !== undefined &&
identity.privateKey !== undefined
) {
void checkPairingConfirmationFromSync(
void checkPairingConfirmationFromSync({
relays,
local.uuid,
remote.uuid,
pairLocal: local.uuid,
pairRemote: remote.uuid,
identity,
start,
end,
remote.publicKey,
).catch((err: unknown) => {
remotePublicKey: remote.publicKey,
}).catch((err: unknown) => {
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 './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>
<App />
</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.
* DH obligatoire: encrypt with ECDH and POST MsgCle. recipientPublicKey and senderIdentity required.
*/
async function publishPairingMessage(
relays: RelayConfig[],
message: MembreFinaliserMessage,
hash: string,
recipientPublicKey: string,
senderIdentity: LocalIdentity,
params: PublishPairingMessageParams,
): Promise<void> {
const { relays, message, hash, recipientPublicKey, senderIdentity } = params;
if (recipientPublicKey === undefined || recipientPublicKey === '') {
throw new Error(
'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.
* DH obligatoire: recipientPublicKey and senderIdentity required.
*/
export async function publishPairingMessageAndSignature(
relays: RelayConfig[],
message: MembreFinaliserMessage,
hash: string,
sig: Signature,
recipientPublicKey: string,
senderIdentity: LocalIdentity,
params: PublishPairingMessageAndSignatureParams,
): Promise<void> {
await publishPairingMessage(
const { relays, message, hash, sig, recipientPublicKey, senderIdentity } =
params;
await publishPairingMessage({
relays,
message,
hash,
recipientPublicKey,
senderIdentity,
);
});
const enabled = relays.filter((r) => r.enabled);
const msgSig: MsgSignature = { signature: sig };
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.
* DH only: ECDH decryption when senderPublicKey and ourIdentity provided. No base64.
*/
export async function fetchPairingMessage(
relays: RelayConfig[],
pairLocal: string,
pairRemote: string,
start: number,
end: number,
senderPublicKey?: string,
ourIdentity?: LocalIdentity,
params: FetchPairingMessageParams,
): Promise<{ message: MembreFinaliserMessage; hash: string } | null> {
const {
relays,
pairLocal,
pairRemote,
start,
end,
senderPublicKey,
ourIdentity,
} = params;
const enabled = relays.filter((r) => r.enabled);
const key = sortedPairKey(pairLocal, pairRemote);
const useEcdh =
@ -401,13 +425,13 @@ async function buildAndPublishPairingVersion2(
};
const canonical = JSON.stringify(m2);
const hash2 = await hashStringAsync(canonical, 'sha256');
await publishPairingMessage(
await publishPairingMessage({
relays,
m2,
hash2,
message: m2,
hash: hash2,
recipientPublicKey,
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.
* remotePublicKey: identity public key of device 2, for ECDH encryption. Required (DH obligatoire).
*/
export async function runDevice1Confirmation(
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
remotePublicKey: string,
params: RunDevice1ConfirmationParams,
): Promise<boolean> {
const { pairLocal, pairRemote, identity, relays, remotePublicKey } = params;
if (remotePublicKey === undefined || remotePublicKey === '') {
throw new Error(
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
@ -479,14 +508,14 @@ export async function runDevice1Confirmation(
);
const n = generateUuid();
const sig = signMembreFinaliser(hash, identity, n);
await publishPairingMessageAndSignature(
await publishPairingMessageAndSignature({
relays,
message,
hash,
sig,
remotePublicKey,
identity,
);
recipientPublicKey: remotePublicKey,
senderIdentity: identity,
});
for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (i > 0) {
await delay(POLL_DELAY_MS);
@ -508,28 +537,41 @@ export async function runDevice1Confirmation(
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.
* remotePublicKey: identity public key of device 1 (sender), for ECDH decryption.
*/
export async function runDevice2Confirmation(
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
start: number,
end: number,
remotePublicKey?: string,
params: RunDevice2ConfirmationParams,
): Promise<boolean> {
const found = await fetchPairingMessage(
const {
pairLocal,
pairRemote,
identity,
relays,
start,
end,
remotePublicKey,
} = params;
const found = await fetchPairingMessage({
relays,
pairLocal,
pairRemote,
start,
end,
remotePublicKey,
identity,
);
senderPublicKey: remotePublicKey,
ourIdentity: identity,
});
if (found === null) {
return false;
}
@ -547,33 +589,46 @@ export async function runDevice2Confirmation(
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).
* Fetches pairing message v1, signatures; if both signers present, stores confirmation.
* remotePublicKey: identity public key of the other device (sender), for ECDH decryption.
*/
export async function checkPairingConfirmationFromSync(
relays: RelayConfig[],
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
start: number,
end: number,
remotePublicKey?: string,
params: CheckPairingConfirmationFromSyncParams,
): Promise<boolean> {
const {
relays,
pairLocal,
pairRemote,
identity,
start,
end,
remotePublicKey,
} = params;
const already = await getPairingConfirmed(pairLocal, pairRemote);
if (already) {
return true;
}
const found = await fetchPairingMessage(
const found = await fetchPairingMessage({
relays,
pairLocal,
pairRemote,
start,
end,
remotePublicKey,
identity,
);
senderPublicKey: remotePublicKey,
ourIdentity: identity,
});
if (found === null) {
return false;
}

View File

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

View File

@ -151,19 +151,32 @@ export interface CollectLoopOpts {
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.
* Optional onProgress called each poll for UI (e.g. X/Y signatures).
*/
export async function runCollectLoop(
relayEndpoints: string[],
hash: string,
ourSigs: ProofSignature[],
path: LoginPath,
pairToMembers: Map<string, string[]>,
pubkeyToPair: Map<string, string>,
opts: CollectLoopOpts,
params: RunCollectLoopParams,
): Promise<ProofSignature[]> {
const {
relayEndpoints,
hash,
ourSigs,
path,
pairToMembers,
pubkeyToPair,
opts,
} = params;
const start = Date.now();
let merged = ourSigs;
for (;;) {