249 lines
7.6 KiB
TypeScript
249 lines
7.6 KiB
TypeScript
import { requestWalletNonce, verifyWalletSignature } from "./api";
|
|
|
|
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
|
|
|
const BNB_CHAIN_ID_HEX = "0x38";
|
|
const BNB_CHAIN_PARAMS = {
|
|
chainId: BNB_CHAIN_ID_HEX,
|
|
chainName: "BNB Smart Chain",
|
|
nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 },
|
|
rpcUrls: ["https://bsc-dataseed.binance.org"],
|
|
blockExplorerUrls: ["https://bscscan.com"],
|
|
};
|
|
|
|
export type EthereumProvider = {
|
|
isMetaMask?: boolean;
|
|
isTokenPocket?: boolean;
|
|
isImToken?: boolean;
|
|
providers?: EthereumProvider[];
|
|
request: <T = unknown>(args: {
|
|
method: string;
|
|
params?: unknown[];
|
|
}) => Promise<T>;
|
|
};
|
|
|
|
function isAddress(value: unknown): value is string {
|
|
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
|
|
}
|
|
|
|
function utf8ToHex(value: string): string {
|
|
return `0x${Array.from(new TextEncoder().encode(value), (byte) =>
|
|
byte.toString(16).padStart(2, "0"),
|
|
).join("")}`;
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
if (!error || typeof error !== "object") return String(error ?? "");
|
|
const parts: string[] = [];
|
|
const record = error as Record<string, unknown>;
|
|
for (const key of ["shortMessage", "message", "details"]) {
|
|
const value = record[key];
|
|
if (typeof value === "string") parts.push(value);
|
|
}
|
|
if (record.cause) parts.push(errorText(record.cause));
|
|
return parts.join("\n");
|
|
}
|
|
|
|
function isNoAccountError(error: unknown): boolean {
|
|
return /wallet must has at least one account|wallet must has one account|must have at least one account|no wallet account returned/i.test(
|
|
errorText(error),
|
|
);
|
|
}
|
|
|
|
function normalizeWalletError(error: unknown): Error {
|
|
if (isNoAccountError(error)) return new Error("walletNoAccount");
|
|
if (error instanceof Error) return error;
|
|
const message = errorText(error);
|
|
return new Error(message || "Wallet login failed");
|
|
}
|
|
|
|
function shouldRetryPersonalSign(error: unknown): boolean {
|
|
const text = errorText(error);
|
|
return /wallet must has at least one account|wallet must has one account|must have at least one account|invalid params|invalid account|account not found/i.test(
|
|
text,
|
|
);
|
|
}
|
|
|
|
async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
|
|
const chainId = await ethereum
|
|
.request<string>({ method: "eth_chainId" })
|
|
.catch(() => "");
|
|
if (chainId.toLowerCase() === BNB_CHAIN_ID_HEX) return;
|
|
|
|
try {
|
|
await ethereum.request({
|
|
method: "wallet_switchEthereumChain",
|
|
params: [{ chainId: BNB_CHAIN_ID_HEX }],
|
|
});
|
|
} catch (error) {
|
|
const code = (error as { code?: number | string } | null)?.code;
|
|
if (code !== 4902 && code !== "4902") throw error;
|
|
await ethereum.request({
|
|
method: "wallet_addEthereumChain",
|
|
params: [BNB_CHAIN_PARAMS],
|
|
});
|
|
}
|
|
}
|
|
|
|
async function requestInjectedAddress(
|
|
ethereum: EthereumProvider,
|
|
): Promise<string> {
|
|
const existingAccounts: unknown[] = await ethereum
|
|
.request<unknown[]>({ method: "eth_accounts" })
|
|
.catch((): unknown[] => []);
|
|
const existingAddress = existingAccounts.find(isAddress);
|
|
if (existingAddress) return existingAddress;
|
|
|
|
const requestedAccounts = await ethereum
|
|
.request<unknown[]>({
|
|
method: "eth_requestAccounts",
|
|
})
|
|
.catch((error: unknown): never => {
|
|
throw normalizeWalletError(error);
|
|
});
|
|
const requestedAddress = requestedAccounts.find(isAddress);
|
|
if (!requestedAddress) throw new Error("walletNoAccount");
|
|
return requestedAddress;
|
|
}
|
|
|
|
async function personalSign(params: {
|
|
ethereum: EthereumProvider;
|
|
message: string;
|
|
address: string;
|
|
}): Promise<string> {
|
|
const { ethereum, message, address } = params;
|
|
const hexMessage = utf8ToHex(message);
|
|
try {
|
|
return await ethereum.request<string>({
|
|
method: "personal_sign",
|
|
params: [hexMessage, address],
|
|
});
|
|
} catch (error) {
|
|
if (!shouldRetryPersonalSign(error)) throw error;
|
|
return ethereum
|
|
.request<string>({
|
|
method: "personal_sign",
|
|
params: [address, hexMessage],
|
|
})
|
|
.catch((retryError: unknown): never => {
|
|
throw normalizeWalletError(retryError);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getInjectedEthereum(): EthereumProvider | null {
|
|
if (typeof window === "undefined") return null;
|
|
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
|
|
return maybeWindow.ethereum ?? null;
|
|
}
|
|
|
|
export function isTokenPocketBrowser(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
return /tokenpocket|tpwallet/i.test(navigator.userAgent || "");
|
|
}
|
|
|
|
export function isImTokenBrowser(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
return /imtoken/i.test(navigator.userAgent || "");
|
|
}
|
|
|
|
export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null {
|
|
const ethereum = getInjectedEthereum();
|
|
if (!ethereum || !kind) return ethereum;
|
|
const providers = ethereum.providers?.length
|
|
? ethereum.providers
|
|
: [ethereum];
|
|
const match = providers.find((provider) => {
|
|
if (kind === "metaMask") return provider.isMetaMask;
|
|
if (kind === "tokenPocket") return provider.isTokenPocket;
|
|
if (kind === "imToken") return provider.isImToken;
|
|
return false;
|
|
});
|
|
if (match) return match;
|
|
|
|
if (kind === "tokenPocket" && isTokenPocketBrowser()) {
|
|
return providers[0] ?? ethereum;
|
|
}
|
|
|
|
if (kind === "imToken" && isImTokenBrowser()) return providers[0] ?? ethereum;
|
|
|
|
return null;
|
|
}
|
|
|
|
export function logWalletProviders(): void {
|
|
const ethereum = getInjectedEthereum();
|
|
const list = (
|
|
ethereum?.providers?.length
|
|
? ethereum.providers
|
|
: ethereum
|
|
? [ethereum]
|
|
: []
|
|
).map((p) => ({
|
|
isMetaMask: Boolean(p.isMetaMask),
|
|
isTokenPocket: Boolean(p.isTokenPocket),
|
|
isImToken: Boolean(p.isImToken),
|
|
}));
|
|
console.info("[wallet-login] providers", {
|
|
hasEthereum: Boolean(ethereum),
|
|
count: list.length,
|
|
list,
|
|
});
|
|
}
|
|
|
|
export async function connectInjectedWallet(
|
|
kind?: WalletKind,
|
|
): Promise<string> {
|
|
console.info("[wallet-login] start injected connect", { kind });
|
|
logWalletProviders();
|
|
const ethereum = getInjectedWallet(kind);
|
|
if (!ethereum) {
|
|
console.warn("[wallet-login] no injected provider found");
|
|
throw new Error("No injected wallet found");
|
|
}
|
|
|
|
console.info("[wallet-login] requesting BNB wallet account…");
|
|
const address = await requestInjectedAddress(ethereum);
|
|
console.info("[wallet-login] injected account", address);
|
|
|
|
console.info("[wallet-login] ensuring BNB Chain (0x38)…");
|
|
await ensureBnbChain(ethereum);
|
|
return address;
|
|
}
|
|
|
|
export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
|
token: string;
|
|
wallet: string;
|
|
}> {
|
|
console.info("[wallet-login] start injected", { kind });
|
|
logWalletProviders();
|
|
const ethereum = getInjectedWallet(kind);
|
|
if (!ethereum) {
|
|
console.warn("[wallet-login] no injected provider found");
|
|
throw new Error("No injected wallet found");
|
|
}
|
|
|
|
console.info("[wallet-login] requesting BNB wallet account…");
|
|
const address = await requestInjectedAddress(ethereum);
|
|
console.info("[wallet-login] account", address);
|
|
|
|
console.info("[wallet-login] ensuring BNB Chain (0x38)…");
|
|
await ensureBnbChain(ethereum);
|
|
|
|
console.info("[wallet-login] requesting nonce for", address);
|
|
const nonce = await requestWalletNonce(address);
|
|
console.info("[wallet-login] got nonce, requesting personal_sign…");
|
|
const signature = await personalSign({
|
|
ethereum,
|
|
message: nonce.message,
|
|
address,
|
|
});
|
|
console.info("[wallet-login] signed, verifying with backend…");
|
|
const result = await verifyWalletSignature({
|
|
address,
|
|
message: nonce.message,
|
|
signature,
|
|
});
|
|
console.info("[wallet-login] verified, wallet =", result.wallet);
|
|
return result;
|
|
}
|