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
This commit is contained in:
2026-03-04 16:03:21 +03:00
parent 3ad5a627b3
commit cb9cd6a410
2 changed files with 126 additions and 70 deletions
+74 -55
View File
@@ -79,6 +79,73 @@ export function useMessaging() {
} }
}, [peopleApi, isPeopleReady, selectedAccount]); }, [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<EncryptedMessage[]>[] = [
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 // Derive encryption keys from wallet signature and register on-chain
const setupKey = useCallback(async () => { const setupKey = useCallback(async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) { if (!peopleApi || !isPeopleReady || !selectedAccount) {
@@ -119,6 +186,8 @@ export function useMessaging() {
registering: false, registering: false,
})); }));
toast.success('Encryption key unlocked'); toast.success('Encryption key unlocked');
// Refresh inbox now that we have the private key for decryption
refreshInbox();
return; return;
} }
@@ -155,12 +224,13 @@ export function useMessaging() {
registering: false, registering: false,
})); }));
toast.success('Encryption key registered'); toast.success('Encryption key registered');
refreshInbox();
} catch (err) { } catch (err) {
setState(prev => ({ ...prev, registering: false })); setState(prev => ({ ...prev, registering: false }));
const msg = err instanceof Error ? err.message : 'Failed to setup key'; const msg = err instanceof Error ? err.message : 'Failed to setup key';
toast.error(msg); toast.error(msg);
} }
}, [peopleApi, isPeopleReady, selectedAccount, walletSource, signMessage]); }, [peopleApi, isPeopleReady, selectedAccount, walletSource, signMessage, refreshInbox]);
// Unlock existing key (re-derive from signature without registering) // Unlock existing key (re-derive from signature without registering)
const unlockKey = useCallback(async () => { const unlockKey = useCallback(async () => {
@@ -184,64 +254,13 @@ export function useMessaging() {
publicKeyRef.current = publicKey; publicKeyRef.current = publicKey;
setState(prev => ({ ...prev, isKeyUnlocked: true, registering: false })); setState(prev => ({ ...prev, isKeyUnlocked: true, registering: false }));
toast.success('Encryption key unlocked'); toast.success('Encryption key unlocked');
// Refresh inbox now that we have the private key for decryption
refreshInbox();
} catch { } catch {
setState(prev => ({ ...prev, registering: false })); setState(prev => ({ ...prev, registering: false }));
toast.error('Failed to unlock key'); toast.error('Failed to unlock key');
} }
}, [peopleApi, selectedAccount, signMessage]); }, [peopleApi, selectedAccount, signMessage, refreshInbox]);
// 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]);
// Send an encrypted message // Send an encrypted message
const sendEncryptedMessage = useCallback(async (recipient: string, text: string) => { const sendEncryptedMessage = useCallback(async (recipient: string, text: string) => {
+52 -15
View File
@@ -33,7 +33,11 @@ export async function getEncryptionKey(
if (!messaging?.encryptionKeys) return null; if (!messaging?.encryptionKeys) return null;
const result = await messaging.encryptionKeys(address); const result = await messaging.encryptionKeys(address);
if (result.isNone || result.isEmpty) return null; 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); return hexToBytes(hex);
} }
@@ -54,30 +58,63 @@ export async function getInbox(
const messaging = (api.query as any).messaging; const messaging = (api.query as any).messaging;
if (!messaging?.inbox) return []; if (!messaging?.inbox) return [];
const result = await messaging.inbox(era, address); 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 []; if (result.isEmpty || result.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const first = result[0]; const first = result[0];
if (first) { 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 json = first.toJSON?.() ?? {};
const jsonKeys = Object.keys(json); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
return result.map((msg: Record<string, any>) => { return result.map((msg: Record<string, any>, idx: number) => {
// Try multiple field name patterns // Try codec accessors first (camelCase), then snake_case, then toJSON fallback
const eph = msg.ephemeralPublicKey ?? msg.ephemeral_public_key ?? msg.ephemeralPubKey ?? msg.ephemeral_pub_key; let eph = msg.ephemeralPublicKey ?? msg.ephemeral_public_key;
const blk = msg.blockNumber ?? msg.block_number ?? msg.blockNum; let blk = msg.blockNumber ?? msg.block_number;
const ct = msg.ciphertext ?? msg.cipher_text; 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 { return {
sender: msg.sender.toString(), sender: sender?.toString?.() ?? '',
blockNumber: blk?.toNumber?.() ?? 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any
ephemeralPublicKey: hexToBytes(eph?.toHex?.() ?? '0x'), blockNumber: typeof blk === 'number' ? blk : (blk as any)?.toNumber?.() ?? 0,
nonce: hexToBytes(msg.nonce?.toHex?.() ?? '0x'), ephemeralPublicKey: toBytes(eph, 'ephemeralPublicKey', 32),
ciphertext: hexToBytes(ct?.toHex?.() ?? '0x'), nonce: toBytes(nonce, 'nonce', 24),
ciphertext: toBytes(ct, 'ciphertext'),
}; };
}); });
} }