From d1793e020fb2c7862109521df3404112c18513dc Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Tue, 13 Jan 2026 16:25:26 +0700 Subject: [PATCH 1/6] fix(tangle-dapp): add substrate wallet selection modal for claim migration - Add SubstrateWalletModal for selecting between Polkadot.js, Talisman, and SubWallet - Update SubstrateWalletSelector to use the new modal with localStorage persistence - Store extension reference for signing without re-fetching - Add wallet icons (PolkadotJsIcon, SubWalletIcon) - Filter substrate wallets from EVM wallet modal Co-Authored-By: Claude Opus 4.5 --- .../components/SubstrateWalletModal.tsx | 258 +++++++++++++++ .../components/SubstrateWalletSelector.tsx | 302 +++++++++++++++--- .../src/pages/claim/migration/index.tsx | 26 +- libs/icons/src/wallets/PolkadotJsIcon.tsx | 34 ++ libs/icons/src/wallets/SubWalletIcon.tsx | 183 +++++++++++ libs/icons/src/wallets/TalismanIcon.tsx | 38 +-- libs/icons/src/wallets/index.ts | 2 + .../EvmWalletModal/EvmWalletModal.tsx | 10 + 8 files changed, 778 insertions(+), 75 deletions(-) create mode 100644 apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx create mode 100644 libs/icons/src/wallets/PolkadotJsIcon.tsx create mode 100644 libs/icons/src/wallets/SubWalletIcon.tsx diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx new file mode 100644 index 0000000000..41da6f475f --- /dev/null +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx @@ -0,0 +1,258 @@ +import type { + InjectedAccount, + InjectedExtension, + Unsubcall, +} from '@polkadot/extension-inject/types'; +import Spinner from '@tangle-network/icons/Spinner'; +import { + PolkadotJsIcon, + SubWalletIcon, + TalismanIcon, +} from '@tangle-network/icons/wallets'; +import { + Modal, + ModalContent, + ModalHeader, +} from '@tangle-network/ui-components/components/Modal'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { type FC, useCallback, useEffect, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +const APP_NAME = 'Tangle dApp'; + +/** + * Ensures extension has accounts.subscribe method + * Matches develop branch's ensureAccountsSubscribe + */ +function ensureAccountsSubscribe( + extension: InjectedExtension, +): InjectedExtension { + if (!extension.accounts.subscribe) { + return { + ...extension, + accounts: { + ...extension.accounts, + subscribe: ( + cb: (accounts: InjectedAccount[]) => void | Promise, + ): Unsubcall => { + extension.accounts.get().then(cb).catch(console.error); + return () => {}; + }, + }, + }; + } + return extension; +} + +/** + * Find and connect to a substrate wallet + */ +async function findSubstrateWallet( + walletName: string, +): Promise { + const extension = window.injectedWeb3?.[walletName]; + + if (extension === undefined) { + throw new Error(`${walletName} is not installed`); + } + + if (extension.connect !== undefined) { + const ex = await extension.connect(APP_NAME); + return ensureAccountsSubscribe(ex); + } + + if (extension.enable !== undefined) { + const injected = await extension.enable(APP_NAME); + return ensureAccountsSubscribe({ + name: walletName, + version: extension.version || 'unknown', + ...injected, + }); + } + + throw new Error(`${walletName} does not support connect() or enable()`); +} + +const SUBSTRATE_WALLETS = [ + { + id: 'polkadot-js', + name: 'polkadot-js', + title: 'Polkadot{.js}', + Icon: PolkadotJsIcon, + }, + { + id: 'talisman', + name: 'talisman', + title: 'Talisman', + Icon: TalismanIcon, + }, + { + id: 'subwallet-js', + name: 'subwallet-js', + title: 'SubWallet', + Icon: SubWalletIcon, + }, +] as const; + +export type SubstrateWalletName = (typeof SUBSTRATE_WALLETS)[number]['name']; + +interface SubstrateWalletModalProps { + isOpen: boolean; + onClose: () => void; + onWalletConnect: ( + walletName: SubstrateWalletName, + accounts: InjectedAccount[], + extension: InjectedExtension, + ) => void; +} + +const SubstrateWalletModal: FC = ({ + isOpen, + onClose, + onWalletConnect, +}) => { + const [connectingWalletId, setConnectingWalletId] = useState( + null, + ); + const [error, setError] = useState(null); + const [installedWallets, setInstalledWallets] = useState>( + new Set(), + ); + + // Detect installed wallets when modal opens + useEffect(() => { + if (!isOpen) return; + + setError(null); + + const detected = new Set(); + for (const wallet of SUBSTRATE_WALLETS) { + const ext = window.injectedWeb3?.[wallet.name]; + if (ext && (ext.enable || ext.connect)) { + detected.add(wallet.id); + } + } + setInstalledWallets(detected); + }, [isOpen]); + + const handleConnect = useCallback( + async (wallet: (typeof SUBSTRATE_WALLETS)[number]) => { + setConnectingWalletId(wallet.id); + setError(null); + + try { + const extension = await findSubstrateWallet(wallet.name); + const accounts = await extension.accounts.get(); + + if (accounts.length === 0) { + throw new Error( + `No accounts found. Please authorize accounts for this site in ${wallet.title} settings.`, + ); + } + + onWalletConnect(wallet.name, accounts, extension); + onClose(); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to connect wallet'; + + if (message.toLowerCase().includes('rejected')) { + setError('Connection rejected. Please try again.'); + } else { + setError(message); + } + } finally { + setConnectingWalletId(null); + } + }, + [onWalletConnect, onClose], + ); + + return ( + !open && onClose()}> + + + Connect Substrate Wallet + + +
+
+ {SUBSTRATE_WALLETS.map((wallet) => { + const isInstalled = installedWallets.has(wallet.id); + const isConnecting = connectingWalletId === wallet.id; + const Icon = wallet.Icon; + + return ( + + ); + })} +
+ + {error && ( +
+ + {error} + +
+ )} + + {installedWallets.size === 0 && ( +
+ + No Substrate wallets detected. Please install Polkadot.js, + Talisman, or SubWallet. + +
+ )} +
+
+
+ ); +}; + +export default SubstrateWalletModal; diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx index 7999986195..b1fc1aae51 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx @@ -1,15 +1,98 @@ -import { type InjectedAccountWithMeta } from '@polkadot/extension-inject/types'; -import { WalletLineIcon, ChevronDown } from '@tangle-network/icons'; +import type { + InjectedAccount, + InjectedAccountWithMeta, + InjectedExtension, + Unsubcall, +} from '@polkadot/extension-inject/types'; +import { ChevronDown, WalletLineIcon } from '@tangle-network/icons'; +import { + PolkadotJsIcon, + SubWalletIcon, + TalismanIcon, +} from '@tangle-network/icons/wallets'; import { Avatar } from '@tangle-network/ui-components/components/Avatar'; import { Typography } from '@tangle-network/ui-components/typography/Typography'; import { shortenString } from '@tangle-network/ui-components/utils/shortenString'; -import { type FC, useCallback, useState } from 'react'; -import { twMerge } from 'tailwind-merge'; import { AnimatePresence, motion } from 'framer-motion'; +import { type FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import SubstrateWalletModal, { + type SubstrateWalletName, +} from './SubstrateWalletModal'; + +const APP_NAME = 'Tangle dApp'; +const STORAGE_KEY = 'tangle-migration-claim-substrate-wallet'; + +interface SavedConnection { + walletName: SubstrateWalletName; + accountAddress: string; +} + +const WALLET_INFO: Record< + SubstrateWalletName, + { title: string; Icon: FC<{ className?: string }> } +> = { + 'polkadot-js': { title: 'Polkadot{.js}', Icon: PolkadotJsIcon }, + talisman: { title: 'Talisman', Icon: TalismanIcon }, + 'subwallet-js': { title: 'SubWallet', Icon: SubWalletIcon }, +}; + +/** + * Ensures extension has accounts.subscribe method + */ +function ensureAccountsSubscribe( + extension: InjectedExtension, +): InjectedExtension { + if (!extension.accounts.subscribe) { + return { + ...extension, + accounts: { + ...extension.accounts, + subscribe: ( + cb: (accounts: InjectedAccount[]) => void | Promise, + ): Unsubcall => { + extension.accounts.get().then(cb).catch(console.error); + return () => {}; + }, + }, + }; + } + return extension; +} + +/** + * Find and connect to a substrate wallet + */ +async function findSubstrateWallet( + walletName: string, +): Promise { + const extension = window.injectedWeb3?.[walletName]; + + if (extension === undefined) { + throw new Error(`${walletName} is not installed`); + } + + if (extension.connect !== undefined) { + const ex = await extension.connect(APP_NAME); + return ensureAccountsSubscribe(ex); + } + + if (extension.enable !== undefined) { + const injected = await extension.enable(APP_NAME); + return ensureAccountsSubscribe({ + name: walletName, + version: extension.version || 'unknown', + ...injected, + }); + } + + throw new Error(`${walletName} does not support connect() or enable()`); +} interface Props { selectedAccount: InjectedAccountWithMeta | null; onAccountSelect: (account: InjectedAccountWithMeta | null) => void; + onExtensionChange?: (extension: InjectedExtension | null) => void; disabled?: boolean; className?: string; } @@ -17,69 +100,163 @@ interface Props { const SubstrateWalletSelector: FC = ({ selectedAccount, onAccountSelect, + onExtensionChange, disabled = false, className, }) => { const [accounts, setAccounts] = useState([]); - const [isConnecting, setIsConnecting] = useState(false); const [isConnected, setIsConnected] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [error, setError] = useState(null); + const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); + const [connectedWalletName, setConnectedWalletName] = + useState(null); + const [isReconnecting, setIsReconnecting] = useState(false); - const connectExtension = useCallback(async () => { - setIsConnecting(true); - setError(null); + const walletInfo = useMemo(() => { + if (!connectedWalletName) return null; + return WALLET_INFO[connectedWalletName]; + }, [connectedWalletName]); - try { - const { web3Enable, web3Accounts } = await import( - '@polkadot/extension-dapp' + // Convert InjectedAccount to InjectedAccountWithMeta + const convertAccounts = useCallback( + (rawAccounts: InjectedAccount[], source: string): InjectedAccountWithMeta[] => { + return rawAccounts.map((account) => ({ + address: account.address, + meta: { + name: account.name, + source, + genesisHash: account.genesisHash, + }, + type: account.type, + })); + }, + [], + ); + + // localStorage helpers + const saveConnection = useCallback( + (walletName: SubstrateWalletName, accountAddress: string) => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ walletName, accountAddress }), ); + }, + [], + ); - const extensions = await web3Enable('Tangle Migration Claim'); + const clearConnection = useCallback(() => { + localStorage.removeItem(STORAGE_KEY); + }, []); - if (extensions.length === 0) { - throw new Error( - 'No Polkadot wallet found. Please install Polkadot.js, Talisman, or SubWallet.', - ); + const loadSavedConnection = useCallback((): SavedConnection | null => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? JSON.parse(saved) : null; + } catch { + return null; + } + }, []); + + // Try to reconnect to saved wallet on mount + useEffect(() => { + const reconnect = async () => { + const saved = loadSavedConnection(); + if (!saved) return; + + // Check if wallet is still installed + if (!window.injectedWeb3?.[saved.walletName]) { + clearConnection(); + return; } - const allAccounts = await web3Accounts(); + setIsReconnecting(true); + + try { + const extension = await findSubstrateWallet(saved.walletName); + const rawAccounts = await extension.accounts.get(); + + if (rawAccounts.length === 0) { + clearConnection(); + return; + } + + const convertedAccounts = convertAccounts(rawAccounts, saved.walletName); + setAccounts(convertedAccounts); + setConnectedWalletName(saved.walletName); + setIsConnected(true); + onExtensionChange?.(extension); - if (allAccounts.length === 0) { - throw new Error( - 'No accounts found. Please create or import an account in your wallet.', + // Restore previously selected account + const savedAccount = convertedAccounts.find( + (acc) => acc.address === saved.accountAddress, ); + if (savedAccount) { + onAccountSelect(savedAccount); + } + } catch (err) { + console.error('Failed to reconnect wallet:', err); + clearConnection(); + } finally { + setIsReconnecting(false); } + }; - setAccounts(allAccounts); + reconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle wallet connection from modal + const handleWalletConnect = useCallback( + ( + walletName: SubstrateWalletName, + rawAccounts: InjectedAccount[], + extension: InjectedExtension, + ) => { + const convertedAccounts = convertAccounts(rawAccounts, walletName); + setAccounts(convertedAccounts); + setConnectedWalletName(walletName); setIsConnected(true); + setError(null); + onExtensionChange?.(extension); - if (allAccounts.length === 1) { - onAccountSelect(allAccounts[0]); + // Auto-select if only one account + if (convertedAccounts.length === 1) { + onAccountSelect(convertedAccounts[0]); + saveConnection(walletName, convertedAccounts[0].address); } - } catch (err) { - console.error('Failed to connect extension:', err); - setError(err instanceof Error ? err.message : 'Failed to connect wallet'); - setIsConnected(false); - } finally { - setIsConnecting(false); - } - }, [onAccountSelect]); + }, + [convertAccounts, onAccountSelect, onExtensionChange, saveConnection], + ); + // Handle account selection const handleAccountClick = useCallback( (account: InjectedAccountWithMeta) => { onAccountSelect(account); setIsDropdownOpen(false); + if (connectedWalletName) { + saveConnection(connectedWalletName, account.address); + } }, - [onAccountSelect], + [connectedWalletName, onAccountSelect, saveConnection], ); + // Handle disconnect const handleDisconnect = useCallback(() => { setIsConnected(false); setAccounts([]); + setConnectedWalletName(null); onAccountSelect(null); + onExtensionChange?.(null); + setIsDropdownOpen(false); + clearConnection(); + }, [clearConnection, onAccountSelect, onExtensionChange]); + + // Handle change wallet + const handleChangeWallet = useCallback(() => { setIsDropdownOpen(false); - }, [onAccountSelect]); + setIsWalletModalOpen(true); + }, []); // Not connected - show connect button if (!isConnected) { @@ -98,8 +275,8 @@ const SubstrateWalletSelector: FC = ({ )} + + setIsWalletModalOpen(false)} + onWalletConnect={handleWalletConnect} + /> ); } // Connected - show account selector + const WalletIcon = walletInfo?.Icon; + return (
) : ( <>
- + {WalletIcon ? ( + + ) : ( + + )}
= ({ ))} -
+
+
)} + + setIsWalletModalOpen(false)} + onWalletConnect={handleWalletConnect} + />
); }; diff --git a/apps/tangle-dapp/src/pages/claim/migration/index.tsx b/apps/tangle-dapp/src/pages/claim/migration/index.tsx index 5d1ffef97b..367d03cdd3 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/index.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/index.tsx @@ -19,7 +19,10 @@ import { type FC, useCallback, useState, useMemo, useEffect } from 'react'; import { useAccount, useChainId } from 'wagmi'; import { type Hex, formatUnits, isAddress, keccak256, toHex } from 'viem'; import { twMerge } from 'tailwind-merge'; -import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types'; +import type { + InjectedAccountWithMeta, + InjectedExtension, +} from '@polkadot/extension-inject/types'; import { AnimatePresence, motion } from 'framer-motion'; import parseTransactionError from '@tangle-network/tangle-shared-ui/utils/parseTransactionError'; import SubstrateWalletSelector from './components/SubstrateWalletSelector'; @@ -45,6 +48,8 @@ const MigrationClaimPage: FC = () => { // State const [substrateAccount, setSubstrateAccount] = useState(null); + const [substrateExtension, setSubstrateExtension] = + useState(null); const [signature, setSignature] = useState(null); const [currentStep, setCurrentStep] = useState( ClaimStep.CONNECT_WALLETS, @@ -134,21 +139,24 @@ const MigrationClaimPage: FC = () => { ); // Check if ready to proceed to signing - // Core requirements: eligibility loaded, user is eligible, hasn't claimed, has valid recipient + // Core requirements: eligibility loaded, user is eligible, hasn't claimed, has valid recipient, and extension ready const canSign = useMemo(() => { return ( substrateAccount && + substrateExtension && !isLoadingEligibility && eligibility.isEligible && !eligibility.hasClaimed && !eligibility.isPaused && validRecipient ); - }, [substrateAccount, isLoadingEligibility, eligibility, validRecipient]); + }, [substrateAccount, substrateExtension, isLoadingEligibility, eligibility, validRecipient]); // Handle signing the challenge const handleSignChallenge = useCallback(async () => { - if (!substrateAccount) return; + if (!substrateAccount || !substrateExtension) { + return; + } setCurrentStep(ClaimStep.SIGN_CHALLENGE); @@ -157,14 +165,11 @@ const MigrationClaimPage: FC = () => { const challengeToSign = challenge || keccak256(toHex('dev-mode-challenge')); - const { web3FromAddress } = await import('@polkadot/extension-dapp'); - const injector = await web3FromAddress(substrateAccount.address); - - if (!injector.signer.signRaw) { + if (!substrateExtension.signer?.signRaw) { throw new Error('Signer does not support raw signing'); } - const result = await injector.signer.signRaw({ + const result = await substrateExtension.signer.signRaw({ address: substrateAccount.address, data: challengeToSign, type: 'bytes', @@ -180,7 +185,7 @@ const MigrationClaimPage: FC = () => { console.error('Failed to sign challenge:', err); setCurrentStep(ClaimStep.CHECK_ELIGIBILITY); } - }, [challenge, substrateAccount]); + }, [challenge, substrateAccount, substrateExtension]); const handleCopySignature = useCallback(() => { if ( @@ -569,6 +574,7 @@ const MigrationClaimPage: FC = () => { = ClaimStep.SIGN_CHALLENGE} /> diff --git a/libs/icons/src/wallets/PolkadotJsIcon.tsx b/libs/icons/src/wallets/PolkadotJsIcon.tsx new file mode 100644 index 0000000000..c8a55942fa --- /dev/null +++ b/libs/icons/src/wallets/PolkadotJsIcon.tsx @@ -0,0 +1,34 @@ +import { createIcon } from '../create-icon'; +import { IconBase } from '../types'; + +export const PolkadotJsIcon = (props: IconBase) => { + return createIcon({ + ...props, + size: props.size ?? 'lg', // Override the default size to `lg` (24px) + displayName: 'PolkadotJsIcon', + path: ( + <> + + + + + + + + + ), + }); +}; \ No newline at end of file diff --git a/libs/icons/src/wallets/SubWalletIcon.tsx b/libs/icons/src/wallets/SubWalletIcon.tsx new file mode 100644 index 0000000000..467ffdfd82 --- /dev/null +++ b/libs/icons/src/wallets/SubWalletIcon.tsx @@ -0,0 +1,183 @@ +import { createIcon } from '../create-icon'; +import { IconBase } from '../types'; + +export const SubWalletIcon = (props: IconBase) => { + return createIcon({ + ...props, + size: props.size ?? 'lg', // Override the default size to `lg` (24px) + displayName: 'SubWalletIcon', + viewBox: '0 0 134 134', + path: ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }); +}; \ No newline at end of file diff --git a/libs/icons/src/wallets/TalismanIcon.tsx b/libs/icons/src/wallets/TalismanIcon.tsx index 1be99d200a..ddb74416e7 100644 --- a/libs/icons/src/wallets/TalismanIcon.tsx +++ b/libs/icons/src/wallets/TalismanIcon.tsx @@ -4,26 +4,28 @@ import { IconBase } from '../types'; export const TalismanIcon = (props: IconBase) => { return createIcon({ ...props, - size: props.size ?? 'lg', + size: props.size ?? 'lg', // Override the default size to `lg` (24px) displayName: 'TalismanIcon', path: ( - - - - - - - - + <> + + + + + + + + ), }); }; diff --git a/libs/icons/src/wallets/index.ts b/libs/icons/src/wallets/index.ts index 517c7934ec..c9db822c73 100644 --- a/libs/icons/src/wallets/index.ts +++ b/libs/icons/src/wallets/index.ts @@ -1,8 +1,10 @@ export * from './CoinbaseIcon'; export * from './KeplrIcon'; export * from './MetaMaskIcon'; +export * from './PolkadotJsIcon'; export * from './RainbowIcon'; export * from './SafeIcon'; +export * from './SubWalletIcon'; export * from './TalismanIcon'; export * from './TrustWalletIcon'; export * from './WalletConnectIcon'; diff --git a/libs/tangle-shared-ui/src/components/EvmWalletModal/EvmWalletModal.tsx b/libs/tangle-shared-ui/src/components/EvmWalletModal/EvmWalletModal.tsx index ea7d94129f..797871aebf 100644 --- a/libs/tangle-shared-ui/src/components/EvmWalletModal/EvmWalletModal.tsx +++ b/libs/tangle-shared-ui/src/components/EvmWalletModal/EvmWalletModal.tsx @@ -24,12 +24,22 @@ const EvmWalletModal: FC = ({ isOpen, onClose }) => { const [connectingId, setConnectingId] = useState(null); // Filter and deduplicate connectors + // Exclude Substrate-only wallets (they're only for the claim migration page) const availableConnectors = useMemo(() => { const seen = new Set(); + const substrateWalletNames = ['subwallet', 'talisman', 'polkadot']; + return connectors.filter((connector) => { // Skip duplicates by name if (seen.has(connector.name)) return false; seen.add(connector.name); + + // Skip Substrate wallets - they're only for claim migration page + const lowerName = connector.name.toLowerCase(); + if (substrateWalletNames.some((name) => lowerName.includes(name))) { + return false; + } + return true; }); }, [connectors]); From 430e4ac52d7d3d7b1b85063e6182f302dd5784ec Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Wed, 14 Jan 2026 16:13:34 +0700 Subject: [PATCH 2/6] feat(tangle-dapp): add unlock configuration display to claim migration Display unlock percentage and lock schedule on the claim page. Shows unlocked vs locked amounts with unlock date. Also adds explorer link for transaction hash in success state. Co-Authored-By: Claude Opus 4.5 --- .../migration/hooks/useClaimEligibility.ts | 54 ++++++- .../src/pages/claim/migration/index.tsx | 143 +++++++++++++++++- libs/dapp-config/src/contracts.ts | 2 +- 3 files changed, 193 insertions(+), 6 deletions(-) diff --git a/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts b/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts index f27ee9745a..0571314ba0 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts +++ b/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts @@ -807,6 +807,10 @@ export interface ClaimEligibility { formattedBalance: string | null; /** Whether claims are paused */ isPaused: boolean; + /** Percentage unlocked immediately (basis points, 1000 = 10%) */ + unlockedBps: number; + /** Timestamp when locked tokens become withdrawable */ + unlockTimestamp: bigint; } interface UseClaimEligibilityOptions { @@ -963,6 +967,40 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { enabled: !!migrationAddress, }); + // Get unlock configuration (percentage unlocked immediately) + const { data: unlockedBps, isLoading: isLoadingUnlockedBps } = + useQuery({ + queryKey: ['migration-unlocked-bps', migrationAddress], + queryFn: async () => { + if (!migrationAddress) return 10000; // Default 100% if not configured + const result = await publicClient.readContract({ + address: migrationAddress, + abi: TANGLE_MIGRATION_ABI, + functionName: 'unlockedBps', + }); + return Number(result); + }, + enabled: !!migrationAddress, + staleTime: Infinity, // Lock config doesn't change after first claim + }); + + // Get unlock timestamp (when locked tokens become withdrawable) + const { data: unlockTimestamp, isLoading: isLoadingUnlockTimestamp } = + useQuery({ + queryKey: ['migration-unlock-timestamp', migrationAddress], + queryFn: async () => { + if (!migrationAddress) return BigInt(0); + const result = await publicClient.readContract({ + address: migrationAddress, + abi: TANGLE_MIGRATION_ABI, + functionName: 'unlockTimestamp', + }); + return result as bigint; + }, + enabled: !!migrationAddress, + staleTime: Infinity, // Lock config doesn't change after first claim + }); + // Get merkle root for verification using viem directly const { data: merkleRoot } = useQuery({ queryKey: ['migration-merkle-root', migrationAddress], @@ -1002,6 +1040,10 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { const actualClaimedAmount = claimedAmount ?? BigInt(0); const hasClaimed = actualClaimedAmount > BigInt(0); + // Default lock config values + const actualUnlockedBps = unlockedBps ?? 10000; // 100% if not set + const actualUnlockTimestamp = unlockTimestamp ?? BigInt(0); + // In dev mode without proofs, provide mock eligibility for UI testing if (isDevMode && !claimData && ss58Address) { const mockAmount = BigInt('1000000000000000000000'); // 1000 TNT @@ -1015,6 +1057,8 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { timeRemaining: BigInt(365 * 24 * 60 * 60), // 1 year formattedBalance: '1,000', isPaused: false, + unlockedBps: 1000, // Mock 10% unlocked for dev mode + unlockTimestamp: BigInt(Math.floor(Date.now() / 1000) + 180 * 24 * 60 * 60), // 180 days from now }; } @@ -1029,6 +1073,8 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { timeRemaining, formattedBalance: null, isPaused: isPaused ?? false, + unlockedBps: actualUnlockedBps, + unlockTimestamp: actualUnlockTimestamp, }; } @@ -1044,6 +1090,8 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { timeRemaining, formattedBalance, isPaused: isPaused ?? false, + unlockedBps: actualUnlockedBps, + unlockTimestamp: actualUnlockTimestamp, }; }, [ claimData, @@ -1052,6 +1100,8 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { isPaused, isDevMode, ss58Address, + unlockedBps, + unlockTimestamp, ]); // In dev mode, don't wait for contract reads since they're disabled anyway @@ -1060,7 +1110,9 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { : isLoadingProofs || isLoadingClaimed || isLoadingDeadline || - isLoadingPaused; + isLoadingPaused || + isLoadingUnlockedBps || + isLoadingUnlockTimestamp; return { eligibility, diff --git a/apps/tangle-dapp/src/pages/claim/migration/index.tsx b/apps/tangle-dapp/src/pages/claim/migration/index.tsx index 367d03cdd3..6f192983a2 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/index.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/index.tsx @@ -3,6 +3,7 @@ import { CheckboxCircleFill, EditLine, Alert, + ExternalLinkLine, } from '@tangle-network/icons'; import { Card, CopyWithTooltip } from '@tangle-network/ui-components'; import Button from '@tangle-network/ui-components/components/buttons/Button'; @@ -15,8 +16,9 @@ import { } from '@tangle-network/ui-components/components/Tooltip'; import { Typography } from '@tangle-network/ui-components/typography/Typography'; import { EvmWalletModal } from '@tangle-network/tangle-shared-ui/components/EvmWalletModal'; +import { makeExplorerUrl } from '@tangle-network/api-provider-environment/transaction/utils/makeExplorerUrl'; import { type FC, useCallback, useState, useMemo, useEffect } from 'react'; -import { useAccount, useChainId } from 'wagmi'; +import { useAccount, useChainId, useConfig } from 'wagmi'; import { type Hex, formatUnits, isAddress, keccak256, toHex } from 'viem'; import { twMerge } from 'tailwind-merge'; import type { @@ -25,6 +27,7 @@ import type { } from '@polkadot/extension-inject/types'; import { AnimatePresence, motion } from 'framer-motion'; import parseTransactionError from '@tangle-network/tangle-shared-ui/utils/parseTransactionError'; +import formatTangleBalance from '../../../utils/formatTangleBalance'; import SubstrateWalletSelector from './components/SubstrateWalletSelector'; import useClaimEligibility, { generateChallenge, @@ -44,6 +47,7 @@ enum ClaimStep { const MigrationClaimPage: FC = () => { const { address: evmAddress, isConnected } = useAccount(); const chainId = useChainId(); + const config = useConfig(); // State const [substrateAccount, setSubstrateAccount] = @@ -103,6 +107,23 @@ const MigrationClaimPage: FC = () => { switchedToWalletMode, } = useSubmitClaim(); + // Generate explorer URL for the transaction based on connected chain + const explorerUrl = useMemo(() => { + if (!txHash) { + return null; + } + + // Get the chain's block explorer URL from wagmi config + const chain = config.chains.find((c) => c.id === chainId); + const baseUrl = chain?.blockExplorers?.default?.url; + + if (!baseUrl) { + return null; + } + + return makeExplorerUrl(baseUrl, txHash, 'tx', 'web3'); + }, [txHash, chainId, config.chains]); + // Validate recipient address const isRecipientValid = useMemo(() => { return recipientAddress && isAddress(recipientAddress); @@ -267,6 +288,15 @@ const MigrationClaimPage: FC = () => { ? formatUnits(eligibility.amount, 18) : '0'; + // Calculate unlocked and locked amounts for display + const unlockedAmount = eligibility.amount + ? (eligibility.amount * BigInt(eligibility.unlockedBps)) / BigInt(10000) + : BigInt(0); + const lockedAmount = eligibility.amount + ? eligibility.amount - unlockedAmount + : BigInt(0); + const hasLockSplit = eligibility.unlockedBps < 10000; + // Render claim success if (isConfirmed) { return ( @@ -288,13 +318,51 @@ const MigrationClaimPage: FC = () => { Claim Successful! - + Your TNT tokens have been claimed successfully. + + {/* Unlock breakdown in success state */} + {hasLockSplit && eligibility.amount !== null && ( +
+
+ + Sent to your wallet + + + {formatTangleBalance(unlockedAmount)} TNT + +
+
+ + Locked until{' '} + {new Date( + Number(eligibility.unlockTimestamp) * 1000, + ).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + {formatTangleBalance(lockedAmount)} TNT + +
+
+ )} +
{ {txHash && ( )} + + {explorerUrl && ( + + + + )}
@@ -666,10 +745,66 @@ const MigrationClaimPage: FC = () => { > {Number(formattedAmount).toLocaleString()} TNT
+ + {/* Unlock breakdown - only show if not 100% unlocked */} + {eligibility.amount !== null && + eligibility.unlockedBps < 10000 && ( +
+
+ + Available immediately + + + {formatTangleBalance( + (eligibility.amount * + BigInt(eligibility.unlockedBps)) / + BigInt(10000), + )}{' '} + TNT ({eligibility.unlockedBps / 100}%) + +
+
+ + Locked until{' '} + {new Date( + Number(eligibility.unlockTimestamp) * 1000, + ).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + {formatTangleBalance( + eligibility.amount - + (eligibility.amount * + BigInt(eligibility.unlockedBps)) / + BigInt(10000), + )}{' '} + TNT ({100 - eligibility.unlockedBps / 100}%) + +
+
+ )} + {eligibility.timeRemaining > BigInt(0) && ( {Math.floor( Number(eligibility.timeRemaining) / 86400, diff --git a/libs/dapp-config/src/contracts.ts b/libs/dapp-config/src/contracts.ts index 3a97ad149f..5cce7acb2c 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -43,7 +43,7 @@ export const LOCAL_CONTRACTS: ContractAddresses = { operatorStatusRegistry: '0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf', rewardVaults: '0x21dF544947ba3E8b3c32561399E88B52Dc8b2823', inflationPool: '0xD8a5a9b31c3C0232E196d518E89Fd8bF83AcAd43', - credits: '0x5081a39b8A5f0E35a8D959395a630b68B74Dd30f', + credits: '0x162A433068F51e18b7d13932F27e66a3f99E6890', liquidDelegationFactory: '0x8F4ec854Dd12F1fe79500a1f53D0cbB30f9b6134', }; From 56f6990b45790ca8fa550cefe4fd66e37810dbe5 Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Wed, 14 Jan 2026 16:16:43 +0700 Subject: [PATCH 3/6] style(tangle-dapp): format code Co-Authored-By: Claude Opus 4.5 --- .../components/SubstrateWalletSelector.tsx | 10 ++++++++-- .../migration/hooks/useClaimEligibility.ts | 4 +++- .../src/pages/claim/migration/index.tsx | 18 +++++++++++++++--- libs/icons/src/wallets/PolkadotJsIcon.tsx | 2 +- libs/icons/src/wallets/SubWalletIcon.tsx | 2 +- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx index b1fc1aae51..c5ff4d41bb 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx @@ -120,7 +120,10 @@ const SubstrateWalletSelector: FC = ({ // Convert InjectedAccount to InjectedAccountWithMeta const convertAccounts = useCallback( - (rawAccounts: InjectedAccount[], source: string): InjectedAccountWithMeta[] => { + ( + rawAccounts: InjectedAccount[], + source: string, + ): InjectedAccountWithMeta[] => { return rawAccounts.map((account) => ({ address: account.address, meta: { @@ -181,7 +184,10 @@ const SubstrateWalletSelector: FC = ({ return; } - const convertedAccounts = convertAccounts(rawAccounts, saved.walletName); + const convertedAccounts = convertAccounts( + rawAccounts, + saved.walletName, + ); setAccounts(convertedAccounts); setConnectedWalletName(saved.walletName); setIsConnected(true); diff --git a/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts b/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts index 0571314ba0..f86bac401a 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts +++ b/apps/tangle-dapp/src/pages/claim/migration/hooks/useClaimEligibility.ts @@ -1058,7 +1058,9 @@ const useClaimEligibility = ({ ss58Address }: UseClaimEligibilityOptions) => { formattedBalance: '1,000', isPaused: false, unlockedBps: 1000, // Mock 10% unlocked for dev mode - unlockTimestamp: BigInt(Math.floor(Date.now() / 1000) + 180 * 24 * 60 * 60), // 180 days from now + unlockTimestamp: BigInt( + Math.floor(Date.now() / 1000) + 180 * 24 * 60 * 60, + ), // 180 days from now }; } diff --git a/apps/tangle-dapp/src/pages/claim/migration/index.tsx b/apps/tangle-dapp/src/pages/claim/migration/index.tsx index 6f192983a2..99d4109520 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/index.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/index.tsx @@ -171,7 +171,13 @@ const MigrationClaimPage: FC = () => { !eligibility.isPaused && validRecipient ); - }, [substrateAccount, substrateExtension, isLoadingEligibility, eligibility, validRecipient]); + }, [ + substrateAccount, + substrateExtension, + isLoadingEligibility, + eligibility, + validRecipient, + ]); // Handle signing the challenge const handleSignChallenge = useCallback(async () => { @@ -322,7 +328,10 @@ const MigrationClaimPage: FC = () => { > Claim Successful! - + Your TNT tokens have been claimed successfully. @@ -342,7 +351,10 @@ const MigrationClaimPage: FC = () => {
- + Locked until{' '} {new Date( Number(eligibility.unlockTimestamp) * 1000, diff --git a/libs/icons/src/wallets/PolkadotJsIcon.tsx b/libs/icons/src/wallets/PolkadotJsIcon.tsx index c8a55942fa..b9cb864f91 100644 --- a/libs/icons/src/wallets/PolkadotJsIcon.tsx +++ b/libs/icons/src/wallets/PolkadotJsIcon.tsx @@ -31,4 +31,4 @@ export const PolkadotJsIcon = (props: IconBase) => { ), }); -}; \ No newline at end of file +}; diff --git a/libs/icons/src/wallets/SubWalletIcon.tsx b/libs/icons/src/wallets/SubWalletIcon.tsx index 467ffdfd82..88f6818a96 100644 --- a/libs/icons/src/wallets/SubWalletIcon.tsx +++ b/libs/icons/src/wallets/SubWalletIcon.tsx @@ -180,4 +180,4 @@ export const SubWalletIcon = (props: IconBase) => { ), }); -}; \ No newline at end of file +}; From fb7ffdc006170d69b53fe4d395678f800e58ecf9 Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Wed, 14 Jan 2026 16:26:07 +0700 Subject: [PATCH 4/6] fix(tangle-dapp): replace empty function with Function.prototype for lint --- .../pages/claim/migration/components/SubstrateWalletModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx index 41da6f475f..f6e76bbb27 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx @@ -36,7 +36,7 @@ function ensureAccountsSubscribe( cb: (accounts: InjectedAccount[]) => void | Promise, ): Unsubcall => { extension.accounts.get().then(cb).catch(console.error); - return () => {}; + return Function.prototype as Unsubcall; }, }, }; From 34a74ab1c33e622fadba28198040c801f9509bd7 Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Wed, 14 Jan 2026 16:27:40 +0700 Subject: [PATCH 5/6] fix(tangle-dapp): replace empty function with Function.prototype for lint --- .../claim/migration/components/SubstrateWalletSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx index c5ff4d41bb..ff4f71c789 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx @@ -52,7 +52,7 @@ function ensureAccountsSubscribe( cb: (accounts: InjectedAccount[]) => void | Promise, ): Unsubcall => { extension.accounts.get().then(cb).catch(console.error); - return () => {}; + return Function.prototype as Unsubcall; }, }, }; From e09c7c4e72bfebea0dc1160ed3fb620826d3ce4b Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Wed, 14 Jan 2026 17:02:37 +0700 Subject: [PATCH 6/6] refactor(tangle-dapp): extract shared wallet utilities Extract duplicated wallet connection code from SubstrateWalletModal and SubstrateWalletSelector into shared utils/walletUtils.ts file. Co-Authored-By: Claude Opus 4.5 --- .../components/SubstrateWalletModal.tsx | 57 +--------------- .../components/SubstrateWalletSelector.tsx | 55 +--------------- .../claim/migration/utils/walletUtils.ts | 66 +++++++++++++++++++ 3 files changed, 68 insertions(+), 110 deletions(-) create mode 100644 apps/tangle-dapp/src/pages/claim/migration/utils/walletUtils.ts diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx index f6e76bbb27..c382c578c2 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletModal.tsx @@ -1,7 +1,6 @@ import type { InjectedAccount, InjectedExtension, - Unsubcall, } from '@polkadot/extension-inject/types'; import Spinner from '@tangle-network/icons/Spinner'; import { @@ -17,61 +16,7 @@ import { import { Typography } from '@tangle-network/ui-components/typography/Typography'; import { type FC, useCallback, useEffect, useState } from 'react'; import { twMerge } from 'tailwind-merge'; - -const APP_NAME = 'Tangle dApp'; - -/** - * Ensures extension has accounts.subscribe method - * Matches develop branch's ensureAccountsSubscribe - */ -function ensureAccountsSubscribe( - extension: InjectedExtension, -): InjectedExtension { - if (!extension.accounts.subscribe) { - return { - ...extension, - accounts: { - ...extension.accounts, - subscribe: ( - cb: (accounts: InjectedAccount[]) => void | Promise, - ): Unsubcall => { - extension.accounts.get().then(cb).catch(console.error); - return Function.prototype as Unsubcall; - }, - }, - }; - } - return extension; -} - -/** - * Find and connect to a substrate wallet - */ -async function findSubstrateWallet( - walletName: string, -): Promise { - const extension = window.injectedWeb3?.[walletName]; - - if (extension === undefined) { - throw new Error(`${walletName} is not installed`); - } - - if (extension.connect !== undefined) { - const ex = await extension.connect(APP_NAME); - return ensureAccountsSubscribe(ex); - } - - if (extension.enable !== undefined) { - const injected = await extension.enable(APP_NAME); - return ensureAccountsSubscribe({ - name: walletName, - version: extension.version || 'unknown', - ...injected, - }); - } - - throw new Error(`${walletName} does not support connect() or enable()`); -} +import { findSubstrateWallet } from '../utils/walletUtils'; const SUBSTRATE_WALLETS = [ { diff --git a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx index ff4f71c789..2532032ef6 100644 --- a/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx +++ b/apps/tangle-dapp/src/pages/claim/migration/components/SubstrateWalletSelector.tsx @@ -2,7 +2,6 @@ import type { InjectedAccount, InjectedAccountWithMeta, InjectedExtension, - Unsubcall, } from '@polkadot/extension-inject/types'; import { ChevronDown, WalletLineIcon } from '@tangle-network/icons'; import { @@ -16,11 +15,11 @@ import { shortenString } from '@tangle-network/ui-components/utils/shortenString import { AnimatePresence, motion } from 'framer-motion'; import { type FC, useCallback, useEffect, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; +import { findSubstrateWallet } from '../utils/walletUtils'; import SubstrateWalletModal, { type SubstrateWalletName, } from './SubstrateWalletModal'; -const APP_NAME = 'Tangle dApp'; const STORAGE_KEY = 'tangle-migration-claim-substrate-wallet'; interface SavedConnection { @@ -37,58 +36,6 @@ const WALLET_INFO: Record< 'subwallet-js': { title: 'SubWallet', Icon: SubWalletIcon }, }; -/** - * Ensures extension has accounts.subscribe method - */ -function ensureAccountsSubscribe( - extension: InjectedExtension, -): InjectedExtension { - if (!extension.accounts.subscribe) { - return { - ...extension, - accounts: { - ...extension.accounts, - subscribe: ( - cb: (accounts: InjectedAccount[]) => void | Promise, - ): Unsubcall => { - extension.accounts.get().then(cb).catch(console.error); - return Function.prototype as Unsubcall; - }, - }, - }; - } - return extension; -} - -/** - * Find and connect to a substrate wallet - */ -async function findSubstrateWallet( - walletName: string, -): Promise { - const extension = window.injectedWeb3?.[walletName]; - - if (extension === undefined) { - throw new Error(`${walletName} is not installed`); - } - - if (extension.connect !== undefined) { - const ex = await extension.connect(APP_NAME); - return ensureAccountsSubscribe(ex); - } - - if (extension.enable !== undefined) { - const injected = await extension.enable(APP_NAME); - return ensureAccountsSubscribe({ - name: walletName, - version: extension.version || 'unknown', - ...injected, - }); - } - - throw new Error(`${walletName} does not support connect() or enable()`); -} - interface Props { selectedAccount: InjectedAccountWithMeta | null; onAccountSelect: (account: InjectedAccountWithMeta | null) => void; diff --git a/apps/tangle-dapp/src/pages/claim/migration/utils/walletUtils.ts b/apps/tangle-dapp/src/pages/claim/migration/utils/walletUtils.ts new file mode 100644 index 0000000000..69b0d05f40 --- /dev/null +++ b/apps/tangle-dapp/src/pages/claim/migration/utils/walletUtils.ts @@ -0,0 +1,66 @@ +import type { + InjectedAccount, + InjectedExtension, + Unsubcall, +} from '@polkadot/extension-inject/types'; + +export const APP_NAME = 'Tangle dApp'; + +/** + * Ensures extension has accounts.subscribe method. + * Some wallet extensions don't implement subscribe, so we polyfill it + * by falling back to a one-time get() call. + */ +export const ensureAccountsSubscribe = ( + extension: InjectedExtension, +): InjectedExtension => { + if (!extension.accounts.subscribe) { + return { + ...extension, + accounts: { + ...extension.accounts, + subscribe: ( + cb: (accounts: InjectedAccount[]) => void | Promise, + ): Unsubcall => { + extension.accounts.get().then(cb).catch(console.error); + return Function.prototype as Unsubcall; + }, + }, + }; + } + return extension; +}; + +/** + * Find and connect to a substrate wallet by name. + * Supports both connect() and enable() methods for wallet extensions. + */ +export const findSubstrateWallet = async ( + walletName: string, +): Promise => { + if (typeof window === 'undefined' || !window.injectedWeb3) { + throw new Error('No wallet extensions detected'); + } + + const extension = window.injectedWeb3[walletName]; + + if (extension === undefined) { + throw new Error(`${walletName} is not installed`); + } + + if (extension.connect !== undefined) { + const ex = await extension.connect(APP_NAME); + return ensureAccountsSubscribe(ex); + } + + if (extension.enable !== undefined) { + const injected = await extension.enable(APP_NAME); + return ensureAccountsSubscribe({ + name: walletName, + version: extension.version || 'unknown', + ...injected, + }); + } + + throw new Error(`${walletName} does not support connect() or enable()`); +};