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 {