From 8d4c51f8477c239cfb3ff43c74f97c2ae1b7b9eb Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Wed, 4 Mar 2026 16:03:21 +0300 Subject: [PATCH] fix: refresh inbox after key unlock + query previous era for message visibility - Call refreshInbox() immediately after setupKey/unlockKey so messages decrypt instantly instead of waiting for 12s polling interval - Query both current and previous era to prevent message loss at era boundaries - Add toJSON fallback for robust field parsing in getInbox - Improve debug logging with era, address, and field diagnostics --- web/src/hooks/useMessaging.ts | 129 +++++++++++++++++++-------------- web/src/lib/messaging/chain.ts | 67 +++++++++++++---- 2 files changed, 126 insertions(+), 70 deletions(-) diff --git a/web/src/hooks/useMessaging.ts b/web/src/hooks/useMessaging.ts index 171f3b09..26cd3e9f 100644 --- a/web/src/hooks/useMessaging.ts +++ b/web/src/hooks/useMessaging.ts @@ -79,6 +79,73 @@ export function useMessaging() { } }, [peopleApi, isPeopleReady, selectedAccount]); + // Decrypt a list of EncryptedMessages using the current private key + const decryptInbox = useCallback((inbox: EncryptedMessage[]): DecryptedMessage[] => { + if (privateKeyRef.current) { + return inbox.map(msg => { + try { + const plaintext = decryptMessage( + privateKeyRef.current!, + msg.ephemeralPublicKey, + msg.nonce, + msg.ciphertext + ); + return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext, raw: msg }; + } catch (err) { + const errText = err instanceof Error ? err.message : String(err); + const toHx = (u: Uint8Array) => Array.from(u.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(''); + const dbg = `e:${toHx(msg.ephemeralPublicKey)} n:${toHx(msg.nonce)} c:${toHx(msg.ciphertext)} L:${msg.ephemeralPublicKey?.length}/${msg.nonce?.length}/${msg.ciphertext?.length}`; + return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext: `[${errText}] ${dbg}`, raw: msg }; + } + }); + } + return inbox.map(msg => ({ + sender: msg.sender, + blockNumber: msg.blockNumber, + plaintext: null, + raw: msg, + })); + }, []); + + // Refresh inbox from chain (queries current + previous era) + const refreshInbox = useCallback(async () => { + if (!peopleApi || !isPeopleReady || !selectedAccount) return; + if (!isPalletAvailable(peopleApi)) return; + + try { + const era = await getCurrentEra(peopleApi); + const addr = selectedAccount.address; + + // Query current era + previous era (messages purge at era boundary) + const queries: Promise[] = [ + getInbox(peopleApi, era, addr), + ]; + if (era > 0) { + queries.push(getInbox(peopleApi, era - 1, addr)); + } + + const [currentInbox, prevInbox = []] = await Promise.all(queries); + const sendCount = await getSendCount(peopleApi, era, addr); + + // Merge: previous era messages first, then current era + const allMessages = [...prevInbox, ...currentInbox]; + + console.log(`[PEZMessage] refreshInbox era=${era} addr=${addr.slice(0, 8)}… current=${currentInbox.length} prev=${prevInbox.length} total=${allMessages.length} keyUnlocked=${!!privateKeyRef.current}`); + + const decrypted = decryptInbox(allMessages); + + setState(prev => ({ + ...prev, + era, + inbox: allMessages, + sendCount, + decryptedMessages: decrypted, + })); + } catch (err) { + console.error('[PEZMessage] Failed to refresh inbox:', err); + } + }, [peopleApi, isPeopleReady, selectedAccount, decryptInbox]); + // Derive encryption keys from wallet signature and register on-chain const setupKey = useCallback(async () => { if (!peopleApi || !isPeopleReady || !selectedAccount) { @@ -119,6 +186,8 @@ export function useMessaging() { registering: false, })); toast.success('Encryption key unlocked'); + // Refresh inbox now that we have the private key for decryption + refreshInbox(); return; } @@ -155,12 +224,13 @@ export function useMessaging() { registering: false, })); toast.success('Encryption key registered'); + refreshInbox(); } catch (err) { setState(prev => ({ ...prev, registering: false })); const msg = err instanceof Error ? err.message : 'Failed to setup key'; toast.error(msg); } - }, [peopleApi, isPeopleReady, selectedAccount, walletSource, signMessage]); + }, [peopleApi, isPeopleReady, selectedAccount, walletSource, signMessage, refreshInbox]); // Unlock existing key (re-derive from signature without registering) const unlockKey = useCallback(async () => { @@ -184,64 +254,13 @@ export function useMessaging() { publicKeyRef.current = publicKey; setState(prev => ({ ...prev, isKeyUnlocked: true, registering: false })); toast.success('Encryption key unlocked'); + // Refresh inbox now that we have the private key for decryption + refreshInbox(); } catch { setState(prev => ({ ...prev, registering: false })); toast.error('Failed to unlock key'); } - }, [peopleApi, selectedAccount, signMessage]); - - // Refresh inbox from chain - const refreshInbox = useCallback(async () => { - if (!peopleApi || !isPeopleReady || !selectedAccount) return; - if (!isPalletAvailable(peopleApi)) return; - - try { - const era = await getCurrentEra(peopleApi); - const [inbox, sendCount] = await Promise.all([ - getInbox(peopleApi, era, selectedAccount.address), - getSendCount(peopleApi, era, selectedAccount.address), - ]); - - // Auto-decrypt if private key is available - let decrypted: DecryptedMessage[] = []; - if (privateKeyRef.current) { - decrypted = inbox.map(msg => { - try { - const plaintext = decryptMessage( - privateKeyRef.current!, - msg.ephemeralPublicKey, - msg.nonce, - msg.ciphertext - ); - return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext, raw: msg }; - } catch (err) { - const errText = err instanceof Error ? err.message : String(err); - // Show first 8 hex chars of each field for comparison with raw SCALE - const toHx = (u: Uint8Array) => Array.from(u.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(''); - const dbg = `e:${toHx(msg.ephemeralPublicKey)} n:${toHx(msg.nonce)} c:${toHx(msg.ciphertext)} L:${msg.ephemeralPublicKey?.length}/${msg.nonce?.length}/${msg.ciphertext?.length}`; - return { sender: msg.sender, blockNumber: msg.blockNumber, plaintext: `[${errText}] ${dbg}`, raw: msg }; - } - }); - } else { - decrypted = inbox.map(msg => ({ - sender: msg.sender, - blockNumber: msg.blockNumber, - plaintext: null, - raw: msg, - })); - } - - setState(prev => ({ - ...prev, - era, - inbox, - sendCount, - decryptedMessages: decrypted, - })); - } catch (err) { - if (import.meta.env.DEV) console.error('Failed to refresh inbox:', err); - } - }, [peopleApi, isPeopleReady, selectedAccount]); + }, [peopleApi, selectedAccount, signMessage, refreshInbox]); // Send an encrypted message const sendEncryptedMessage = useCallback(async (recipient: string, text: string) => { diff --git a/web/src/lib/messaging/chain.ts b/web/src/lib/messaging/chain.ts index ca179e1d..0fcf9561 100644 --- a/web/src/lib/messaging/chain.ts +++ b/web/src/lib/messaging/chain.ts @@ -33,7 +33,11 @@ export async function getEncryptionKey( if (!messaging?.encryptionKeys) return null; const result = await messaging.encryptionKeys(address); if (result.isNone || result.isEmpty) return null; - const hex = result.unwrap().toHex(); + // Handle both Option<[u8;32]> (needs unwrap) and direct [u8;32] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inner = typeof (result as any).unwrap === 'function' ? (result as any).unwrap() : result; + const hex = inner.toHex(); + console.log(`[PEZMessage] getEncryptionKey(${address.slice(0, 8)}…) = ${hex.slice(0, 18)}…`); return hexToBytes(hex); } @@ -54,30 +58,63 @@ export async function getInbox( const messaging = (api.query as any).messaging; if (!messaging?.inbox) return []; const result = await messaging.inbox(era, address); - console.log('[PEZMessage] raw inbox result:', JSON.stringify(result.toHuman?.() ?? result)); + + // Debug: log raw result type and content + const human = result.toHuman?.() ?? result; + console.log(`[PEZMessage] getInbox(era=${era}, addr=${address.slice(0, 8)}…) isEmpty=${result.isEmpty} length=${result.length} raw:`, JSON.stringify(human)); + if (result.isEmpty || result.length === 0) return []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const first = result[0]; if (first) { - const keys = Object.getOwnPropertyNames(first).filter(k => !k.startsWith('_')); + // Log all available field access patterns + const ownKeys = Object.getOwnPropertyNames(first).filter(k => !k.startsWith('_')); const json = first.toJSON?.() ?? {}; const jsonKeys = Object.keys(json); - console.log('[PEZMessage] field names:', keys, 'json keys:', jsonKeys, 'json:', JSON.stringify(json)); + console.log('[PEZMessage] struct field names (own):', ownKeys, '(json):', jsonKeys); + console.log('[PEZMessage] first message json:', JSON.stringify(json)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return result.map((msg: Record) => { - // Try multiple field name patterns - const eph = msg.ephemeralPublicKey ?? msg.ephemeral_public_key ?? msg.ephemeralPubKey ?? msg.ephemeral_pub_key; - const blk = msg.blockNumber ?? msg.block_number ?? msg.blockNum; - const ct = msg.ciphertext ?? msg.cipher_text; + return result.map((msg: Record, idx: number) => { + // Try codec accessors first (camelCase), then snake_case, then toJSON fallback + let eph = msg.ephemeralPublicKey ?? msg.ephemeral_public_key; + let blk = msg.blockNumber ?? msg.block_number; + let ct = msg.ciphertext ?? msg.cipher_text; + const nonce = msg.nonce; + const sender = msg.sender; + + // Fallback: use toJSON() if codec accessors returned undefined + if (!eph || !nonce || !ct) { + const json = msg.toJSON?.() ?? {}; + console.warn(`[PEZMessage] msg[${idx}] codec accessors incomplete, using toJSON fallback:`, JSON.stringify(json)); + if (!eph) eph = json.ephemeralPublicKey ?? json.ephemeral_public_key; + if (!blk && blk !== 0) blk = json.blockNumber ?? json.block_number; + if (!ct) ct = json.ciphertext ?? json.cipher_text; + } + + // Convert to bytes - handle both codec objects (.toHex()) and raw hex strings + const toBytes = (val: unknown, label: string, expectedLen?: number): Uint8Array => { + if (!val) { + console.error(`[PEZMessage] msg[${idx}].${label} is null/undefined`); + return new Uint8Array(expectedLen ?? 0); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hex = typeof (val as any).toHex === 'function' ? (val as any).toHex() : typeof val === 'string' ? val : '0x'; + const bytes = hexToBytes(hex); + if (expectedLen && bytes.length !== expectedLen) { + console.warn(`[PEZMessage] msg[${idx}].${label} expected ${expectedLen} bytes, got ${bytes.length}`); + } + return bytes; + }; + return { - sender: msg.sender.toString(), - blockNumber: blk?.toNumber?.() ?? 0, - ephemeralPublicKey: hexToBytes(eph?.toHex?.() ?? '0x'), - nonce: hexToBytes(msg.nonce?.toHex?.() ?? '0x'), - ciphertext: hexToBytes(ct?.toHex?.() ?? '0x'), + sender: sender?.toString?.() ?? '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + blockNumber: typeof blk === 'number' ? blk : (blk as any)?.toNumber?.() ?? 0, + ephemeralPublicKey: toBytes(eph, 'ephemeralPublicKey', 32), + nonce: toBytes(nonce, 'nonce', 24), + ciphertext: toBytes(ct, 'ciphertext'), }; }); }