UNPKG

@gemini-wallet/core

Version:

Core SDK for Gemini Wallet integration with popup communication

259 lines (234 loc) 8.34 kB
import { type Hex, type SignMessageParameters, type SignTypedDataParameters, type TransactionRequest, } from "viem"; import { DEFAULT_CHAIN_ID } from "@/constants"; import { GeminiStorage, STORAGE_ETH_ACCOUNTS_KEY, STORAGE_ETH_ACTIVE_CHAIN_KEY, } from "@/storage"; import { type GeminiProviderConfig, ProviderEventEmitter, type ProviderInterface, type RpcRequestArgs, } from "@/types"; import { hexStringFromNumber, reverseResolveEns } from "@/utils"; import { GeminiWallet } from "@/wallets"; import { errorCodes, providerErrors, rpcErrors, serializeError, } from "@metamask/rpc-errors"; import { convertSendValuesToBigInt, fetchRpcRequest, validateRpcRequestArgs, } from "./provider.utils"; export class GeminiWalletProvider extends ProviderEventEmitter implements ProviderInterface { private readonly config: GeminiProviderConfig; private wallet: GeminiWallet | undefined = undefined; constructor(providerConfig: Readonly<GeminiProviderConfig>) { super(); this.config = providerConfig; // Preserve user's disconnect callback while adding provider cleanup const userDisconnectCallback = providerConfig.onDisconnectCallback; this.wallet = new GeminiWallet({ ...providerConfig, onDisconnectCallback: () => { // Call user's callback first userDisconnectCallback?.(); // Then handle provider cleanup this.disconnect(); }, }); } public async request<T>(args: RpcRequestArgs): Promise<T> { try { validateRpcRequestArgs(args); if (!this.wallet?.accounts?.length) { switch (args.method) { case "eth_requestAccounts": { // Use existing wallet instance instead of recreating if (!this.wallet) { // Preserve user's disconnect callback while adding provider cleanup const userDisconnectCallback = this.config.onDisconnectCallback; this.wallet = new GeminiWallet({ ...this.config, onDisconnectCallback: () => { // Call user's callback first userDisconnectCallback?.(); // Then handle provider cleanup this.disconnect(); }, }); } await this.wallet.connect(); this.emit("accountsChanged", this.wallet.accounts); break; } case "net_version": // Not connected default value return DEFAULT_CHAIN_ID as T; case "eth_chainId": // Not connected default value return hexStringFromNumber(DEFAULT_CHAIN_ID) as T; default: { // All other methods require active connection throw providerErrors.unauthorized(); } } } let response; let requestParams; switch (args.method) { case "eth_requestAccounts": case "eth_accounts": response = this.wallet.accounts; break; case "net_version": response = this.wallet.chain.id; break; case "eth_chainId": response = hexStringFromNumber(this.wallet.chain.id); break; case "personal_sign": case "wallet_sign": requestParams = args.params as Hex[]; response = await this.wallet.signData({ account: requestParams[1], message: requestParams[0], } as SignMessageParameters); if (response.error) { throw rpcErrors.transactionRejected(response.error); } else { response = response.hash; } break; case "eth_sendTransaction": case "wallet_sendTransaction": requestParams = args.params as TransactionRequest[]; requestParams = convertSendValuesToBigInt(requestParams[0]); response = await this.wallet.sendTransaction(requestParams); if (response.error) { throw rpcErrors.transactionRejected(response.error); } else { response = response.hash; } break; case "wallet_switchEthereumChain": { // Handle both standard EIP-3326 format [{ chainId: hex }] and legacy format { id: number } const rawParams = args.params as | [{ chainId: string }] | { id: number }; let chainId: number; if (Array.isArray(rawParams) && rawParams[0]?.chainId) { // Standard EIP-3326 format: [{ chainId: "0x1" }] chainId = parseInt(rawParams[0].chainId, 16); } else if ( rawParams && typeof rawParams === "object" && "id" in rawParams && Number.isInteger(rawParams.id) ) { // Legacy format: { id: 1 } chainId = rawParams.id; } else { throw rpcErrors.invalidParams( "Invalid chain id argument. Expected [{ chainId: hex_string }] or { id: number }.", ); } response = await this.wallet.switchChain({ id: chainId }); // Per EIP-3326, a non-null response indicates error if (response) { throw providerErrors.custom({ code: 4902, message: response }); } await this.emit("chainChanged", hexStringFromNumber(chainId)); break; } case "eth_signTypedData_v1": case "eth_signTypedData_v2": case "eth_signTypedData_v3": case "eth_signTypedData_v4": case "eth_signTypedData": { requestParams = args.params as Hex[]; const signedTypedDataParams = JSON.parse( requestParams[1] as string, ) as SignTypedDataParameters; response = await this.wallet.signTypedData({ account: requestParams[0], domain: signedTypedDataParams.domain, message: signedTypedDataParams.message, primaryType: signedTypedDataParams.primaryType, types: signedTypedDataParams.types, }); if (response.error) { throw rpcErrors.transactionRejected(response.error); } else { response = response.hash; } break; } // TODO: not yet implemented or unclear if we support case "eth_ecRecover": case "eth_subscribe": case "eth_unsubscribe": case "personal_ecRecover": case "eth_signTransaction": case "wallet_watchAsset": case "wallet_sendCalls": case "wallet_getCallsStatus": case "wallet_getCapabilities": case "wallet_showCallsStatus": case "wallet_grantPermissions": throw rpcErrors.methodNotSupported("Not yet implemented."); // Not supported case "eth_sign": case "eth_coinbase": case "wallet_addEthereumChain": throw rpcErrors.methodNotSupported(); // Call rpc directly for everything else default: if (!this.wallet.chain.rpcUrl) { throw rpcErrors.internal( `RPC URL missing for current chain (${this.wallet.chain.id})`, ); } return fetchRpcRequest(args, this.wallet.chain.rpcUrl); } return response as T; } catch (error) { const { code } = error as { code?: number }; if (code === errorCodes.provider.unauthorized) { this.disconnect(); } return Promise.reject(serializeError(error)); } } // Custom wallet function to open settings page async openSettings() { await this.wallet?.openSettings(); } // Custom function for reverse ENS resolution async reverseResolveEns(address: string) { return await reverseResolveEns(address as `0x${string}`); } async disconnect() { // If wallet exists, let it handle its own storage cleanup if (this.wallet) { // Create a temporary storage instance with the same config to clean up const storage = this.config.storage || new GeminiStorage(); await storage.removeItem(STORAGE_ETH_ACCOUNTS_KEY); await storage.removeItem(STORAGE_ETH_ACTIVE_CHAIN_KEY); } this.wallet = undefined; await this.emit("disconnect", "User initiated disconnection"); } }