/** * Wallet Standard Discovery for Solana Wallets * * Simple implementation to get Solana wallets using the Wallet Standard API */ import type { Wallet } from '@wallet-standard/base' import { getWallets } from '@wallet-standard/app' import { isWalletAdapterCompatibleStandardWallet } from '@solana/wallet-adapter-base' import { StandardWalletAdapter } from '@solana/wallet-standard-wallet-adapter-base' import type { Transaction } from '@solana/web3.js' import type { WalletMetadata } from '@meshconnect/uwc-types' import { solanaNetwork } from '@meshconnect/uwc-constants' import { BRIDGE_WALLET_POLL_MS } from './bridge-poll' import { LegacyInjectedSolanaWalletAdapter, getInjectedSolanaProvider, resolveInjectedProperty } from './legacy-injected-solana-adapter' /** * Telemetry sink for the legacy injected-Solana fallback. Fires only when a * wallet declares `solana.injectedId` and a global exists at that path but is * NOT a valid Solana provider (wrong method surface) — the actionable * "the wallet changed its injected provider" signal. An absent global is a * normal not-installed case and is intentionally not reported. */ export type LegacyInjectedSolanaDiscoveryFailure = (info: { injectedId: string walletName: string }) => void export interface ExtendedStandardWalletAdapter extends StandardWalletAdapter { customFunctions: ( | 'sendSerializedTransaction' | 'signSerializedTransaction' | 'signAllSerializedTransactions' | 'signSolanaTransactionBytes' )[] sendSerializedTransaction: ( transaction: unknown, rpcUrl: string ) => Promise signSerializedTransaction: ( transaction: unknown ) => Promise signAllSerializedTransactions: ( transactions: unknown[] ) => Promise // Sign-only, raw bytes (so no Transaction object crosses Comlink). See // BridgeParent for the connectedAddress rationale. signSolanaTransactionBytes: ( transaction: unknown, connectedAddress?: string ) => Promise } export type SolanaAdapter = | ExtendedStandardWalletAdapter | StandardWalletAdapter // Legacy injected fallback (MFS-805): a BaseMessageSignerWalletAdapter, not a // StandardWalletAdapter — it has no `.wallet` getter, so the sign path's // `(adapter as StandardWalletAdapter).wallet` check falls through to the legacy // branch. See legacy-injected-solana-adapter.ts. | LegacyInjectedSolanaWalletAdapter export interface WalletStandardInfo { uuid: string name: string chains: string[] features: string[] adapter: SolanaAdapter | undefined /** * True only for entries produced by the legacy injected-Solana fallback (a * `window[injectedId]` global), false/absent for Wallet Standard registry * wallets. Lets detection report which discovery path matched (MFS-805). */ legacyInjected?: boolean } /** * Polls for bridge wallets when UWCBridgeInitialized is true * @returns Promise that resolves to an array of detected wallets from the bridge */ async function pollForBridgeWallets(): Promise { const pollInterval = 50 // Poll every 50ms // Consumer budget — see BRIDGE_WALLET_POLL_MS for rationale. Even though // Solana is published early by BridgeParent, a late-exposing parent can // push the child's marshal past 500ms; that race silently cached Solana // injected wallets as "not installed". const maxPollTime = BRIDGE_WALLET_POLL_MS const startTime = Date.now() return new Promise(resolve => { const poll = () => { // Check if we've exceeded the max poll time if (Date.now() - startTime > maxPollTime) { resolve([]) return } // Check if window.walletStandardWallets is available if ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - window.eip6963Wallets is set by the bridge window.walletStandardWallets && // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - window.eip6963Wallets is set by the bridge Array.isArray(window.walletStandardWallets) ) { const bridgeWallets = // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore window.walletStandardWallets as WalletStandardInfo[] resolve(bridgeWallets) return } // Continue polling setTimeout(poll, pollInterval) } // Start polling poll() }) } /** * Gets all available Solana wallets using Wallet Standard */ export async function getSolanaWallets( expectedWallets: WalletMetadata[] = [], usingIntegratedBrowser = false, onLegacyDiscoveryFailure?: LegacyInjectedSolanaDiscoveryFailure ): Promise { // No wallets to discover outside a browser (SSR / non-DOM) — every path below // touches `window`. if (typeof window === 'undefined') return [] // UWCBridgeChildInitialized is only initialized if app runs in an iframe. We can skip checking for bridge proxy providers when it's not necessary. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - window.UWCBridgeInitialized is set by the bridge if (window.UWCBridgeChildInitialized === true) { const wallets = await pollForBridgeWallets() if (wallets.length > 0) { return wallets } } const { get } = getWallets() const wallets = get() const solanaWallets: WalletStandardInfo[] = [] // Lowercased names — dedupe must match core's case-insensitive Solana name // matching, so a legacy entry can't slip past a registry wallet that differs // only by casing. const walletNames = new Set() // First, get wallets from Wallet Standard for (const wallet of wallets) { // Check if this is a Solana wallet if (isSolanaWallet(wallet)) { let adapter: StandardWalletAdapter | undefined if (isWalletAdapterCompatibleStandardWallet(wallet)) { adapter = new StandardWalletAdapter({ wallet }) } // Key the uuid on the wallet's first `solana:` chain (not `chains[0]`): // multi-chain wallets may list a non-Solana chain first, which would make // the id depend on chain ordering. const solanaChain = wallet.chains?.find(chain => chain.startsWith('solana:')) || wallet.chains?.[0] || 'unknown' solanaWallets.push({ uuid: generateWalletId(wallet.name, solanaChain), name: wallet.name, chains: (wallet.chains || []) as string[], features: Object.keys(wallet.features), adapter: adapter }) walletNames.add(wallet.name.toLowerCase()) } } // Legacy injected-Solana fallback (MFS-805): wallets whose in-app browser // exposes Solana only via a `window[injectedId]` global and never register with // the Wallet Standard registry (first: Coinbase/Base Legacy mode). Metadata- // driven, mirroring the EVM/TRON `injectedId` pattern — no wallet hardcoded. for (const expected of expectedWallets) { // Name + injectedId come ONLY from the provider core will match against — // the session-matched one (mirrors applyDetectedWallet's `pickProvider`). // No fallback to the other provider: core reads only `pickProvider(w)`, so a // cross-provider entry (e.g. named from the extension while the session is // the in-wallet browser) would be discovered but never marked `installed` — // a dead entry. Skipping it is fail-closed and honest. const sessionProvider = usingIntegratedBrowser ? expected.integratedBrowserInjectedProvider : expected.extensionInjectedProvider const solanaMeta = sessionProvider?.namespaceMetaData?.solana const injectedId = solanaMeta?.injectedId const name = solanaMeta?.walletStandardName if (!injectedId || !name || walletNames.has(name.toLowerCase())) continue if (!getInjectedSolanaProvider(injectedId)) { // A global exists but isn't a valid Solana provider → actionable (the // wallet changed its shape); an absent global is just not-installed. if (resolveInjectedProperty(injectedId)) { onLegacyDiscoveryFailure?.({ injectedId, walletName: name }) } continue } solanaWallets.push({ uuid: generateWalletId(name, solanaNetwork.id), name, chains: [solanaNetwork.id], features: [ 'solana:signTransaction', 'solana:signAllTransactions', 'solana:signMessage', 'solana:signAndSendTransaction' ], adapter: new LegacyInjectedSolanaWalletAdapter(injectedId, name), legacyInjected: true }) walletNames.add(name.toLowerCase()) } return solanaWallets } /** * Check if a wallet supports Solana */ function isSolanaWallet(wallet: Wallet): boolean { const chains = wallet.chains || [] const hasSolanaChain = chains.some(chain => chain.startsWith('solana:')) return hasSolanaChain } /** * Generate a unique, stable ID for a wallet from its name + primary chain. */ function generateWalletId(name: string, chain: string): string { return `${name}-${chain}`.toLowerCase().replace(/\s+/g, '-') }