/** * Generic legacy injected-Solana adapter. * * WHY THIS EXISTS (MFS-805): some wallets' in-app dApp browsers expose their * Solana provider ONLY via a legacy `window[injectedId]` global and never * register with the Wallet Standard registry (`getWallets()`) — so UWC's * registry-only Solana discovery misses them and connect hangs. The first case * is Coinbase/Base Wallet Legacy mode (`window.coinbaseSolana`), but this is * wallet-agnostic: the global key comes from `SolanaMetadata.injectedId`, exactly * like the EVM (`eip155.injectedId`) and TRON (`tron.injectedId`) legacy paths. * * The provider shape is the de-facto injected-Solana interface (connect + * sign*), the same one `@solana/wallet-standard-wallet-adapter-base` wraps. */ import type { EventEmitter, SendTransactionOptions, WalletName } from '@solana/wallet-adapter-base' import { BaseMessageSignerWalletAdapter, isVersionedTransaction, scopePollingDetectionStrategy, WalletAccountError, WalletConnectionError, WalletDisconnectedError, WalletDisconnectionError, WalletError, WalletNotConnectedError, WalletNotReadyError, WalletPublicKeyError, WalletReadyState, WalletSendTransactionError, WalletSignTransactionError, WalletSignMessageError } from '@solana/wallet-adapter-base' import type { Connection, SendOptions, Transaction, VersionedTransaction, TransactionSignature, TransactionVersion } from '@solana/web3.js' import { PublicKey } from '@solana/web3.js' interface LegacyInjectedSolanaEvents { connect(...args: unknown[]): unknown disconnect(...args: unknown[]): unknown } /** * De-facto shape of a legacy injected-Solana provider. Only the members UWC * relies on are declared. */ export interface LegacyInjectedSolanaProvider extends EventEmitter { publicKey?: PublicKey signTransaction( transaction: T ): Promise signAllTransactions( transactions: T[] ): Promise signAndSendTransaction( transaction: T, options?: SendOptions ): Promise<{ signature: TransactionSignature }> signMessage(message: Uint8Array): Promise<{ signature: Uint8Array }> connect(): Promise disconnect(): Promise } /** * Resolve a (possibly dot-nested) `injectedId` path on `window`, without * throwing in non-browser / SSR contexts. Mirrors the TRON discovery resolver. */ export function resolveInjectedProperty(injectedId: string): unknown { if (typeof window === 'undefined') return undefined return injectedId .split('.') .reduce( (acc, part) => acc == null ? undefined : (acc as Record)[part], window as unknown as Record ) } /** * Duck-type check: a legacy injected-Solana provider must expose every method * the adapter invokes. Checking the full surface (not just `connect` + * `signTransaction`) keeps discovery from surfacing a partial/unrelated global * that would then throw at connect/sign/lifecycle time. */ export function isLegacyInjectedSolanaProvider( value: unknown ): value is LegacyInjectedSolanaProvider { if (!value || typeof value !== 'object') return false const p = value as Record const required = [ 'connect', 'disconnect', 'on', 'off', 'signTransaction', 'signAllTransactions', 'signMessage', 'signAndSendTransaction' ] return required.every(method => typeof p[method] === 'function') } /** * Read the legacy injected-Solana provider at `injectedId`, or undefined. */ export function getInjectedSolanaProvider( injectedId: string ): LegacyInjectedSolanaProvider | undefined { const provider = resolveInjectedProperty(injectedId) return isLegacyInjectedSolanaProvider(provider) ? provider : undefined } export class LegacyInjectedSolanaWalletAdapter extends BaseMessageSignerWalletAdapter { readonly name: WalletName // The adapter's own url/icon are unused by the Link UI (it renders its own // catalog metadata); the wallet-adapter contract just requires strings. url = '' icon = '' supportedTransactionVersions: ReadonlySet = new Set([ 'legacy', 0 ]) private readonly _injectedId: string private _connecting = false private _wallet: LegacyInjectedSolanaProvider | null = null private _publicKey: PublicKey | null = null private _readyState: WalletReadyState = typeof window === 'undefined' || typeof document === 'undefined' ? WalletReadyState.Unsupported : WalletReadyState.NotDetected constructor(injectedId: string, name: string) { super() this._injectedId = injectedId this.name = name as WalletName if (this._readyState !== WalletReadyState.Unsupported) { // Discovery only constructs this adapter after confirming the provider // exists, so mark `Installed` synchronously — otherwise `connect()` (which // hard-requires `Installed`) can race the first polling tick on the // auto-reconnect path and throw `WalletNotReadyError`. Fall back to // polling only when the provider isn't present yet. if (getInjectedSolanaProvider(this._injectedId)) { this._readyState = WalletReadyState.Installed } else { scopePollingDetectionStrategy(() => { if (getInjectedSolanaProvider(this._injectedId)) { this._readyState = WalletReadyState.Installed this.emit('readyStateChange', this._readyState) return true } return false }) } } } get publicKey() { return this._publicKey } get connecting() { return this._connecting } get readyState() { return this._readyState } async connect(): Promise { // Guard OUTSIDE the try: a concurrent connect() must early-return without // hitting the `finally` below, which would otherwise clear `_connecting` for // the still-in-flight first call and allow re-entrancy. if (this.connected || this.connecting) return this._connecting = true try { if (this._readyState !== WalletReadyState.Installed) throw new WalletNotReadyError() const wallet = getInjectedSolanaProvider(this._injectedId) if (!wallet) throw new WalletNotReadyError() try { await wallet.connect() // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new WalletConnectionError(error?.message, error) } if (!wallet.publicKey) throw new WalletAccountError() let publicKey: PublicKey try { publicKey = new PublicKey(wallet.publicKey.toBytes()) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new WalletPublicKeyError(error?.message, error) } // Assign state BEFORE subscribing: if the provider fires `disconnect` // synchronously during registration, `_disconnected` must see `_wallet` // set so it can tear down cleanly. this._wallet = wallet this._publicKey = publicKey wallet.on('disconnect', this._disconnected) this.emit('connect', publicKey) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', error) throw error } finally { this._connecting = false } } async disconnect(): Promise { const wallet = this._wallet if (wallet) { wallet.off('disconnect', this._disconnected) this._wallet = null this._publicKey = null try { await wallet.disconnect() // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', new WalletDisconnectionError(error?.message, error)) } } this.emit('disconnect') } override async sendTransaction( transaction: T, connection: Connection, options: SendTransactionOptions = {} ): Promise { try { const wallet = this._wallet if (!wallet) throw new WalletNotConnectedError() try { const { signers, ...sendOptions } = options if (isVersionedTransaction(transaction)) { if (signers?.length) transaction.sign(signers) } else { if (!(transaction as Transaction).recentBlockhash) { transaction = (await this.prepareTransaction( transaction, connection, sendOptions )) as T } if (signers?.length) (transaction as Transaction).partialSign(...signers) } if (!sendOptions.preflightCommitment && connection.commitment) { sendOptions.preflightCommitment = connection.commitment } const { signature } = await wallet.signAndSendTransaction( transaction, sendOptions ) return signature // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (error instanceof WalletError) throw error throw new WalletSendTransactionError(error?.message, error) } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', error) throw error } } async signTransaction( transaction: T ): Promise { try { const wallet = this._wallet if (!wallet) throw new WalletNotConnectedError() try { return ((await wallet.signTransaction(transaction)) as T) || transaction // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new WalletSignTransactionError(error?.message, error) } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', error) throw error } } override async signAllTransactions< T extends Transaction | VersionedTransaction >(transactions: T[]): Promise { try { const wallet = this._wallet if (!wallet) throw new WalletNotConnectedError() try { return ( ((await wallet.signAllTransactions(transactions)) as T[]) || transactions ) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new WalletSignTransactionError(error?.message, error) } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', error) throw error } } async signMessage(message: Uint8Array): Promise { try { const wallet = this._wallet if (!wallet) throw new WalletNotConnectedError() try { const { signature } = await wallet.signMessage(message) return signature // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new WalletSignMessageError(error?.message, error) } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.emit('error', error) throw error } } private _disconnected = () => { const wallet = this._wallet if (wallet) { wallet.off('disconnect', this._disconnected) this._wallet = null this._publicKey = null this.emit('error', new WalletDisconnectedError()) this.emit('disconnect') } } }