From 01eab88c0f3048975f5b8e4765c1dcf861a4febb Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:06:29 +0800 Subject: [PATCH] feat: connect wallet favorites to backend --- ...desktop-favorites-header-blank-page-fix.md | 39 +++ README.md | 4 +- ...ckend-wallet-favorites-production-fixes.md | 313 ++++++++++++++++++ src/components/ScrollToTop.tsx | 11 +- src/favorites/api.ts | 53 +-- src/index.css | 14 +- src/layouts/PublicLayout.tsx | 22 +- src/pages/Favorites/index.tsx | 7 +- src/wallet/AutoInjectedLogin.tsx | 6 +- src/wallet/WalletLoginModal.tsx | 17 +- src/wallet/WalletProvider.tsx | 32 +- src/wallet/api.ts | 21 +- src/wallet/injected.ts | 74 +---- src/wallet/useWalletConnectLogin.ts | 38 ++- 14 files changed, 479 insertions(+), 172 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md create mode 100644 docs/backend-wallet-favorites-production-fixes.md diff --git a/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md b/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md new file mode 100644 index 0000000..e6c0288 --- /dev/null +++ b/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md @@ -0,0 +1,39 @@ +--- +title: "Desktop Favorites Header Blank Page — Quick Fix" +type: quick-fix +date: 2026-06-04 +--- + +# Desktop Favorites Header Blank Page — Quick Fix + +## Bug + +Clicking the desktop header “我的收藏 / My Favorites” button could leave the page visually blank in the local browser. The provided screenshot showed DevTools Elements with an empty `` and no React `#root` node after navigating to `/cn/favorites`. + +## Root Cause + +This was not a z-index overlay issue. The screenshot showed that React had not mounted at all because the current document had no `#root` element. In local Vite/HMR/browser state, client-side React Router navigation could land in a stale or broken document state. The favorites route itself was valid and returned the correct Vite HTML when requested directly. + +There was also a possible same-page navigation edge case: clicking “我的收藏” while already on the favorites route would not necessarily trigger route scroll reset. + +## Fix + +The desktop header favorites button now uses React Router's `reloadDocument` so clicking it performs a full document navigation. This forces the browser/Vite dev server to return a fresh `index.html` with `
` instead of relying on a potentially stale client-side navigation state. + +The route scroll reset was also made more robust by disabling browser scroll restoration and running the route scroll reset in `useLayoutEffect`, so a restored scroll position cannot leave the favorites page sitting in blank lower space before paint. + +### Files Modified + +- `src/layouts/PublicLayout.tsx` — added the desktop header favorites button and made it use `reloadDocument` plus top scroll reset. +- `src/components/ScrollToTop.tsx` — switched route scroll reset to `useLayoutEffect` and set `history.scrollRestoration = "manual"` while the app is mounted. + +## Verification + +- Ran `npx tsc --noEmit`. +- Ran `npm run format:check`. +- Used browser native to open `http://192.168.1.187:5173/cn/browse`, confirm the header “我的收藏” button is present, navigate to favorites, and inspect the resulting page. +- Verified with browser native eval that `http://192.168.1.187:5173/favorites` has `document.getElementById("root") === true`, title `My Favorites | ARK Library`, and `scrollY === 0`. + +## Notes + +No deploy was performed. diff --git a/README.md b/README.md index ccbc9ea..92ccba1 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,7 @@ Create a local `.env` only when needed. Do not commit secrets. See `.env.example ## Wallet login notes -Wallet login is used to verify address ownership for user favorites. The primary stable paths are injected wallets (`window.ethereum`) and TokenPocket QR callback login. MetaMask/imToken QR login is a RainbowKit/Reown fallback and may be unstable on some China networks. - -The current frontend stores the wallet JWT in `localStorage` as a simple MVP session mechanism. This keeps the implementation small, but any future XSS vulnerability could expose a 30-day wallet session. A more secure future iteration should move wallet sessions to backend-set `httpOnly` cookies or shorten the token lifetime with refresh-token support. +Wallet login is used to attach a wallet address to user favorites. The frontend connects an injected wallet (`window.ethereum`), sends the selected address to `POST /api/auth/wallet/login`, and stores the returned wallet JWT in `localStorage` as a simple MVP session mechanism. This keeps the implementation small, but any future XSS vulnerability could expose a wallet session. A more secure future iteration should move wallet sessions to backend-set `httpOnly` cookies or shorten the token lifetime with refresh-token support. ## Project layout diff --git a/docs/backend-wallet-favorites-production-fixes.md b/docs/backend-wallet-favorites-production-fixes.md new file mode 100644 index 0000000..990330b --- /dev/null +++ b/docs/backend-wallet-favorites-production-fixes.md @@ -0,0 +1,313 @@ +# Backend fixes required for Wallet Login + Favorites production readiness + +Date: 2026-06-04 +Environment tested: `https://arkie-library-stag.com/apnew/api` + +## Summary + +Frontend has been updated to the new backend contract: + +- Wallet login: `POST /api/auth/wallet/login` with `{ address }` +- Wallet session check: `GET /api/auth/wallet/me` with `Authorization: Bearer ` +- Favorites list/status: `GET /api/favorites` and `GET /api/favorites?ids=...` +- Favorite mutation: `POST /api/posts/{id}/favorite` with `{ add: true|false }` + +Staging confirms the new login endpoint works, but favorite mutation currently accepts an invalid Bearer token. This must be fixed before production trust. + +--- + +## Priority 0 — Fix favorite mutation authentication + +### Current staging behavior + +The following request currently returns `200 OK` even with an invalid token: + +```bash +curl -i -X POST \ + "https://arkie-library-stag.com/apnew/api/posts/8f4a571c-3477-4b05-91be-d85907048de5/favorite" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer invalid-token" \ + --data '{"add":true}' +``` + +Observed response: + +```http +HTTP 200 +{"ok":true} +``` + +### Required behavior + +`POST /api/posts/{id}/favorite` must require a valid wallet JWT. + +Invalid, missing, expired, malformed, or unverifiable tokens must return: + +```http +HTTP 401 Unauthorized +``` + +Recommended response body: + +```json +{ + "error": "unauthorized" +} +``` + +### Acceptance tests + +#### Missing token + +```bash +curl -i -X POST \ + "https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \ + -H "Content-Type: application/json" \ + --data '{"add":true}' +``` + +Expected: + +```http +HTTP 401 +``` + +#### Invalid token + +```bash +curl -i -X POST \ + "https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer invalid-token" \ + --data '{"add":true}' +``` + +Expected: + +```http +HTTP 401 +``` + +#### Valid token + +```bash +TOKEN=$(curl -s -X POST \ + "https://arkie-library-stag.com/apnew/api/auth/wallet/login" \ + -H "Content-Type: application/json" \ + --data '{"address":"0x0000000000000000000000000000000000000001"}' \ + | jq -r .token) + +curl -i -X POST \ + "https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + --data '{"add":true}' +``` + +Expected: + +```http +HTTP 200 +``` + +Response should include at least: + +```json +{ + "ok": true, + "favorited": true +} +``` + +Then cancel: + +```bash +curl -i -X POST \ + "https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + --data '{"add":false}' +``` + +Expected: + +```json +{ + "ok": true, + "favorited": false +} +``` + +--- + +## Priority 1 — Confirm wallet login security model + +### Current contract + +```http +POST /api/auth/wallet/login +Content-Type: application/json + +{ + "address": "0x..." +} +``` + +Response: + +```json +{ + "token": "", + "wallet": "0x..." +} +``` + +This is what the frontend now uses. + +### Production risk + +This flow does not prove wallet ownership. Any client can submit any wallet address and receive a token for that address. + +If wallet identity is only used for low-risk favorites, this may be acceptable as an MVP. If wallet identity will be used for user identity, permissions, membership, rewards, asset ownership, admin behavior, or anything security-sensitive, backend should require signature verification. + +### Recommended secure production flow + +If stronger security is required, backend should use nonce + signature: + +1. `POST /api/auth/wallet/nonce` with `{ address }` +2. Backend returns a one-time message / nonce. +3. Frontend asks wallet to sign the message. +4. `POST /api/auth/wallet/verify` with `{ address, message, signature }` +5. Backend verifies recovered address equals requested address. +6. Backend issues JWT. + +If backend decides to keep the simplified `{ address }` login, please explicitly confirm that this is an accepted production risk. + +--- + +## Priority 2 — Normalize favorites response contract + +Frontend currently supports the staging response shape, but the response should be made explicit. + +### Favorites list + +```http +GET /api/favorites?lang=&limit=&page=&sort=&category=&q= +Authorization: Bearer +``` + +Current staging response observed: + +```json +{ + "items": [ + { + "id": "...", + "postType": "image", + "categoryId": 11, + "categorySlug": "official-assets", + "language": "zh", + "title": "..." + } + ] +} +``` + +Recommended production response: + +```json +{ + "items": [], + "page": 1, + "limit": 24, + "total": 0 +} +``` + +`page`, `limit`, and `total` are needed for correct pagination. + +### Favorite status by ids + +```http +GET /api/favorites?ids=id1,id2,id3 +Authorization: Bearer +``` + +Current staging response observed: + +```json +{ + "items": [] +} +``` + +This works, but for frontend performance and clarity, recommended response is: + +```json +{ + "ids": ["id1", "id3"] +} +``` + +Meaning: only IDs that are already favorited by the current wallet user. + +Frontend currently accepts both: + +- `{ ids: string[] }` +- `{ items: Resource[] }` + +But backend should document and standardize one shape. + +--- + +## Priority 3 — Required status codes + +Please standardize these responses: + +| Case | Expected status | +| --- | --- | +| Missing Bearer token on protected endpoint | `401` | +| Invalid/expired Bearer token | `401` | +| Valid token but post ID does not exist | `404` | +| Invalid JSON body | `400` | +| Invalid `add` value | `400` | +| Successful favorite add/remove | `200` | + +Protected endpoints: + +- `GET /api/auth/wallet/me` +- `GET /api/favorites` +- `GET /api/favorites?ids=...` +- `POST /api/posts/{id}/favorite` + +--- + +## Frontend compatibility notes + +The frontend currently calls these staging paths through the same-origin prefix: + +```txt +/apnew/api/auth/wallet/login +/apnew/api/auth/wallet/me +/apnew/api/favorites +/apnew/api/favorites?ids=... +/apnew/api/posts/{id}/favorite +``` + +In frontend source this is written as `/api/...`; staging build uses `VITE_API_PREFIX=/apnew`. + +Please keep backend routes under `/api/...` behind the proxy. + +--- + +## Final production checklist + +Backend should confirm all of the following before production release: + +- [ ] `POST /api/posts/{id}/favorite` rejects missing token with `401`. +- [ ] `POST /api/posts/{id}/favorite` rejects invalid token with `401`. +- [ ] `POST /api/posts/{id}/favorite` only changes favorites for the wallet from the validated JWT. +- [ ] `GET /api/favorites` requires a valid Bearer token. +- [ ] `GET /api/favorites?ids=...` requires a valid Bearer token, unless explicitly declared public/legacy. +- [ ] `GET /api/auth/wallet/me` validates token and returns the wallet address from the token. +- [ ] Backend explicitly confirms whether simplified `{ address }` login is acceptable for production, or switches to nonce/signature verification. diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index 89d5ca4..cd1f35b 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { useLocation } from "react-router-dom"; /** @@ -23,6 +23,15 @@ export function ScrollToTop() { const prevPathname = useRef(pathname); useEffect(() => { + if (!("scrollRestoration" in window.history)) return; + const previous = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { + window.history.scrollRestoration = previous; + }; + }, []); + + useLayoutEffect(() => { const pathnameChanged = prevPathname.current !== pathname; prevPathname.current = pathname; diff --git a/src/favorites/api.ts b/src/favorites/api.ts index a25f47d..6f29b00 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -1,17 +1,12 @@ -import { apiBase, type Resource } from "../api"; +import { apiBase, itemsOrEmpty, type Resource } from "../api"; export type FavoriteSort = "favorited_at" | "published_at" | "hot"; -export type FavoriteItem = { - favoritedAt: string; - resource: Resource; -}; - export type FavoriteListResponse = { - items: FavoriteItem[]; - page: number; - limit: number; - total: number; + items: Resource[]; + page?: number; + limit?: number; + total?: number; }; export type FavoriteIdsResponse = { @@ -20,16 +15,24 @@ export type FavoriteIdsResponse = { export type FavoriteMutationResponse = { ok: boolean; - resourceId: string; - favorited: boolean; + changed?: boolean; + resourceId?: string; + favorited?: boolean; favoritedAt?: string; - favoriteCount: number; + favoriteCount?: number; }; function authHeaders(token: string): HeadersInit { return { Authorization: `Bearer ${token}` }; } +function authJSONHeaders(token: string): HeadersInit { + return { + ...authHeaders(token), + "Content-Type": "application/json", + }; +} + /** HTTP error that preserves the status code so callers can react to 401s. */ export class FavoriteHttpError extends Error { readonly status: number; @@ -68,7 +71,7 @@ export async function listFavorites( sp.set(key, String(value)); }); const suffix = sp.toString() ? `?${sp}` : ""; - const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, { + const res = await fetch(`${apiBase}/api/favorites${suffix}`, { headers: authHeaders(token), }); return parseJSON(res); @@ -81,22 +84,23 @@ export async function getFavoriteIds( if (resourceIds.length === 0) return []; const uniqueIds = [...new Set(resourceIds)].slice(0, 100); const res = await fetch( - `${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent( - uniqueIds.join(","), - )}`, + `${apiBase}/api/favorites?ids=${encodeURIComponent(uniqueIds.join(","))}`, { headers: authHeaders(token) }, ); - const data = await parseJSON(res); - return data.ids; + const data = await parseJSON(res); + if ("ids" in data && Array.isArray(data.ids)) return data.ids; + if ("items" in data) return itemsOrEmpty(data.items).map((item) => item.id); + return []; } export async function addFavorite( token: string, resourceId: string, ): Promise { - const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, { method: "POST", - headers: authHeaders(token), + headers: authJSONHeaders(token), + body: JSON.stringify({ add: true }), }); return parseJSON(res); } @@ -105,9 +109,10 @@ export async function removeFavorite( token: string, resourceId: string, ): Promise { - const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { - method: "DELETE", - headers: authHeaders(token), + const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, { + method: "POST", + headers: authJSONHeaders(token), + body: JSON.stringify({ add: false }), }); return parseJSON(res); } diff --git a/src/index.css b/src/index.css index 750839e..6c804cc 100644 --- a/src/index.css +++ b/src/index.css @@ -50,20 +50,12 @@ header button { } } -/* Desktop header nav: thin scrollbar when links overflow (still 單列) */ .header-nav-scroll { - scrollbar-width: thin; - scrollbar-color: rgba(238, 183, 38, 0.45) transparent; + -ms-overflow-style: none; + scrollbar-width: none; } .header-nav-scroll::-webkit-scrollbar { - height: 4px; -} -.header-nav-scroll::-webkit-scrollbar-thumb { - background-color: rgba(238, 183, 38, 0.45); - border-radius: 9999px; -} -.header-nav-scroll::-webkit-scrollbar-track { - background: transparent; + display: none; } .gold-underline { diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index ce2f5cc..3ff1ce1 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; +import { + ChevronDown, + Heart, + Menu, + Search as SearchIcon, + X, +} from "lucide-react"; import { AnimatePresence, m } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; @@ -690,6 +696,20 @@ export function PublicLayout() { ariaLabel={t("langLabel")} className="hidden h-10 w-36 md:block lg:w-40" /> + window.scrollTo({ top: 0, left: 0 })} + className={`hidden h-10 shrink-0 items-center justify-center gap-2 rounded-full border px-3 text-sm font-bold outline-none transition focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${ + na("favorites") + ? "border-ark-gold bg-ark-gold text-black" + : "border-ark-line bg-[#1a1b20] text-neutral-200 hover:border-ark-gold/50 hover:text-ark-gold" + }`} + aria-current={na("favorites") ? "page" : undefined} + > + + {t("favorites")} +
diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index b8dcb06..5a9dcea 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -163,13 +163,12 @@ export default function Favorites() { page, limit: pageSize, includeUnavailable: true, - lang: langQuery(lang), }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items).map((item) => item.resource); + const resources = itemsOrEmpty(data.items); setItems(resources); - setTotal(data.total); + setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); }) .catch((err) => { @@ -187,7 +186,7 @@ export default function Favorites() { return () => { cancelled = true; }; - }, [category, lang, markFavorite, page, query, reloadKey, sort, t, wallet]); + }, [category, markFavorite, page, query, reloadKey, sort, t, wallet]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const hasFilters = Boolean(category || query || sort !== "favorited_at"); diff --git a/src/wallet/AutoInjectedLogin.tsx b/src/wallet/AutoInjectedLogin.tsx index c3e8ab0..4191d6d 100644 --- a/src/wallet/AutoInjectedLogin.tsx +++ b/src/wallet/AutoInjectedLogin.tsx @@ -4,7 +4,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; const AUTO_LOGIN_PARAMS = ["autoLogin", "autologin"]; const ETHEREUM_WAIT_MS = 8000; @@ -52,7 +52,7 @@ function waitForInjected(kind: WalletKind): Promise { } export function AutoInjectedLogin() { - const { completeLogin, status } = useWallet(); + const { loginAddress, status } = useWallet(); useEffect(() => { if (typeof window === "undefined") return; @@ -69,7 +69,7 @@ export function AutoInjectedLogin() { try { const address = await connectInjectedWallet(kind); if (cancelled) return; - completeLogin(localWalletToken(address), address); + await loginAddress(address); } catch (err) { console.warn("[wallet-autologin] failed", err); } diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 0ab69b9..479118c 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -7,7 +7,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; import { WalletBrandIcon } from "./WalletBrandIcon"; const AUTO_LOGIN_PARAM = "autologin"; @@ -51,7 +51,7 @@ function isMobileDevice(): boolean { export function WalletLoginModal() { const { t } = useI18n(); - const { closeLoginModal, completeLogin, loginModalOpen } = useWallet(); + const { closeLoginModal, loginAddress, loginModalOpen } = useWallet(); const [selected, setSelected] = useState(null); const [mobileDevice, setMobileDevice] = useState(() => isMobileDevice()); const [state, setState] = useState("idle"); @@ -101,7 +101,7 @@ export function WalletLoginModal() { try { const address = await connectInjectedWallet(kind); if (mobileDevice) { - completeLogin(localWalletToken(address), address); + await loginAddress(address); return; } setPendingLogin({ kind, address }); @@ -112,9 +112,16 @@ export function WalletLoginModal() { } }; - const confirmPendingLogin = () => { + const confirmPendingLogin = async () => { if (!pendingLogin) return; - completeLogin(localWalletToken(pendingLogin.address), pendingLogin.address); + setState("connecting"); + setError(""); + try { + await loginAddress(pendingLogin.address); + } catch (err) { + setState("idle"); + setError(walletErrorMessage(err, t)); + } }; const cancelPendingLogin = () => { diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 7e07755..480afa7 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; -import { fetchWalletMe } from "./api"; +import { fetchWalletMe, loginWithWallet } from "./api"; import { signInWithInjectedWallet, type WalletKind } from "./injected"; import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; @@ -22,18 +22,6 @@ function walletErrorMessage(error: unknown, t: Translate): string { return t(error.message) || t("walletLoginFailed"); } -const localWalletTokenPrefix = "local-wallet:"; - -export function localWalletToken(wallet: string): string { - return `${localWalletTokenPrefix}${wallet}`; -} - -function walletFromLocalToken(token: string): string | null { - return token.startsWith(localWalletTokenPrefix) - ? token.slice(localWalletTokenPrefix.length) - : null; -} - type WalletContextValue = { address: string | null; token: string | null; @@ -43,6 +31,7 @@ type WalletContextValue = { closeLoginModal: () => void; signInInjected: (kind?: WalletKind) => Promise; completeLogin: (token: string, wallet: string) => void; + loginAddress: (address: string) => Promise; logout: () => void; }; @@ -71,13 +60,6 @@ export function WalletProvider({ children }: { children: ReactNode }) { return; } - const localWallet = walletFromLocalToken(token); - if (localWallet) { - setAddress(localWallet); - setStatus("loggedIn"); - return; - } - setStatus("loading"); fetchWalletMe(token) .then((me) => { @@ -110,6 +92,14 @@ export function WalletProvider({ children }: { children: ReactNode }) { [showToast, t], ); + const loginAddress = useCallback( + async (walletAddress: string) => { + const res = await loginWithWallet(walletAddress); + completeLogin(res.token, res.wallet); + }, + [completeLogin], + ); + const signInInjected = useCallback( async (kind?: WalletKind) => { try { @@ -141,12 +131,14 @@ export function WalletProvider({ children }: { children: ReactNode }) { closeLoginModal: () => setLoginModalOpen(false), signInInjected, completeLogin, + loginAddress, logout, }), [ address, completeLogin, loginModalOpen, + loginAddress, logout, signInInjected, status, diff --git a/src/wallet/api.ts b/src/wallet/api.ts index b6c664f..e9a9cd3 100644 --- a/src/wallet/api.ts +++ b/src/wallet/api.ts @@ -1,11 +1,6 @@ import { apiBase, getJSONAuth, postJSON } from "../api"; -export type WalletNonceResponse = { - nonce: string; - message: string; -}; - -export type WalletVerifyResponse = { +export type WalletLoginResponse = { token: string; wallet: string; }; @@ -36,18 +31,8 @@ export type TokenPocketLoginResult = signature: string; }; -export function requestWalletNonce( - address: string, -): Promise { - return postJSON("/api/auth/wallet/nonce", { address }); -} - -export function verifyWalletSignature(params: { - address: string; - message: string; - signature: string; -}): Promise { - return postJSON("/api/auth/wallet/verify", params); +export function loginWithWallet(address: string): Promise { + return postJSON("/api/auth/wallet/login", { address }); } export function fetchWalletMe(token: string): Promise { diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index ed111c9..f2f75be 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -1,4 +1,4 @@ -import { requestWalletNonce, verifyWalletSignature } from "./api"; +import { loginWithWallet } from "./api"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; @@ -26,12 +26,6 @@ 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[] = []; @@ -57,13 +51,6 @@ function normalizeWalletError(error: unknown): 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 { const chainId = await ethereum .request({ method: "eth_chainId" }) @@ -106,31 +93,6 @@ async function requestInjectedAddress( return requestedAddress; } -async function personalSign(params: { - ethereum: EthereumProvider; - message: string; - address: string; -}): Promise { - const { ethereum, message, address } = params; - const hexMessage = utf8ToHex(message); - try { - return await ethereum.request({ - method: "personal_sign", - params: [hexMessage, address], - }); - } catch (error) { - if (!shouldRetryPersonalSign(error)) throw error; - return ethereum - .request({ - 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 }; @@ -214,35 +176,9 @@ 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); + const address = await connectInjectedWallet(kind); + console.info("[wallet-login] requesting backend login for", address); + const result = await loginWithWallet(address); + console.info("[wallet-login] logged in, wallet =", result.wallet); return result; } diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index b94da93..6968c8a 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -7,7 +7,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; export type WalletConnectLoginState = "idle" | "connecting" | "signing"; export type WalletConnectLoginMode = "deeplink" | "qr"; @@ -96,7 +96,7 @@ function connectorMatchesWallet( export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); - const { address: localAddress, completeLogin } = useWallet(); + const { address: localAddress, loginAddress } = useWallet(); const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); const { disconnectAsync } = useDisconnect(); @@ -140,12 +140,17 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(wagmiAddress), wagmiAddress); - console.info("[wallet-login] local wallet session completed", { - address: wagmiAddress, - }); + void loginAddress(wagmiAddress) + .then(() => { + console.info("[wallet-login] wallet session completed", { + address: wagmiAddress, + }); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Wallet login failed"); + }); } - }, [completeLogin, localAddress, wagmiAddress, wagmiConnected]); + }, [localAddress, loginAddress, wagmiAddress, wagmiConnected]); const start = useCallback( async ( @@ -173,7 +178,7 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(injectedAddress), injectedAddress); + await loginAddress(injectedAddress); setState("idle"); return; } catch (err) { @@ -256,10 +261,17 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(address), address); - console.info("[wallet-login] local wallet session completed", { - address, - }); + void loginAddress(address) + .then(() => { + console.info("[wallet-login] wallet session completed", { + address, + }); + }) + .catch((err: unknown) => { + setError( + err instanceof Error ? err.message : "Wallet login failed", + ); + }); }; const pollId = window.setInterval(() => { void connector @@ -293,7 +305,7 @@ export function useWalletConnectLogin() { cleanupPollingRef.current = null; } }, - [available, completeLogin, connectAsync, connectors, disconnectAsync], + [available, connectAsync, connectors, disconnectAsync, loginAddress], ); return {