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 { deriveKeypair, encryptMessage, decryptMessage } from '@/lib/messaging/crypto';
import {
isPalletAvailable,
getEncryptionKey,
getCurrentEra,
getInbox,
@@ -23,6 +24,7 @@ export interface DecryptedMessage {
}
interface MessagingState {
palletReady: boolean;
isKeyRegistered: boolean;
isKeyUnlocked: boolean;
inbox: EncryptedMessage[];
@@ -39,6 +41,7 @@ export function useMessaging() {
const { signMessage } = useWallet();
const [state, setState] = useState<MessagingState>({
palletReady: false,
isKeyRegistered: false,
isKeyUnlocked: false,
inbox: [],
@@ -55,9 +58,18 @@ export function useMessaging() {
const publicKeyRef = useRef<Uint8Array | 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
const checkKeyRegistration = useCallback(async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try {
const key = await getEncryptionKey(peopleApi, selectedAccount.address);
@@ -74,19 +86,27 @@ export function useMessaging() {
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 }));
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 { publicKey, privateKey } = deriveKeypair(signature);
// 2. Store keys in memory
// 3. Store keys in memory
privateKeyRef.current = privateKey;
publicKeyRef.current = publicKey;
// 3. Check if key is already registered on-chain
const existingKey = await getEncryptionKey(peopleApi, selectedAccount.address);
// 4. If key already matches, just unlock
const alreadyRegistered = existingKey !== null &&
existingKey.length === publicKey.length &&
existingKey.every((b, i) => b === publicKey[i]);
@@ -102,7 +122,7 @@ export function useMessaging() {
return;
}
// 4. Register key on-chain
// 5. Register key on-chain
const tx = buildRegisterKeyTx(peopleApi, publicKey);
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
@@ -163,6 +183,7 @@ export function useMessaging() {
// Refresh inbox from chain
const refreshInbox = useCallback(async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try {
const era = await getCurrentEra(peopleApi);
@@ -214,6 +235,10 @@ export function useMessaging() {
toast.error('Wallet not connected');
return;
}
if (!isPalletAvailable(peopleApi)) {
toast.error('Messaging pallet is not available on this chain');
return;
}
setState(prev => ({ ...prev, sending: true }));
@@ -267,6 +292,7 @@ export function useMessaging() {
// Acknowledge messages (optional, feeless)
const acknowledge = useCallback(async () => {
if (!peopleApi || !selectedAccount) return;
if (!isPalletAvailable(peopleApi)) return;
try {
const tx = buildAcknowledgeTx(peopleApi);
@@ -284,14 +310,21 @@ export function useMessaging() {
setState(prev => ({ ...prev, loading: true }));
const init = async () => {
await checkKeyRegistration();
await refreshInbox();
const available = checkPalletAvailability();
if (available) {
await checkKeyRegistration();
await refreshInbox();
}
setState(prev => ({ ...prev, loading: false }));
};
init();
// Poll every 12 seconds (1 block interval)
pollIntervalRef.current = setInterval(refreshInbox, 12000);
// Poll every 12 seconds (1 block interval) - only if pallet exists
pollIntervalRef.current = setInterval(() => {
if (isPalletAvailable(peopleApi)) {
refreshInbox();
}
}, 12000);
return () => {
if (pollIntervalRef.current) {
@@ -299,7 +332,7 @@ export function useMessaging() {
pollIntervalRef.current = null;
}
};
}, [peopleApi, isPeopleReady, selectedAccount, checkKeyRegistration, refreshInbox]);
}, [peopleApi, isPeopleReady, selectedAccount, checkPalletAvailability, checkKeyRegistration, refreshInbox]);
// Clear private key when account changes
useEffect(() => {
+25 -4
View File
@@ -9,6 +9,19 @@ export interface EncryptedMessage {
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 ---
export async function getEncryptionKey(
@@ -16,7 +29,9 @@ export async function getEncryptionKey(
address: string
): Promise<Uint8Array | null> {
// 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;
const hex = result.unwrap().toHex();
return hexToBytes(hex);
@@ -24,7 +39,9 @@ export async function getEncryptionKey(
export async function getCurrentEra(api: ApiPromise): Promise<number> {
// 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();
}
@@ -34,7 +51,9 @@ export async function getInbox(
address: string
): Promise<EncryptedMessage[]> {
// 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 [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -55,7 +74,9 @@ export async function getSendCount(
address: string
): Promise<number> {
// 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();
}
+28
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useMessaging } from '@/hooks/useMessaging';
@@ -14,12 +15,15 @@ import {
Loader2,
Wallet,
Inbox,
AlertTriangle,
} from 'lucide-react';
export default function Messaging() {
const navigate = useNavigate();
const { t } = useTranslation();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const {
palletReady,
isKeyRegistered,
isKeyUnlocked,
decryptedMessages,
@@ -130,7 +134,20 @@ export default function Messaging() {
</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 */}
{palletReady && (
<div className="mb-4">
<KeySetup
isKeyRegistered={isKeyRegistered}
@@ -140,6 +157,7 @@ export default function Messaging() {
onUnlockKey={unlockKey}
/>
</div>
)}
{/* Era / Stats bar */}
<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}
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>
);
}