mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 03:17:56 +00:00
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
import { useWallet } from '@/contexts/WalletContext';
|
|
import { deriveKeypair, encryptMessage, decryptMessage } from '@/lib/messaging/crypto';
|
|
import {
|
|
isPalletAvailable,
|
|
getEncryptionKey,
|
|
getCurrentEra,
|
|
getInbox,
|
|
getSendCount,
|
|
buildRegisterKeyTx,
|
|
buildSendMessageTx,
|
|
buildAcknowledgeTx,
|
|
type EncryptedMessage,
|
|
} from '@/lib/messaging/chain';
|
|
import { getSigner } from '@/lib/get-signer';
|
|
import { toast } from 'sonner';
|
|
|
|
export interface DecryptedMessage {
|
|
sender: string;
|
|
blockNumber: number;
|
|
plaintext: string | null; // null if decryption failed
|
|
raw: EncryptedMessage;
|
|
}
|
|
|
|
interface MessagingState {
|
|
palletReady: boolean;
|
|
isKeyRegistered: boolean;
|
|
isKeyUnlocked: boolean;
|
|
inbox: EncryptedMessage[];
|
|
decryptedMessages: DecryptedMessage[];
|
|
era: number;
|
|
sendCount: number;
|
|
loading: boolean;
|
|
sending: boolean;
|
|
registering: boolean;
|
|
}
|
|
|
|
export function useMessaging() {
|
|
const { peopleApi, isPeopleReady, selectedAccount, walletSource } = usePezkuwi();
|
|
const { signMessage } = useWallet();
|
|
|
|
const [state, setState] = useState<MessagingState>({
|
|
palletReady: false,
|
|
isKeyRegistered: false,
|
|
isKeyUnlocked: false,
|
|
inbox: [],
|
|
decryptedMessages: [],
|
|
era: 0,
|
|
sendCount: 0,
|
|
loading: false,
|
|
sending: false,
|
|
registering: false,
|
|
});
|
|
|
|
// Private key stored only in memory (cleared on unmount/page close)
|
|
const privateKeyRef = useRef<Uint8Array | null>(null);
|
|
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);
|
|
setState(prev => ({ ...prev, isKeyRegistered: key !== null }));
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Failed to check encryption key:', err);
|
|
}
|
|
}, [peopleApi, isPeopleReady, selectedAccount]);
|
|
|
|
// Derive encryption keys from wallet signature and register on-chain
|
|
const setupKey = useCallback(async () => {
|
|
if (!peopleApi || !isPeopleReady || !selectedAccount) {
|
|
toast.error('Wallet not connected');
|
|
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. 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);
|
|
|
|
// 3. Store keys in memory
|
|
privateKeyRef.current = privateKey;
|
|
publicKeyRef.current = publicKey;
|
|
|
|
// 4. If key already matches, just unlock
|
|
const alreadyRegistered = existingKey !== null &&
|
|
existingKey.length === publicKey.length &&
|
|
existingKey.every((b, i) => b === publicKey[i]);
|
|
|
|
if (alreadyRegistered) {
|
|
setState(prev => ({
|
|
...prev,
|
|
isKeyRegistered: true,
|
|
isKeyUnlocked: true,
|
|
registering: false,
|
|
}));
|
|
toast.success('Encryption key unlocked');
|
|
return;
|
|
}
|
|
|
|
// 5. Register key on-chain
|
|
const tx = buildRegisterKeyTx(peopleApi, publicKey);
|
|
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tx.signAndSend(
|
|
selectedAccount.address,
|
|
{ signer: injector.signer },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
({ status, dispatchError }: { status: any; dispatchError?: any }) => {
|
|
if (status.isFinalized) {
|
|
if (dispatchError) {
|
|
let errorMsg = 'Transaction failed';
|
|
if (dispatchError.isModule) {
|
|
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
|
|
errorMsg = `${decoded.section}.${decoded.name}`;
|
|
}
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
}
|
|
).catch(reject);
|
|
});
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
isKeyRegistered: true,
|
|
isKeyUnlocked: true,
|
|
registering: false,
|
|
}));
|
|
toast.success('Encryption key registered');
|
|
} 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]);
|
|
|
|
// Unlock existing key (re-derive from signature without registering)
|
|
const unlockKey = useCallback(async () => {
|
|
if (!peopleApi || !selectedAccount) return;
|
|
|
|
setState(prev => ({ ...prev, registering: true }));
|
|
try {
|
|
const signature = await signMessage('PEZMessage:v1');
|
|
const { publicKey, privateKey } = deriveKeypair(signature);
|
|
privateKeyRef.current = privateKey;
|
|
publicKeyRef.current = publicKey;
|
|
setState(prev => ({ ...prev, isKeyUnlocked: true, registering: false }));
|
|
toast.success('Encryption key unlocked');
|
|
} 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);
|
|
const dbg = `eph:${msg.ephemeralPublicKey?.length} n:${msg.nonce?.length} ct:${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
|
|
const sendEncryptedMessage = useCallback(async (recipient: string, text: string) => {
|
|
if (!peopleApi || !isPeopleReady || !selectedAccount) {
|
|
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 }));
|
|
|
|
try {
|
|
// 1. Get recipient's public key
|
|
const recipientPubKey = await getEncryptionKey(peopleApi, recipient);
|
|
if (!recipientPubKey) {
|
|
throw new Error('Recipient has no encryption key registered');
|
|
}
|
|
|
|
// 2. Encrypt
|
|
const { ephemeralPublicKey, nonce, ciphertext } = encryptMessage(recipientPubKey, text);
|
|
|
|
// 3. Build and send TX
|
|
const tx = buildSendMessageTx(peopleApi, recipient, ephemeralPublicKey, nonce, ciphertext);
|
|
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tx.signAndSend(
|
|
selectedAccount.address,
|
|
{ signer: injector.signer },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
({ status, dispatchError }: { status: any; dispatchError?: any }) => {
|
|
if (status.isFinalized) {
|
|
if (dispatchError) {
|
|
let errorMsg = 'Transaction failed';
|
|
if (dispatchError.isModule) {
|
|
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
|
|
errorMsg = `${decoded.section}.${decoded.name}`;
|
|
}
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
}
|
|
).catch(reject);
|
|
});
|
|
|
|
toast.success('Message sent');
|
|
setState(prev => ({ ...prev, sending: false }));
|
|
// Refresh inbox to show updated send count
|
|
await refreshInbox();
|
|
} catch (err) {
|
|
setState(prev => ({ ...prev, sending: false }));
|
|
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
|
toast.error(msg);
|
|
}
|
|
}, [peopleApi, isPeopleReady, selectedAccount, walletSource, refreshInbox]);
|
|
|
|
// Acknowledge messages (optional, feeless)
|
|
const acknowledge = useCallback(async () => {
|
|
if (!peopleApi || !selectedAccount) return;
|
|
if (!isPalletAvailable(peopleApi)) return;
|
|
|
|
try {
|
|
const tx = buildAcknowledgeTx(peopleApi);
|
|
const injector = await getSigner(selectedAccount.address, walletSource, peopleApi);
|
|
await tx.signAndSend(selectedAccount.address, { signer: injector.signer });
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Failed to acknowledge:', err);
|
|
}
|
|
}, [peopleApi, selectedAccount, walletSource]);
|
|
|
|
// Initial load + polling
|
|
useEffect(() => {
|
|
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
|
|
|
|
setState(prev => ({ ...prev, loading: true }));
|
|
|
|
const init = async () => {
|
|
const available = checkPalletAvailability();
|
|
if (available) {
|
|
await checkKeyRegistration();
|
|
await refreshInbox();
|
|
}
|
|
setState(prev => ({ ...prev, loading: false }));
|
|
};
|
|
init();
|
|
|
|
// Poll every 12 seconds (1 block interval) - only if pallet exists
|
|
pollIntervalRef.current = setInterval(() => {
|
|
if (isPalletAvailable(peopleApi)) {
|
|
refreshInbox();
|
|
}
|
|
}, 12000);
|
|
|
|
return () => {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
};
|
|
}, [peopleApi, isPeopleReady, selectedAccount, checkPalletAvailability, checkKeyRegistration, refreshInbox]);
|
|
|
|
// Clear private key when account changes
|
|
useEffect(() => {
|
|
privateKeyRef.current = null;
|
|
publicKeyRef.current = null;
|
|
setState(prev => ({ ...prev, isKeyUnlocked: false }));
|
|
}, [selectedAccount?.address]);
|
|
|
|
return {
|
|
...state,
|
|
setupKey,
|
|
unlockKey,
|
|
sendMessage: sendEncryptedMessage,
|
|
refreshInbox,
|
|
acknowledge,
|
|
};
|
|
}
|