feat: add wallet provider foundation
This commit is contained in:
68
src/wallet/RainbowWalletProvider.tsx
Normal file
68
src/wallet/RainbowWalletProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import "@rainbow-me/rainbowkit/styles.css";
|
||||
|
||||
import {
|
||||
RainbowKitProvider,
|
||||
connectorsForWallets,
|
||||
darkTheme,
|
||||
} from "@rainbow-me/rainbowkit";
|
||||
import {
|
||||
imTokenWallet,
|
||||
metaMaskWallet,
|
||||
tokenPocketWallet,
|
||||
} from "@rainbow-me/rainbowkit/wallets";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { http, createConfig, WagmiProvider } from "wagmi";
|
||||
import { mainnet } from "wagmi/chains";
|
||||
|
||||
const projectId =
|
||||
import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only";
|
||||
|
||||
const connectors = connectorsForWallets(
|
||||
[
|
||||
{
|
||||
groupName: "ARK Library",
|
||||
wallets: [metaMaskWallet, imTokenWallet, tokenPocketWallet],
|
||||
},
|
||||
],
|
||||
{
|
||||
appName: "ARK Library",
|
||||
projectId,
|
||||
},
|
||||
);
|
||||
|
||||
export const wagmiConfig = createConfig({
|
||||
chains: [mainnet],
|
||||
connectors,
|
||||
ssr: false,
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
},
|
||||
});
|
||||
|
||||
export function RainbowWalletProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
return (
|
||||
<WagmiProvider config={wagmiConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
modalSize="compact"
|
||||
theme={darkTheme({
|
||||
accentColor: "#d7b46a",
|
||||
accentColorForeground: "#08070c",
|
||||
borderRadius: "large",
|
||||
fontStack: "system",
|
||||
overlayBlur: "small",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWalletConnectProjectId(): boolean {
|
||||
return Boolean(import.meta.env.VITE_WALLETCONNECT_PROJECT_ID);
|
||||
}
|
||||
136
src/wallet/WalletProvider.tsx
Normal file
136
src/wallet/WalletProvider.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useI18n } from "../i18n";
|
||||
import { fetchWalletMe } from "./api";
|
||||
import { signInWithInjectedWallet } from "./injected";
|
||||
import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
|
||||
|
||||
type WalletStatus = "loading" | "loggedOut" | "loggedIn";
|
||||
|
||||
type WalletContextValue = {
|
||||
address: string | null;
|
||||
token: string | null;
|
||||
status: WalletStatus;
|
||||
loginModalOpen: boolean;
|
||||
openLoginModal: () => void;
|
||||
closeLoginModal: () => void;
|
||||
signInInjected: () => Promise<void>;
|
||||
completeLogin: (token: string, wallet: string) => void;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const WalletContext = createContext<WalletContextValue | null>(null);
|
||||
|
||||
export function shortenAddress(address: string): string {
|
||||
if (address.length <= 12) return address;
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function WalletProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [token, setToken] = useState<string | null>(() => readWalletToken());
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<WalletStatus>(
|
||||
token ? "loading" : "loggedOut",
|
||||
);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!token) {
|
||||
setStatus("loggedOut");
|
||||
setAddress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
fetchWalletMe(token)
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
setAddress(me.wallet);
|
||||
setStatus("loggedIn");
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
clearWalletToken();
|
||||
setToken(null);
|
||||
setAddress(null);
|
||||
setStatus("loggedOut");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const completeLogin = useCallback((nextToken: string, wallet: string) => {
|
||||
writeWalletToken(nextToken);
|
||||
setToken(nextToken);
|
||||
setAddress(wallet);
|
||||
setStatus("loggedIn");
|
||||
setLoginModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const signInInjected = useCallback(async () => {
|
||||
try {
|
||||
const res = await signInWithInjectedWallet();
|
||||
completeLogin(res.token, res.wallet);
|
||||
showToast(t("walletLoginSuccess"));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("walletLoginFailed");
|
||||
showToast(message || t("walletLoginFailed"), "error");
|
||||
throw error;
|
||||
}
|
||||
}, [completeLogin, showToast, t]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearWalletToken();
|
||||
setToken(null);
|
||||
setAddress(null);
|
||||
setStatus("loggedOut");
|
||||
showToast(t("walletDisconnected"));
|
||||
}, [showToast, t]);
|
||||
|
||||
const value = useMemo<WalletContextValue>(
|
||||
() => ({
|
||||
address,
|
||||
token,
|
||||
status,
|
||||
loginModalOpen,
|
||||
openLoginModal: () => setLoginModalOpen(true),
|
||||
closeLoginModal: () => setLoginModalOpen(false),
|
||||
signInInjected,
|
||||
completeLogin,
|
||||
logout,
|
||||
}),
|
||||
[
|
||||
address,
|
||||
completeLogin,
|
||||
loginModalOpen,
|
||||
logout,
|
||||
signInInjected,
|
||||
status,
|
||||
token,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<WalletContext.Provider value={value}>{children}</WalletContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWallet() {
|
||||
const ctx = useContext(WalletContext);
|
||||
if (!ctx) throw new Error("useWallet must be used within WalletProvider");
|
||||
return ctx;
|
||||
}
|
||||
72
src/wallet/api.ts
Normal file
72
src/wallet/api.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { apiBase, getJSONAuth, postJSON } from "../api";
|
||||
|
||||
export type WalletNonceResponse = {
|
||||
nonce: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WalletVerifyResponse = {
|
||||
token: string;
|
||||
wallet: string;
|
||||
};
|
||||
|
||||
export type WalletMeResponse = {
|
||||
wallet: string;
|
||||
role: "user";
|
||||
};
|
||||
|
||||
export type TokenPocketLoginRequest = {
|
||||
actionId: string;
|
||||
nonce: string;
|
||||
message: string;
|
||||
qrUrl: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type TokenPocketLoginResult =
|
||||
| {
|
||||
status: "pending" | "expired" | "failed";
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
status: "completed";
|
||||
address: string;
|
||||
message: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export function requestWalletNonce(
|
||||
address: string,
|
||||
): Promise<WalletNonceResponse> {
|
||||
return postJSON<WalletNonceResponse>("/api/auth/wallet/nonce", { address });
|
||||
}
|
||||
|
||||
export function verifyWalletSignature(params: {
|
||||
address: string;
|
||||
message: string;
|
||||
signature: string;
|
||||
}): Promise<WalletVerifyResponse> {
|
||||
return postJSON<WalletVerifyResponse>("/api/auth/wallet/verify", params);
|
||||
}
|
||||
|
||||
export function fetchWalletMe(token: string): Promise<WalletMeResponse> {
|
||||
return getJSONAuth<WalletMeResponse>("/api/auth/wallet/me", token);
|
||||
}
|
||||
|
||||
export function createTokenPocketLoginRequest(): Promise<TokenPocketLoginRequest> {
|
||||
return postJSON<TokenPocketLoginRequest>(
|
||||
"/api/auth/wallet/tp-login-request",
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTokenPocketLoginResult(
|
||||
actionId: string,
|
||||
): Promise<TokenPocketLoginResult> {
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/auth/wallet/tp-result?actionId=${encodeURIComponent(actionId)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<TokenPocketLoginResult>;
|
||||
}
|
||||
39
src/wallet/injected.ts
Normal file
39
src/wallet/injected.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { requestWalletNonce, verifyWalletSignature } from "./api";
|
||||
|
||||
export type EthereumProvider = {
|
||||
request: <T = unknown>(args: {
|
||||
method: string;
|
||||
params?: unknown[];
|
||||
}) => Promise<T>;
|
||||
};
|
||||
|
||||
export function getInjectedEthereum(): EthereumProvider | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
|
||||
return maybeWindow.ethereum ?? null;
|
||||
}
|
||||
|
||||
export async function signInWithInjectedWallet(): Promise<{
|
||||
token: string;
|
||||
wallet: string;
|
||||
}> {
|
||||
const ethereum = getInjectedEthereum();
|
||||
if (!ethereum) throw new Error("No injected wallet found");
|
||||
|
||||
const accounts = await ethereum.request<string[]>({
|
||||
method: "eth_requestAccounts",
|
||||
});
|
||||
const address = accounts[0];
|
||||
if (!address) throw new Error("No wallet account returned");
|
||||
|
||||
const nonce = await requestWalletNonce(address);
|
||||
const signature = await ethereum.request<string>({
|
||||
method: "personal_sign",
|
||||
params: [nonce.message, address],
|
||||
});
|
||||
return verifyWalletSignature({
|
||||
address,
|
||||
message: nonce.message,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
28
src/wallet/token.ts
Normal file
28
src/wallet/token.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const walletTokenKey = "ark-wallet-token:v1";
|
||||
|
||||
export function readWalletToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(walletTokenKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWalletToken(token: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(walletTokenKey, token);
|
||||
} catch {
|
||||
// Ignore storage failures; session will only live in memory.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWalletToken(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(walletTokenKey);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user