refactor(wallet): use unified rainbowkit login

This commit is contained in:
TerryM
2026-06-02 21:25:05 +08:00
parent 243e98b829
commit 803d3d57c1
3 changed files with 219 additions and 256 deletions

View File

@@ -2,6 +2,15 @@ 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;
@@ -13,6 +22,116 @@ export type EthereumProvider = {
}) => 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;
// Some injected wallets incorrectly expect the legacy param order.
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 };
@@ -69,23 +188,22 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
throw new Error("No injected wallet found");
}
// Login is signature-only (EIP-191 personal_sign). The backend verifies the
// recovered address and never inspects chainId, so we deliberately do NOT
// switch or add any chain — that only adds a failure-prone wallet popup.
console.info("[wallet-login] requesting accounts (eth_requestAccounts)…");
const accounts = await ethereum.request<string[]>({
method: "eth_requestAccounts",
});
console.info("[wallet-login] accounts", accounts);
const address = accounts[0];
if (!address) throw new Error("No wallet account returned");
// BNB Smart Chain is EVM-compatible, so browser wallets still expose the
// standard EIP-1193 method names (`eth_*`) while operating on BNB chain 56.
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 ethereum.request<string>({
method: "personal_sign",
params: [nonce.message, address],
const signature = await personalSign({
ethereum,
message: nonce.message,
address,
});
console.info("[wallet-login] signed, verifying with backend…");
const result = await verifyWalletSignature({