Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion web/src/__tests__/app/pay/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,25 @@ describe("PayPage", () => {
expect(screen.getByText("Send payment")).toBeInTheDocument();
});

it("renders chain selector with all chains", async () => {
it("renders chain selector with mainnet chains (hides testnet by default)", async () => {
render(<PayPage />);
await waitFor(() => {
expect(screen.getByText("Base")).toBeInTheDocument();
});
expect(screen.getByText("Ethereum")).toBeInTheDocument();
expect(screen.getByText("Solana")).toBeInTheDocument();
expect(screen.getByText("TON")).toBeInTheDocument();
// Base Sepolia should be hidden by default
expect(screen.queryByText("Base Sepolia")).not.toBeInTheDocument();
});

it("shows testnet chains when ?test=true is set", async () => {
setUrlParams({ uid: "12345", plan: "starter", idtype: "tg", test: "true" });
render(<PayPage />);
await waitFor(() => {
expect(screen.getByText("Base")).toBeInTheDocument();
});
expect(screen.getByText("Base Sepolia")).toBeInTheDocument();
});

it("renders token selector with USDC and USDT", async () => {
Expand Down
117 changes: 117 additions & 0 deletions web/src/__tests__/lib/evm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { connectEvm, isEvmAvailable } from "@/lib/wallets/evm";

// Mock ethers.js
const mockSend = vi.fn();
const mockGetAddress = vi.fn(() => "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01");
const mockGetSigner = vi.fn(() => ({ getAddress: mockGetAddress }));

vi.mock("ethers", () => {
const BrowserProvider = vi.fn(function (this: any) {
this.send = mockSend;
this.getSigner = mockGetSigner;
});
return {
BrowserProvider,
Contract: vi.fn(),
parseUnits: vi.fn(),
};
});

describe("isEvmAvailable", () => {
it("returns false when window.ethereum is undefined", () => {
(window as any).ethereum = undefined;
expect(isEvmAvailable()).toBe(false);
});

it("returns true when window.ethereum exists", () => {
(window as any).ethereum = { request: vi.fn() };
expect(isEvmAvailable()).toBe(true);
});
});

describe("connectEvm", () => {
beforeEach(() => {
vi.clearAllMocks();
(window as any).ethereum = { request: vi.fn() };
mockSend.mockResolvedValue(undefined);
});

it("switches to correct chain on connect", async () => {
const result = await connectEvm("base");
expect(mockSend).toHaveBeenCalledWith("eth_requestAccounts", []);
expect(mockSend).toHaveBeenCalledWith("wallet_switchEthereumChain", [{ chainId: "0x2105" }]);
expect(result.address).toBe("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01");
});

it("adds Base Sepolia chain when wallet returns raw 4902", async () => {
mockSend
.mockResolvedValueOnce(undefined) // eth_requestAccounts
.mockRejectedValueOnce({ code: 4902 }) // wallet_switchEthereumChain
.mockResolvedValueOnce(undefined); // wallet_addEthereumChain

const result = await connectEvm("base_sepolia");
expect(mockSend).toHaveBeenCalledWith("wallet_addEthereumChain", [
expect.objectContaining({
chainId: "0x14a34",
chainName: "Base Sepolia",
rpcUrls: ["https://sepolia.base.org"],
}),
]);
expect(result.address).toBe("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01");
});

it("adds chain when ethers wraps 4902 in data.originalError", async () => {
mockSend
.mockResolvedValueOnce(undefined) // eth_requestAccounts
.mockRejectedValueOnce({
code: -32603,
data: { originalError: { code: 4902, message: "Unrecognized chain ID" } },
})
.mockResolvedValueOnce(undefined); // wallet_addEthereumChain

const result = await connectEvm("base_sepolia");
expect(mockSend).toHaveBeenCalledWith("wallet_addEthereumChain", [
expect.objectContaining({ chainId: "0x14a34" }),
]);
expect(result.address).toBe("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01");
});

it("adds chain when error message contains 'Unrecognized chain ID'", async () => {
mockSend
.mockResolvedValueOnce(undefined) // eth_requestAccounts
.mockRejectedValueOnce(
new Error('could not coalesce error (error={"code":-32603,"data":{"originalError":{"code":4902,"message":"Unrecognized chain ID \\"0x14a34\\""}}})')
)
.mockResolvedValueOnce(undefined); // wallet_addEthereumChain

const result = await connectEvm("base_sepolia");
expect(mockSend).toHaveBeenCalledWith("wallet_addEthereumChain", [
expect.objectContaining({ chainId: "0x14a34" }),
]);
expect(result.address).toBe("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01");
});

it("throws user-friendly message for unknown chain without addChain params", async () => {
mockSend
.mockResolvedValueOnce(undefined) // eth_requestAccounts
.mockRejectedValueOnce({ code: 4902 }); // wallet_switchEthereumChain for eth

// eth doesn't have EVM_CHAIN_PARAMS entry, so it should throw
await expect(connectEvm("eth")).rejects.toThrow("Please add eth network to your wallet");
});

it("re-throws non-4902 errors", async () => {
const otherErr = new Error("User rejected request");
mockSend
.mockResolvedValueOnce(undefined) // eth_requestAccounts
.mockRejectedValueOnce(otherErr); // wallet_switchEthereumChain

await expect(connectEvm("base")).rejects.toBe(otherErr);
});

it("throws when no ethereum provider", async () => {
(window as any).ethereum = undefined;
await expect(connectEvm("base")).rejects.toThrow("No EVM wallet detected");
});
});
11 changes: 10 additions & 1 deletion web/src/app/pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export default function PayPage() {
const [selectedChain, setSelectedChain] = useState<ChainId>("base");
const [selectedToken, setSelectedToken] = useState<TokenId>("usdc");

// Testnet visibility (show only when ?test=true)
const [showTestnets, setShowTestnets] = useState(false);

// Payment state
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
Expand All @@ -61,6 +64,9 @@ export default function PayPage() {
const params = new URLSearchParams(window.location.search);
const tg = window.Telegram?.WebApp;

// Enable testnets if ?test=true
setShowTestnets(params.get("test") === "true");

let pUid = params.get("uid") || "";
let pPlan = params.get("plan") || "starter";
let pIdType = (params.get("idtype") || "tg") as "tg" | "email";
Expand Down Expand Up @@ -104,6 +110,9 @@ export default function PayPage() {
// Get price for current plan
const price = config?.prices[plan] ?? config?.prices.starter ?? 10;

// Filter chains — hide testnets unless ?test=true
const visibleChains = showTestnets ? CHAINS : CHAINS.filter((c) => !c.testnet);

// Get wallet address for current chain
const walletAddress = config?.wallets[selectedChain] ?? "";

Expand Down Expand Up @@ -215,7 +224,7 @@ export default function PayPage() {
<section className="mt-6">
<StepHeader step={1} title="Select network" />
<ChainSelector
chains={CHAINS}
chains={visibleChains}
selected={selectedChain}
onSelect={setSelectedChain}
disabled={verified}
Expand Down
20 changes: 20 additions & 0 deletions web/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ export const EVM_CHAIN_IDS: Record<string, string> = {
base_sepolia: "0x14a34",
};

// Full chain params for wallet_addEthereumChain (testnets / non-default chains)
export const EVM_CHAIN_PARAMS: Record<
string,
{
chainId: string;
chainName: string;
rpcUrls: string[];
nativeCurrency: { name: string; symbol: string; decimals: number };
blockExplorerUrls: string[];
}
> = {
base_sepolia: {
chainId: "0x14a34",
chainName: "Base Sepolia",
rpcUrls: ["https://sepolia.base.org"],
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
blockExplorerUrls: ["https://sepolia.basescan.org"],
},
};

export const EVM_CHAINS: ChainId[] = ["base", "eth", "base_sepolia"];

// ERC-20 transfer ABI
Expand Down
28 changes: 23 additions & 5 deletions web/src/lib/wallets/evm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// EVM wallet integration via ethers.js v6 (works with any EIP-1193 wallet)

import { BrowserProvider, Contract, parseUnits, type Signer } from "ethers";
import { ERC20_ABI, EVM_CHAIN_IDS, type ChainId } from "../config";
import { ERC20_ABI, EVM_CHAIN_IDS, EVM_CHAIN_PARAMS, type ChainId } from "../config";

declare global {
interface Window {
Expand All @@ -16,6 +16,18 @@ export function isEvmAvailable(): boolean {
return typeof window !== "undefined" && !!window.ethereum;
}

/** Check if a wallet error is "chain not recognized" (code 4902).
* ethers.js v6 wraps the raw provider error in UNKNOWN_ERROR,
* so we also check data.originalError.code. */
function isChainNotAddedError(err: unknown): boolean {
const e = err as { code?: number; data?: { originalError?: { code?: number } } };
if (e.code === 4902) return true;
if (e.data?.originalError?.code === 4902) return true;
// Also match the error message as a last resort
if (err instanceof Error && err.message.includes("Unrecognized chain ID")) return true;
return false;
}

export async function connectEvm(chainId: ChainId): Promise<{ signer: Signer; address: string }> {
if (!window.ethereum) throw new Error("No EVM wallet detected");

Expand All @@ -28,11 +40,17 @@ export async function connectEvm(chainId: ChainId): Promise<{ signer: Signer; ad
try {
await provider.send("wallet_switchEthereumChain", [{ chainId: targetChainId }]);
} catch (err: unknown) {
const switchErr = err as { code?: number };
if (switchErr.code === 4902) {
throw new Error(`Please add ${chainId} network to your wallet`);
if (isChainNotAddedError(err)) {
// Try to add the chain if we have params for it
const chainParams = EVM_CHAIN_PARAMS[chainId];
if (chainParams) {
await provider.send("wallet_addEthereumChain", [chainParams]);
} else {
throw new Error(`Please add ${chainId} network to your wallet`);
}
} else {
throw err;
}
throw err;
}
}

Expand Down