import { SigningCyberClient } from '@cybercongress/cyber-js';
import { OfflineSigner } from '@cybercongress/cyber-js/build/signingcyberclient';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { CHAIN_ID, RPC_URL } from 'src/constants/config';
import defaultNetworks, { getHealthyRpcUrl } from 'src/constants/defaultNetworks';
import { addAddressPocket, setDefaultAccount } from 'src/redux/features/pocket';
import { useAppDispatch, useAppSelector } from 'src/redux/hooks';
import { Option } from 'src/types';
import { Networks } from 'src/types/networks';
import { decryptMnemonic, encryptMnemonic, getTauriDeviceKey } from 'src/utils/mnemonicCrypto';
import {
connectLedger as connectLedgerDevice,
ReconnectingLedgerSigner,
createLedgerSigner,
closeTransport,
checkTransportHealth,
} from 'src/utils/ledgerSigner';
import networkListIbc from 'src/utils/networkListIbc';
import { getOfflineSigner as getOfflineSignerFromMnemonic } from 'src/utils/offlineSigner';
import { getEncryptedMnemonic, setEncryptedMnemonic } from 'src/utils/utils';
const MNEMONIC_AUTO_CLEAR_MS = 15 * 60 * 1000; // 15 minutes
type SignerClientContextType = {
readonly signingClient: Option<SigningCyberClient>;
readonly signer: Option<OfflineSigner>;
readonly signerReady: boolean;
readonly isLedgerAccount: boolean;
readonly getSignClientByChainId: (
chainId: Networks.BOSTROM | Networks.SPACE_PUSSY
) => Promise<Option<SigningCyberClient>>;
setSigner: (signer: Option<OfflineSigner>) => void;
activateWalletSigner: (signer: OfflineSigner, mnemonic: string) => void;
unlockWallet: (password: string) => Promise<void>;
connectLedger: () => Promise<{ address: string; pubkey: Uint8Array }>;
reconnectLedger: () => Promise<void>;
getSignerForChain: (chainId: string) => Promise<Option<OfflineSigner>>;
};
async function createClient(signer: OfflineSigner): Promise<SigningCyberClient> {
const rpcUrl = await getHealthyRpcUrl(CHAIN_ID, RPC_URL);
const client = await SigningCyberClient.connectWithSigner(rpcUrl, signer);
return client;
}
export const SignerClientContext = React.createContext<SignerClientContextType>({
signer: undefined,
signingClient: undefined,
signerReady: false,
isLedgerAccount: false,
setSigner: () => {},
activateWalletSigner: () => {},
unlockWallet: async () => {},
connectLedger: async () => ({ address: '', pubkey: new Uint8Array() }),
reconnectLedger: async () => {},
getSignerForChain: async () => undefined,
getSignClientByChainId: async () => undefined,
});
export function useSigningClient() {
return useContext(SignerClientContext);
}
function SigningClientProvider({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const { defaultAccount } = useAppSelector((state) => state.pocket);
const [signer, setSigner] = useState<SignerClientContextType['signer']>();
const [signerReady, setSignerReady] = useState(false);
const [signingClient, setSigningClient] = useState<SignerClientContextType['signingClient']>();
const mnemonicRef = useRef<string | null>(null);
const mnemonicTimerRef = useRef<ReturnType<typeof setTimeout>>();
const isWalletAccount = defaultAccount.account?.cyber?.keys === 'wallet';
const isLedgerAccount = defaultAccount.account?.cyber?.keys === 'ledger';
useEffect(() => {
(async () => {
const address = signer ? (await signer.getAccounts())[0].address : undefined;
setSignerReady(
Boolean(address) &&
Boolean(defaultAccount.account) &&
address === defaultAccount.account?.cyber.bech32
);
})();
}, [defaultAccount, signer]);
// Rebuild signingClient whenever signer changes
useEffect(() => {
if (signer) {
createClient(signer)
.then(setSigningClient)
.catch((err) => {
console.error('Failed to create signing client:', err);
setSigningClient(undefined);
});
} else {
setSigningClient(undefined);
}
}, [signer]);
// Ledger disconnect detection on page refresh
useEffect(() => {
if (isLedgerAccount && !signer) {
window.dispatchEvent(new CustomEvent('__cyb_ledger_disconnected'));
}
}, [isLedgerAccount, signer]);
// Tauri or web without Keplr: auto-generate mnemonic on first launch,
// restore saved mnemonic on subsequent launches.
// addAddressPocket deduplicates โ won't re-register or override default account.
useEffect(() => {
(async () => {
if (!process.env.IS_TAURI) return;
try {
let mnemonic: string | null = null;
let walletSource = 'existing';
let accountName = 'Account 1';
// Check bootstrap.json FIRST โ it's an explicit user action (download from web)
// and should override any existing wallet
if (process.env.IS_TAURI) {
try {
const { invoke } = await import('@tauri-apps/api/core');
const bootstrap = await invoke('read_bootstrap') as { mnemonic?: string; referrer?: string; name?: string } | null;
if (bootstrap?.mnemonic) {
mnemonic = bootstrap.mnemonic;
walletSource = 'cyb-boot';
if (bootstrap.name) {
accountName = bootstrap.name;
}
if (bootstrap.referrer) {
const { saveReferrer } = await import('src/pages/Mining/components/ReferralSection');
saveReferrer(bootstrap.referrer);
}
}
} catch (err) {
console.log('[Bootstrap] No bootstrap.json found (normal first launch):', err);
}
}
if (!mnemonic) {
const deviceKey = getTauriDeviceKey();
// Migrate legacy plaintext mnemonic (old Tauri key without address suffix)
const legacyKey = 'cyb:mnemonic';
const legacyMnemonic = localStorage.getItem(legacyKey);
if (legacyMnemonic && legacyMnemonic.split(' ').length >= 12) {
mnemonic = legacyMnemonic;
walletSource = 'migrated';
localStorage.removeItem(legacyKey);
console.log('[Bootstrap] Migrated legacy plaintext mnemonic');
}
// Try to restore from encrypted storage
if (!mnemonic) {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('cyb:mnemonic:')) {
const encrypted = localStorage.getItem(key);
if (encrypted) {
try {
mnemonic = await decryptMnemonic(encrypted, deviceKey);
console.log('[Bootstrap] Restored encrypted wallet from storage');
break;
} catch {
// Not decryptable with device key, skip
}
}
}
}
}
}
if (!mnemonic) {
const { generateMnemonic } = await import('src/utils/offlineSigner');
mnemonic = await generateMnemonic();
walletSource = 'generated';
console.log('[Bootstrap] Auto-generated new wallet');
}
const mnemonicSigner = await getOfflineSignerFromMnemonic(mnemonic);
const mnemonicAccounts = await mnemonicSigner.getAccounts();
const { address } = mnemonicAccounts[0];
const pk = Buffer.from(mnemonicAccounts[0].pubkey).toString('hex');
console.log(`[Bootstrap] Wallet active: ${address} (source: ${walletSource}, name: ${accountName})`);
// Store mnemonic encrypted with device key
const deviceKey = getTauriDeviceKey();
const encrypted = await encryptMnemonic(mnemonic, deviceKey);
setEncryptedMnemonic(encrypted, address);
// Register account (deduplicates if already exists)
dispatch(
addAddressPocket({
bech32: address,
keys: 'wallet',
pk,
name: accountName,
})
);
// Force set as active โ addAddressPocket skips this if initPocket already ran
if (walletSource === 'cyb-boot' || walletSource === 'generated') {
console.log(`[Bootstrap] Setting ${address} as default account (${accountName})`);
dispatch(
setDefaultAccount({
name: accountName,
account: { cyber: { bech32: address, keys: 'wallet', pk, name: accountName } },
})
);
}
const clientJs = await createClient(mnemonicSigner);
setSigner(mnemonicSigner);
setSigningClient(clientJs);
setSignerReady(true);
console.log(`[Bootstrap] Signing client ready (${walletSource})`);
} catch (e) {
console.error('[Bootstrap] Failed to init signer client:', e);
}
})();
}, []);
// Auto-switch signer when defaultAccount changes to a local wallet
useEffect(() => {
(async () => {
const keys = defaultAccount.account?.cyber?.keys;
const bech32 = defaultAccount.account?.cyber?.bech32;
if (keys !== 'wallet' || !bech32) return;
// On web, auto-switch only works if wallet is already unlocked (mnemonicRef)
// On Tauri, decrypt with device key
let mnemonic: string | null = mnemonicRef.current;
if (!mnemonic && process.env.IS_TAURI) {
const encrypted = getEncryptedMnemonic(bech32);
if (encrypted) {
try {
mnemonic = await decryptMnemonic(encrypted, getTauriDeviceKey());
} catch {
console.warn('[Signer] Failed to decrypt mnemonic for account switch');
return;
}
}
}
if (!mnemonic) return;
try {
const localSigner = await getOfflineSignerFromMnemonic(mnemonic);
const [account] = await localSigner.getAccounts();
if (account.address !== bech32) {
console.warn('[Signer] Mnemonic derives different address, skipping');
return;
}
const clientJs = await createClient(localSigner);
setSigner(localSigner);
setSigningClient(clientJs);
console.log('[Signer] Switched to local account:', bech32);
} catch (e) {
console.error('[Signer] Failed to switch to local account:', e);
}
})();
}, [defaultAccount]);
const getSignClientByChainId = useCallback(
async (chainId: Networks.BOSTROM | Networks.SPACE_PUSSY) => {
let offlineSigner: Option<OfflineSigner>;
if (isLedgerAccount) {
const { BECH32_PREFIX: prefix } = defaultNetworks[chainId];
offlineSigner = await createLedgerSigner(prefix);
} else if (isWalletAccount && mnemonicRef.current) {
offlineSigner = await getOfflineSignerFromMnemonic(mnemonicRef.current, chainId);
}
if (!offlineSigner) {
return undefined;
}
const { RPC_URL: _RPC_URL } = defaultNetworks[chainId];
const rpcUrl = await getHealthyRpcUrl(chainId, _RPC_URL);
return SigningCyberClient.connectWithSigner(rpcUrl, offlineSigner);
},
[isWalletAccount, isLedgerAccount]
);
const setMnemonicWithAutoClear = useCallback((mnemonic: string | null) => {
if (mnemonicTimerRef.current) {
clearTimeout(mnemonicTimerRef.current);
}
mnemonicRef.current = mnemonic;
}, []);
// Clear mnemonic on unmount
useEffect(() => {
return () => {
mnemonicRef.current = null;
if (mnemonicTimerRef.current) clearTimeout(mnemonicTimerRef.current);
};
}, []);
// Auto-lock disabled โ wallet stays unlocked until device locks
const activateWalletSigner = useCallback(
(offlineSigner: OfflineSigner, mnemonic: string) => {
setMnemonicWithAutoClear(mnemonic);
setSigner(offlineSigner);
},
[setMnemonicWithAutoClear]
);
const unlockWallet = useCallback(
async (password: string) => {
const address = defaultAccount.account?.cyber.bech32;
if (!address) throw new Error('Select an account in Keys before signing');
const encrypted = getEncryptedMnemonic(address);
if (!encrypted) throw new Error('Wallet data not found. Re-import your seed phrase');
const mnemonic = await decryptMnemonic(encrypted, password);
const offlineSigner = await getOfflineSignerFromMnemonic(mnemonic);
// Verify decrypted mnemonic derives to the expected address
const [account] = await offlineSigner.getAccounts();
if (account.address !== address) {
throw new Error('Seed phrase does not match this account. Check your backup');
}
setMnemonicWithAutoClear(mnemonic);
setSigner(offlineSigner);
},
[defaultAccount, setMnemonicWithAutoClear]
);
// Connect Ledger โ requires user gesture (WebUSB)
const connectLedgerFn = useCallback(async () => {
const { signer: ledgerSigner, address, pubkey } = await connectLedgerDevice();
setSigner(ledgerSigner);
return { address, pubkey };
}, []);
// Reconnect Ledger โ for when signer was lost (page refresh / device sleep)
const reconnectLedger = useCallback(async () => {
if (!isLedgerAccount) return;
// Validate device first with a raw signer
const rawSigner = await createLedgerSigner();
const expectedAddress = defaultAccount.account?.cyber?.bech32;
if (expectedAddress) {
const [account] = await rawSigner.getAccounts();
if (account.address !== expectedAddress) {
throw new Error(
'This Ledger has a different address. Is it the correct device?'
);
}
}
// Use ReconnectingLedgerSigner for long-lived use โ survives sleep
const [account] = await rawSigner.getAccounts();
const reconnectingSigner = new ReconnectingLedgerSigner('bostrom', [account]);
setSigner(reconnectingSigner);
}, [isLedgerAccount, defaultAccount]);
// Ledger health monitoring โ detect sleep / disconnect
useEffect(() => {
if (!isLedgerAccount || !signer) return;
const HEALTH_INTERVAL_MS = 30_000; // 30 seconds
const check = async () => {
const healthy = await checkTransportHealth();
if (!healthy) {
setSigner(undefined);
window.dispatchEvent(new CustomEvent('__cyb_ledger_disconnected'));
}
};
const interval = setInterval(check, HEALTH_INTERVAL_MS);
return () => clearInterval(interval);
}, [isLedgerAccount, signer]);
// Close transport on unmount and on page unload
useEffect(() => {
const handleBeforeUnload = () => {
closeTransport();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
closeTransport();
};
}, []);
const getSignerForChain = useCallback(
async (chainId: string): Promise<Option<OfflineSigner>> => {
if (isLedgerAccount) {
const network = networkListIbc[chainId];
const prefix = network?.prefix;
if (prefix) {
return createLedgerSigner(prefix);
}
return undefined;
}
if (mnemonicRef.current) {
return getOfflineSignerFromMnemonic(mnemonicRef.current, chainId);
}
return undefined;
},
[isLedgerAccount]
);
const value = useMemo(
() => ({
signer,
setSigner,
activateWalletSigner,
signingClient,
signerReady,
isLedgerAccount,
unlockWallet,
connectLedger: connectLedgerFn,
reconnectLedger,
getSignerForChain,
getSignClientByChainId,
}),
[signer, signingClient, signerReady, isLedgerAccount, setSigner, activateWalletSigner, unlockWallet, connectLedgerFn, reconnectLedger, getSignerForChain, getSignClientByChainId]
);
return <SignerClientContext.Provider value={value}>{children}</SignerClientContext.Provider>;
}
export default SigningClientProvider;