fix(web): guard messaging against missing pallet + add back-to-home

- Check isPalletAvailable() BEFORE requesting wallet signature
- All chain queries return safe defaults if pallet not in runtime
- Show orange banner when messaging pallet needs runtime upgrade
- Add floating back-to-home button on messaging page
This commit is contained in:
2026-03-03 08:40:41 +03:00
parent ad3c0e414e
commit 7ff8ae4462
3 changed files with 96 additions and 14 deletions
+43 -10
View File
@@ -3,6 +3,7 @@ import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext'; import { useWallet } from '@/contexts/WalletContext';
import { deriveKeypair, encryptMessage, decryptMessage } from '@/lib/messaging/crypto'; import { deriveKeypair, encryptMessage, decryptMessage } from '@/lib/messaging/crypto';
import { import {
isPalletAvailable,
getEncryptionKey, getEncryptionKey,
getCurrentEra, getCurrentEra,
getInbox, getInbox,
@@ -23,6 +24,7 @@ export interface DecryptedMessage {
} }
interface MessagingState { interface MessagingState {
palletReady: boolean;
isKeyRegistered: boolean; isKeyRegistered: boolean;
isKeyUnlocked: boolean; isKeyUnlocked: boolean;
inbox: EncryptedMessage[]; inbox: EncryptedMessage[];
@@ -39,6 +41,7 @@ export function useMessaging() {
const { signMessage } = useWallet(); const { signMessage } = useWallet();
const [state, setState] = useState<MessagingState>({ const [state, setState] = useState<MessagingState>({
palletReady: false,
isKeyRegistered: false, isKeyRegistered: false,
isKeyUnlocked: false, isKeyUnlocked: false,
inbox: [], inbox: [],
@@ -55,9 +58,18 @@ export function useMessaging() {
const publicKeyRef = useRef<Uint8Array | null>(null); const publicKeyRef = useRef<Uint8Array | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Check if messaging pallet exists on chain
const checkPalletAvailability = useCallback((): boolean => {
if (!peopleApi || !isPeopleReady) return false;
const available = isPalletAvailable(peopleApi);
setState(prev => ({ ...prev, palletReady: available }));
return available;
}, [peopleApi, isPeopleReady]);
// Check if user has a registered encryption key on-chain // Check if user has a registered encryption key on-chain
const checkKeyRegistration = useCallback(async () => { const checkKeyRegistration = useCallback(async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return; if (!peopleApi || !isPeopleReady || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try { try {
const key = await getEncryptionKey(peopleApi, selectedAccount.address); const key = await getEncryptionKey(peopleApi, selectedAccount.address);
@@ -74,19 +86,27 @@ export function useMessaging() {
return; return;
} }
// Check pallet BEFORE asking for signature
if (!isPalletAvailable(peopleApi)) {
toast.error('Messaging pallet is not yet available on this chain. Runtime upgrade required.');
return;
}
setState(prev => ({ ...prev, registering: true })); setState(prev => ({ ...prev, registering: true }));
try { try {
// 1. Sign deterministic message to derive keys // 1. Check if key is already registered on-chain (no signing needed)
const existingKey = await getEncryptionKey(peopleApi, selectedAccount.address);
// 2. Now sign to derive keys
const signature = await signMessage('PEZMessage:v1'); const signature = await signMessage('PEZMessage:v1');
const { publicKey, privateKey } = deriveKeypair(signature); const { publicKey, privateKey } = deriveKeypair(signature);
// 2. Store keys in memory // 3. Store keys in memory
privateKeyRef.current = privateKey; privateKeyRef.current = privateKey;
publicKeyRef.current = publicKey; publicKeyRef.current = publicKey;
// 3. Check if key is already registered on-chain // 4. If key already matches, just unlock
const existingKey = await getEncryptionKey(peopleApi, selectedAccount.address);
const alreadyRegistered = existingKey !== null && const alreadyRegistered = existingKey !== null &&
existingKey.length === publicKey.length && existingKey.length === publicKey.length &&
existingKey.every((b, i) => b === publicKey[i]); existingKey.every((b, i) => b === publicKey[i]);
@@ -102,7 +122,7 @@ export function useMessaging() {
return; return;
} }
// 4. Register key on-chain // 5. Register key on-chain
const tx = buildRegisterKeyTx(peopleApi, publicKey); const tx = buildRegisterKeyTx(peopleApi, publicKey);
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi); const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
@@ -163,6 +183,7 @@ export function useMessaging() {
// Refresh inbox from chain // Refresh inbox from chain
const refreshInbox = useCallback(async () => { const refreshInbox = useCallback(async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return; if (!peopleApi || !isPeopleReady || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try { try {
const era = await getCurrentEra(peopleApi); const era = await getCurrentEra(peopleApi);
@@ -214,6 +235,10 @@ export function useMessaging() {
toast.error('Wallet not connected'); toast.error('Wallet not connected');
return; return;
} }
if (!isPalletAvailable(peopleApi)) {
toast.error('Messaging pallet is not available on this chain');
return;
}
setState(prev => ({ ...prev, sending: true })); setState(prev => ({ ...prev, sending: true }));
@@ -267,6 +292,7 @@ export function useMessaging() {
// Acknowledge messages (optional, feeless) // Acknowledge messages (optional, feeless)
const acknowledge = useCallback(async () => { const acknowledge = useCallback(async () => {
if (!peopleApi || !selectedAccount) return; if (!peopleApi || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try { try {
const tx = buildAcknowledgeTx(peopleApi); const tx = buildAcknowledgeTx(peopleApi);
@@ -284,14 +310,21 @@ export function useMessaging() {
setState(prev => ({ ...prev, loading: true })); setState(prev => ({ ...prev, loading: true }));
const init = async () => { const init = async () => {
await checkKeyRegistration(); const available = checkPalletAvailability();
await refreshInbox(); if (available) {
await checkKeyRegistration();
await refreshInbox();
}
setState(prev => ({ ...prev, loading: false })); setState(prev => ({ ...prev, loading: false }));
}; };
init(); init();
// Poll every 12 seconds (1 block interval) // Poll every 12 seconds (1 block interval) - only if pallet exists
pollIntervalRef.current = setInterval(refreshInbox, 12000); pollIntervalRef.current = setInterval(() => {
if (isPalletAvailable(peopleApi)) {
refreshInbox();
}
}, 12000);
return () => { return () => {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
@@ -299,7 +332,7 @@ export function useMessaging() {
pollIntervalRef.current = null; pollIntervalRef.current = null;
} }
}; };
}, [peopleApi, isPeopleReady, selectedAccount, checkKeyRegistration, refreshInbox]); }, [peopleApi, isPeopleReady, selectedAccount, checkPalletAvailability, checkKeyRegistration, refreshInbox]);
// Clear private key when account changes // Clear private key when account changes
useEffect(() => { useEffect(() => {
+25 -4
View File
@@ -9,6 +9,19 @@ export interface EncryptedMessage {
ciphertext: Uint8Array; ciphertext: Uint8Array;
} }
/**
* Check if the messaging pallet exists in the runtime metadata.
* Must be called before any other chain function.
*/
export function isPalletAvailable(api: ApiPromise): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !!(api.query as any).messaging;
} catch {
return false;
}
}
// --- Storage queries --- // --- Storage queries ---
export async function getEncryptionKey( export async function getEncryptionKey(
@@ -16,7 +29,9 @@ export async function getEncryptionKey(
address: string address: string
): Promise<Uint8Array | null> { ): Promise<Uint8Array | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query as any).messaging.encryptionKeys(address); const messaging = (api.query as any).messaging;
if (!messaging?.encryptionKeys) return null;
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(); const hex = result.unwrap().toHex();
return hexToBytes(hex); return hexToBytes(hex);
@@ -24,7 +39,9 @@ export async function getEncryptionKey(
export async function getCurrentEra(api: ApiPromise): Promise<number> { export async function getCurrentEra(api: ApiPromise): Promise<number> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const era = await (api.query as any).messaging.currentEra(); const messaging = (api.query as any).messaging;
if (!messaging?.currentEra) return 0;
const era = await messaging.currentEra();
return era.toNumber(); return era.toNumber();
} }
@@ -34,7 +51,9 @@ export async function getInbox(
address: string address: string
): Promise<EncryptedMessage[]> { ): Promise<EncryptedMessage[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query as any).messaging.inbox([era, address]); const messaging = (api.query as any).messaging;
if (!messaging?.inbox) return [];
const result = await messaging.inbox([era, address]);
if (result.isEmpty || result.length === 0) return []; if (result.isEmpty || result.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -55,7 +74,9 @@ export async function getSendCount(
address: string address: string
): Promise<number> { ): Promise<number> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const count = await (api.query as any).messaging.sendCount([era, address]); const messaging = (api.query as any).messaging;
if (!messaging?.sendCount) return 0;
const count = await messaging.sendCount([era, address]);
return count.toNumber(); return count.toNumber();
} }
+28
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useMessaging } from '@/hooks/useMessaging'; import { useMessaging } from '@/hooks/useMessaging';
@@ -14,12 +15,15 @@ import {
Loader2, Loader2,
Wallet, Wallet,
Inbox, Inbox,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
export default function Messaging() { export default function Messaging() {
const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi(); const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const { const {
palletReady,
isKeyRegistered, isKeyRegistered,
isKeyUnlocked, isKeyUnlocked,
decryptedMessages, decryptedMessages,
@@ -130,7 +134,20 @@ export default function Messaging() {
</div> </div>
</div> </div>
{/* Pallet not available banner */}
{!loading && !palletReady && (
<Card className="border-orange-500/30 bg-orange-500/5 mb-4">
<CardContent className="flex items-center gap-3 p-4">
<AlertTriangle className="w-5 h-5 text-orange-400 flex-shrink-0" />
<p className="text-sm text-orange-300">
{t('messaging.palletNotReady', 'Messaging pallet is not yet available on People Chain. A runtime upgrade is required.')}
</p>
</CardContent>
</Card>
)}
{/* Key Setup Banner */} {/* Key Setup Banner */}
{palletReady && (
<div className="mb-4"> <div className="mb-4">
<KeySetup <KeySetup
isKeyRegistered={isKeyRegistered} isKeyRegistered={isKeyRegistered}
@@ -140,6 +157,7 @@ export default function Messaging() {
onUnlockKey={unlockKey} onUnlockKey={unlockKey}
/> />
</div> </div>
)}
{/* Era / Stats bar */} {/* Era / Stats bar */}
<div className="flex items-center gap-3 text-xs text-gray-500 mb-4 px-1"> <div className="flex items-center gap-3 text-xs text-gray-500 mb-4 px-1">
@@ -196,6 +214,16 @@ export default function Messaging() {
onSend={sendMessage} onSend={sendMessage}
sending={sending} sending={sending}
/> />
{/* Back to Home */}
<div className="fixed bottom-4 right-4 sm:bottom-8 sm:right-8 z-50">
<button
onClick={() => navigate('/')}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-2 sm:px-6 sm:py-3 text-sm sm:text-base rounded-full shadow-lg flex items-center gap-2 transition-all"
>
{`${t('common.backToHome')}`}
</button>
</div>
</div> </div>
); );
} }