@gemini-wallet/core
Version:
Core SDK for Gemini Wallet integration with popup communication
482 lines (398 loc) • 14.2 kB
text/typescript
import { errorCodes } from "@metamask/rpc-errors";
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { type Address, type Hex } from "viem";
import { DEFAULT_CHAIN_ID } from "../constants";
import { GeminiStorage } from "../storage";
import { type Chain, type GeminiProviderConfig } from "../types";
import { hexStringFromNumber } from "../utils";
// import { GeminiWallet } from "../wallets"; // Not used - using MockGeminiWallet instead
import { GeminiWalletProvider } from "./provider";
const mockAddress = "0xAfEDA61dB9e162293b2eF2C2bC5A800b37Bb5E4a" as Address;
const mockTxHash = "0x5de3752c591ecc35d1046f3aca2eba1ba5bdcfb786639a8661e9ecb823675743" as Hex;
const mockSigHash = "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045b" as Hex;
// Mock dependencies - Create a more comprehensive mock of GeminiWallet
class MockGeminiWallet {
accounts: Address[] = [];
chain: Chain = { id: DEFAULT_CHAIN_ID };
constructor(public config: GeminiProviderConfig) {}
connect() {
this.accounts = [mockAddress];
return Promise.resolve(this.accounts);
}
disconnect() {
this.accounts = [];
return Promise.resolve();
}
sendTransaction(params: any) {
if (params.from === mockAddress) {
return Promise.resolve({ hash: mockTxHash });
}
return Promise.resolve({ error: "Invalid address" });
}
signData(params: any) {
if (params.account === mockAddress) {
return Promise.resolve({ hash: mockSigHash });
}
return Promise.resolve({ error: "Invalid address" });
}
signTypedData(params: any) {
if (params.account === mockAddress) {
return Promise.resolve({ hash: mockSigHash });
}
return Promise.resolve({ error: "Invalid address" });
}
switchChain(params: { id: number }) {
if (params.id === 1 || params.id === 42161) {
this.chain = { id: params.id };
return Promise.resolve(null); // Success
}
return Promise.resolve("Unsupported chain");
}
}
// Mock the wallet module
mock.module("../wallets", () => ({
GeminiWallet: MockGeminiWallet,
}));
describe("GeminiWalletProvider", () => {
let provider: GeminiWalletProvider;
let mockStorage: GeminiStorage;
let providerConfig: GeminiProviderConfig;
beforeEach(() => {
mockStorage = new GeminiStorage();
providerConfig = {
appMetadata: { name: "Test App" },
chain: { id: DEFAULT_CHAIN_ID },
onDisconnectCallback: mock(),
storage: mockStorage,
};
provider = new GeminiWalletProvider(providerConfig);
});
afterEach(() => {
mock.restore();
});
describe("constructor", () => {
it("should create a provider instance with config", () => {
expect(provider).toBeDefined();
expect(provider).toBeInstanceOf(GeminiWalletProvider);
});
it("should preserve user disconnect callback", async () => {
const disconnectCallback = mock();
const customConfig = {
...providerConfig,
onDisconnectCallback: disconnectCallback,
};
const customProvider = new GeminiWalletProvider(customConfig);
// Trigger disconnect
await customProvider.disconnect();
// User callback should be preserved
expect(disconnectCallback).toHaveBeenCalled();
});
});
describe("eth_requestAccounts", () => {
it("should connect wallet and return accounts", async () => {
const accounts = await provider.request<Address[]>({
method: "eth_requestAccounts",
});
expect(accounts).toEqual([mockAddress]);
});
it("should emit accountsChanged event on connect", async () => {
const accountsChangedHandler = mock();
provider.on("accountsChanged", accountsChangedHandler);
await provider.request({
method: "eth_requestAccounts",
});
expect(accountsChangedHandler).toHaveBeenCalledWith([mockAddress]);
});
});
describe("eth_accounts", () => {
it("should return empty array when not connected", async () => {
try {
await provider.request({ method: "eth_accounts" });
} catch (error: any) {
expect(error.code).toBe(errorCodes.provider.unauthorized);
}
});
it("should return accounts when connected", async () => {
// Connect first
await provider.request({ method: "eth_requestAccounts" });
const accounts = await provider.request<Address[]>({
method: "eth_accounts",
});
expect(accounts).toEqual([mockAddress]);
});
});
describe("eth_chainId", () => {
it("should return default chain ID when not connected", async () => {
const chainId = await provider.request<string>({
method: "eth_chainId",
});
expect(chainId).toBe(hexStringFromNumber(DEFAULT_CHAIN_ID));
});
it("should return current chain ID when connected", async () => {
await provider.request({ method: "eth_requestAccounts" });
const chainId = await provider.request<string>({
method: "eth_chainId",
});
expect(chainId).toBe(hexStringFromNumber(DEFAULT_CHAIN_ID));
});
});
describe("net_version", () => {
it("should return default chain ID when not connected", async () => {
const netVersion = await provider.request<number>({
method: "net_version",
});
expect(netVersion).toBe(DEFAULT_CHAIN_ID);
});
it("should return current chain ID when connected", async () => {
await provider.request({ method: "eth_requestAccounts" });
const netVersion = await provider.request<number>({
method: "net_version",
});
expect(netVersion).toBe(DEFAULT_CHAIN_ID);
});
});
describe("personal_sign", () => {
it("should throw when not connected", async () => {
try {
await provider.request({
method: "personal_sign",
params: ["0x123456", mockAddress],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.provider.unauthorized);
}
});
it("should sign message when connected", async () => {
await provider.request({ method: "eth_requestAccounts" });
const signature = await provider.request<Hex>({
method: "personal_sign",
params: ["0x123456" as Hex, mockAddress],
});
expect(signature).toBe(mockSigHash);
});
it("should throw on signature error", async () => {
await provider.request({ method: "eth_requestAccounts" });
try {
await provider.request({
method: "personal_sign",
params: ["0x123456" as Hex, "0xinvalidaddress" as Address],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.rpc.transactionRejected);
}
});
});
describe("eth_sendTransaction", () => {
it("should throw when not connected", async () => {
try {
await provider.request({
method: "eth_sendTransaction",
params: [{ from: mockAddress, to: mockAddress, value: "0x0" }],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.provider.unauthorized);
}
});
it("should send transaction when connected", async () => {
await provider.request({ method: "eth_requestAccounts" });
const txHash = await provider.request<Hex>({
method: "eth_sendTransaction",
params: [{ from: mockAddress, to: mockAddress, value: "0x100" }],
});
expect(txHash).toBe(mockTxHash);
});
it("should convert hex values to bigint", async () => {
await provider.request({ method: "eth_requestAccounts" });
const txHash = await provider.request<Hex>({
method: "eth_sendTransaction",
params: [
{
from: mockAddress,
gas: "0x5208",
gasPrice: "0x3b9aca00",
to: mockAddress,
value: "0x100",
},
],
});
expect(txHash).toBe(mockTxHash);
});
it("should throw on transaction error", async () => {
await provider.request({ method: "eth_requestAccounts" });
try {
await provider.request({
method: "eth_sendTransaction",
params: [
{
from: "0xinvalidaddress" as Address,
to: mockAddress,
value: "0x0",
},
],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.rpc.transactionRejected);
}
});
});
describe("wallet_switchEthereumChain", () => {
it("should throw when not connected", async () => {
try {
await provider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x1" }],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.provider.unauthorized);
}
});
it("should switch chain with standard EIP-3326 format", async () => {
await provider.request({ method: "eth_requestAccounts" });
const chainChangedHandler = mock();
provider.on("chainChanged", chainChangedHandler);
await provider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x1" }],
});
expect(chainChangedHandler).toHaveBeenCalledWith("0x1");
});
it("should switch chain with legacy format", async () => {
await provider.request({ method: "eth_requestAccounts" });
const chainChangedHandler = mock();
provider.on("chainChanged", chainChangedHandler);
await provider.request({
method: "wallet_switchEthereumChain",
params: { id: 1 } as any,
});
expect(chainChangedHandler).toHaveBeenCalledWith("0x1");
});
it("should throw on unsupported chain", async () => {
await provider.request({ method: "eth_requestAccounts" });
try {
await provider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x999" }],
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(4902);
expect(error.message).toContain("Unsupported chain");
}
});
it("should throw on invalid parameters", async () => {
await provider.request({ method: "eth_requestAccounts" });
try {
await provider.request({
method: "wallet_switchEthereumChain",
params: "invalid" as any,
});
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(errorCodes.rpc.invalidParams);
}
});
});
describe("eth_signTypedData", () => {
it("should sign typed data when connected", async () => {
await provider.request({ method: "eth_requestAccounts" });
const signature = await provider.request<Hex>({
method: "eth_signTypedData_v4",
params: [
mockAddress,
JSON.stringify({
domain: {},
message: {},
primaryType: "Test",
types: {},
}),
],
});
expect(signature).toBe(mockSigHash);
});
it("should throw when not connected", async () => {
try {
await provider.request({
method: "eth_signTypedData_v4",
params: [
mockAddress,
JSON.stringify({
domain: {},
message: {},
primaryType: "Test",
types: {},
}),
],
});
expect(true).toBe(false);
} catch (error: any) {
expect(error.code).toBe(errorCodes.provider.unauthorized);
}
});
});
describe("disconnect", () => {
it("should clear accounts on disconnect", async () => {
await provider.request({ method: "eth_requestAccounts" });
const accountsChangedHandler = mock();
provider.on("accountsChanged", accountsChangedHandler);
await provider.disconnect();
expect(accountsChangedHandler).toHaveBeenCalledWith([]);
});
it("should trigger user disconnect callback", async () => {
const disconnectCallback = mock();
const customConfig = {
...providerConfig,
onDisconnectCallback: disconnectCallback,
};
const customProvider = new GeminiWalletProvider(customConfig);
await customProvider.disconnect();
expect(disconnectCallback).toHaveBeenCalled();
});
});
describe("request validation", () => {
it("should throw on invalid method type", async () => {
try {
await provider.request({ method: 123 as any });
expect(true).toBe(false);
} catch (error: any) {
expect(error.code).toBe(errorCodes.rpc.invalidParams);
}
});
it("should throw on empty method", async () => {
try {
await provider.request({ method: "" });
expect(true).toBe(false);
} catch (error: any) {
expect(error.code).toBe(errorCodes.rpc.invalidParams);
}
});
});
describe("unsupported methods", () => {
it("should return unsupported error for unknown methods", async () => {
await provider.request({ method: "eth_requestAccounts" });
// Mock fetch to return method not found error
const originalFetch = (global as any).window.fetch;
(global as any).window.fetch = mock(() =>
Promise.resolve({
json: () =>
Promise.resolve({
error: { code: -32601, message: "Method not found" },
}),
}),
) as any;
try {
await provider.request({ method: "unsupported_method" });
expect(true).toBe(false);
} catch (error: any) {
// RPC returns -32603 (internal error) for unknown methods
expect(error.code).toBe(-32603);
} finally {
(global as any).window.fetch = originalFetch;
}
});
});
});