@gemini-wallet/core
Version:
Core SDK for Gemini Wallet integration with popup communication
259 lines (234 loc) • 8.34 kB
text/typescript
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");
}
}