diff --git a/aastar-frontend/app/address-book/page.tsx b/aastar-frontend/app/address-book/page.tsx index 0f992be..afc0c8e 100644 --- a/aastar-frontend/app/address-book/page.tsx +++ b/aastar-frontend/app/address-book/page.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Layout from "@/components/Layout"; -import { addressBookAPI } from "@/lib/api"; +import { getAddressBook, setAddressName, removeAddress } from "@/lib/address-book-store"; +import { useDashboard } from "@/contexts/DashboardContext"; import SwipeableListItem from "@/components/SwipeableListItem"; import toast from "react-hot-toast"; import { PencilIcon, PlusIcon, BookOpenIcon } from "@heroicons/react/24/outline"; @@ -19,6 +20,8 @@ interface AddressBookEntry { export default function AddressBookPage() { const router = useRouter(); + const { data } = useDashboard(); + const accountAddress = data?.account?.address ?? null; const [addressBook, setAddressBook] = useState([]); const [loading, setLoading] = useState(true); const [editingAddress, setEditingAddress] = useState(null); @@ -27,15 +30,16 @@ export default function AddressBookPage() { const [newAddress, setNewAddress] = useState(""); const [newName, setNewName] = useState(""); + // Re-load when the (async) account resolves so the list is scoped to this account. useEffect(() => { loadAddressBook(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountAddress]); - const loadAddressBook = async () => { + const loadAddressBook = () => { setLoading(true); try { - const response = await addressBookAPI.getAddressBook(); - setAddressBook(response.data); + setAddressBook(getAddressBook(accountAddress)); } catch (error) { console.error("Failed to load address book:", error); toast.error("Failed to load address book"); @@ -57,8 +61,8 @@ export default function AddressBookPage() { } try { - await addressBookAPI.setAddressName(newAddress, newName || ""); - await loadAddressBook(); + setAddressName(accountAddress, newAddress, newName || ""); + loadAddressBook(); setShowAddForm(false); setNewAddress(""); setNewName(""); @@ -73,8 +77,8 @@ export default function AddressBookPage() { const handleUpdateName = async (address: string) => { try { - await addressBookAPI.setAddressName(address, editingName); - await loadAddressBook(); + setAddressName(accountAddress, address, editingName); + loadAddressBook(); setEditingAddress(null); setEditingName(""); toast.success("Name updated successfully!"); @@ -92,8 +96,8 @@ export default function AddressBookPage() { } try { - await addressBookAPI.removeAddress(address); - await loadAddressBook(); + removeAddress(accountAddress, address); + loadAddressBook(); toast.success("Address deleted successfully!"); } catch (error) { const message = diff --git a/aastar-frontend/app/paymaster/page.tsx b/aastar-frontend/app/paymaster/page.tsx index b7d73af..00d4e18 100644 --- a/aastar-frontend/app/paymaster/page.tsx +++ b/aastar-frontend/app/paymaster/page.tsx @@ -3,7 +3,12 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import Layout from "@/components/Layout"; -import { paymasterAPI } from "@/lib/api"; +import { + getAvailablePaymasters, + getPaymasterPresets, + addCustomPaymaster, + removeCustomPaymaster, +} from "@/lib/paymaster-store"; import { useDashboard } from "@/contexts/DashboardContext"; import SwipeableListItem from "@/components/SwipeableListItem"; import toast from "react-hot-toast"; @@ -55,7 +60,9 @@ export default function PaymasterPage() { useEffect(() => { loadPaymasters(); - }, []); + // saved paymasters are account-scoped (localStorage) — reload when the account resolves + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountAddress]); // Re-read the account-scoped default whenever the (async-loaded) account resolves, // so the ☆ / "Default" state reflects THIS account's preference. @@ -81,12 +88,8 @@ export default function PaymasterPage() { const loadPaymasters = async () => { setLoading(true); try { - const [available, presetList] = await Promise.all([ - paymasterAPI.getAvailable(), - paymasterAPI.getPresets().catch(() => ({ data: [] })), - ]); - setPaymasters(available.data); - setPresets(presetList.data); + setPaymasters(getAvailablePaymasters(accountAddress)); + setPresets(getPaymasterPresets()); } catch (error) { console.error("Failed to load paymasters:", error); toast.error("Failed to load paymasters"); @@ -99,7 +102,7 @@ export default function PaymasterPage() { const handleAddPreset = async (preset: PaymasterPreset) => { setActionLoading(`preset-${preset.name}`); try { - await paymasterAPI.addCustom({ + addCustomPaymaster(accountAddress, { name: preset.name, address: preset.address, type: preset.type, @@ -130,7 +133,7 @@ export default function PaymasterPage() { setActionLoading("add"); try { - await paymasterAPI.addCustom({ + addCustomPaymaster(accountAddress, { name: newPaymaster.name, address: newPaymaster.address, type: newPaymaster.type, @@ -166,7 +169,7 @@ export default function PaymasterPage() { setActionLoading(`remove-${name}`); try { const removed = paymasters.find(p => p.name === name); - await paymasterAPI.remove(name); + removeCustomPaymaster(accountAddress, name); // If the removed paymaster was the persisted default, drop the stale preference. if (removed && defaultPaymaster === removed.address.toLowerCase()) { clearDefaultPaymaster(accountAddress); diff --git a/aastar-frontend/app/settings/page.tsx b/aastar-frontend/app/settings/page.tsx new file mode 100644 index 0000000..4b75749 --- /dev/null +++ b/aastar-frontend/app/settings/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Layout from "@/components/Layout"; +import toast from "react-hot-toast"; +import { + getApiKey, + setApiKey, + clearApiKey, + getKmsUrl, + setKmsUrl, + getBundlerUrl, + setBundlerUrl, +} from "@/lib/api-key-store"; +import { kmsBaseUrl, isDirectKmsReady, pingKms } from "@/lib/kms-client"; + +export default function SettingsPage() { + const [apiKey, setApiKeyInput] = useState(""); + const [kmsUrl, setKmsUrlInput] = useState(""); + const [bundlerUrl, setBundlerUrlInput] = useState(""); + const [ready, setReady] = useState(false); + const [resolvedKms, setResolvedKms] = useState(""); + const [pinging, setPinging] = useState(false); + const [pingResult, setPingResult] = useState(null); + + useEffect(() => { + setApiKeyInput(getApiKey() ?? ""); + setKmsUrlInput(getKmsUrl() ?? ""); + setBundlerUrlInput(getBundlerUrl() ?? ""); + setReady(isDirectKmsReady()); + setResolvedKms(kmsBaseUrl()); + }, []); + + const handleSave = () => { + if (apiKey.trim()) setApiKey(apiKey); + else clearApiKey(); + setKmsUrl(kmsUrl); + setBundlerUrl(bundlerUrl); + setReady(isDirectKmsReady()); + setResolvedKms(kmsBaseUrl()); + toast.success("Settings saved (this device)"); + }; + + const handleClear = () => { + clearApiKey(); + setKmsUrl(""); + setBundlerUrl(""); + setApiKeyInput(""); + setKmsUrlInput(""); + setBundlerUrlInput(""); + setReady(false); + setResolvedKms(kmsBaseUrl()); + setPingResult(null); + toast.success("Cleared"); + }; + + const handlePing = async () => { + setPinging(true); + setPingResult(null); + const r = await pingKms(); + setPingResult(r.ok ? `OK (${r.status})` : `Failed: ${r.error ?? r.status}`); + setPinging(false); + }; + + const inputClass = + "block w-full px-4 py-3 border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-xl shadow-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm placeholder-gray-400 dark:placeholder-gray-500 transition-all"; + + return ( + +
+

+ Settings +

+

+ Your AAStar API key authorizes KMS + gas (bundler) access. Stored on this device only. A + free tier covers normal use; more usage is paid / aPoints. +

+ +
+
+ + setApiKeyInput(e.target.value)} + placeholder="Paste your API key" + autoComplete="off" + className={inputClass} + /> +

+ Authorizes both KMS and the bundler. Leave blank to keep using the hosted proxy. +

+
+ +
+ + setKmsUrlInput(e.target.value)} + placeholder={kmsBaseUrl()} + autoComplete="off" + spellCheck={false} + className={`${inputClass} font-mono`} + /> +
+ +
+ + setBundlerUrlInput(e.target.value)} + placeholder="Default bundler" + autoComplete="off" + spellCheck={false} + className={`${inputClass} font-mono`} + /> +
+ +
+ + + +
+
+ + {/* Status */} +
+
+ Direct-KMS ready + + {ready ? "Yes (API key set)" : "No (using hosted proxy)"} + +
+
+ Resolved KMS endpoint + + {resolvedKms} + +
+ {pingResult && ( +
+ Last test + {pingResult} +
+ )} +
+ +

+ Note: transfer signing still runs through the hosted service today. Direct browser→KMS + + direct bundler (using this key) turn on once the KMS Origin/API-key path is live — this + screen configures it ahead of that switch. +

+
+
+ ); +} diff --git a/aastar-frontend/app/transfer/page.tsx b/aastar-frontend/app/transfer/page.tsx index 647bdf0..1ea8c60 100644 --- a/aastar-frontend/app/transfer/page.tsx +++ b/aastar-frontend/app/transfer/page.tsx @@ -6,7 +6,10 @@ import Layout from "@/components/Layout"; import TokenSelector from "@/components/TokenSelector"; import TransferSkeleton from "@/components/TransferSkeleton"; import { useDashboard } from "@/contexts/DashboardContext"; -import { transferAPI, tokenAPI, paymasterAPI, addressBookAPI } from "@/lib/api"; +import { transferAPI } from "@/lib/api"; +import { getAvailablePaymasters, getPaymasterPresets } from "@/lib/paymaster-store"; +import { getAddressBook, setAddressName, recordSuccessfulTransfer } from "@/lib/address-book-store"; +import { getTokenBalance } from "@/lib/token-balance"; import { GasEstimate, Token, TokenBalance } from "@/lib/types"; import toast from "react-hot-toast"; import { startAuthentication } from "@simplewebauthn/browser"; @@ -184,6 +187,9 @@ export default function TransferPage() { const pollingIntervalRef = useRef(null); const isPollingRef = useRef(false); const defaultPaymasterAppliedForRef = useRef(null); + // Recipient captured at submit time so the client can record it in the address book + // when polling confirms the transfer (formData.to is cleared right after submit). + const pendingRecipientRef = useRef(""); const touchStartY = useRef(0); const containerRef = useRef(null); const router = useRouter(); @@ -208,6 +214,12 @@ export default function TransferPage() { } }, [account?.address, savedPaymasters]); + // Saved paymasters are account-scoped (localStorage) — (re)load whenever the account + // resolves, so a fresh page load that beats the account fetch still populates them. + useEffect(() => { + setSavedPaymasters(getAvailablePaymasters(account?.address)); + }, [account?.address]); + // Judge the transfer (tier + required signatures, ETH/ERC-20 unified) whenever the // amount or token changes, debounced. Drives the indicator + fail-fast on block. useEffect(() => { @@ -244,29 +256,17 @@ export default function TransferPage() { const loadPageData = async () => { setLoading(prev => ({ ...prev, page: true })); try { - // Load saved paymasters - try { - const [paymasterResponse, presetResponse] = await Promise.all([ - paymasterAPI.getAvailable(), - paymasterAPI.getPresets().catch(() => ({ data: [] })), - ]); - setPaymasterPresets((presetResponse.data ?? []) as any[]); - setSavedPaymasters((paymasterResponse.data ?? []) as any[]); - // The persisted default paymaster is applied by a dedicated effect once the - // account address (its storage scope) and the saved list are both ready. - } catch (error) { - console.error("Failed to load saved paymasters:", error); - setSavedPaymasters([]); - } + // Presets are account-independent (SDK canonical). Saved paymasters are + // account-scoped and loaded by a dedicated effect (below) once the account resolves. + setPaymasterPresets(getPaymasterPresets()); - // Load address book + recent transfer recipients + // Load address book (client-side store) + recent transfer recipients (history API) try { - const [addressBookResponse, historyResponse] = await Promise.all([ - addressBookAPI.getAddressBook().catch(() => ({ data: [] })), - transferAPI.getHistory(1, 50).catch(() => ({ data: { transfers: [] } })), - ]); + const historyResponse = await transferAPI + .getHistory(1, 50) + .catch(() => ({ data: { transfers: [] } })); - const bookEntries = addressBookResponse.data || []; + const bookEntries = getAddressBook(account?.address); const bookAddresses = new Set(bookEntries.map((e: any) => e.address.toLowerCase())); // Extract unique recent recipients not already in address book @@ -300,15 +300,14 @@ export default function TransferPage() { }; const loadTokenBalance = async (token: Token | null) => { - if (!token || token.address === "ETH") { + if (!token || token.address === "ETH" || !account?.address) { setTokenBalance(null); return; } setLoadingTokenBalance(true); try { - const response = await tokenAPI.getTokenBalance(token.address); - setTokenBalance(response.data); + setTokenBalance(await getTokenBalance(account.address, token.address)); } catch (error) { console.error("Failed to load token balance:", error); setTokenBalance(null); @@ -561,6 +560,10 @@ export default function TransferPage() { } toast.success("Transfer submitted! Tracking status..."); + // Capture the recipient before the form is cleared so the address-book record + // (on confirmation) has it. + pendingRecipientRef.current = formData.to; + // Start polling for status startStatusPolling(response.data.transferId); @@ -684,6 +687,14 @@ export default function TransferPage() { // Only show toast once when polling was active if (wasPolling) { if (response.data.status === "completed") { + // Record the confirmed recipient in the client-side address book (usage++, + // keep tx hash) — replaces the old server-side recordSuccessfulTransfer. + recordSuccessfulTransfer( + account?.address, + pendingRecipientRef.current, + response.data.transactionHash || "" + ); + pendingRecipientRef.current = ""; toast.success("Transfer completed successfully!"); } else { toast.error(`Transfer failed: ${response.data.error || "Unknown error"}`); @@ -807,11 +818,10 @@ export default function TransferPage() { const name = prompt("Enter a name for this address (optional):"); try { - await addressBookAPI.setAddressName(formData.to, name || ""); + setAddressName(account?.address, formData.to, name || ""); // Refresh address book - const addressBookResponse = await addressBookAPI.getAddressBook(); - setAddressBook(addressBookResponse.data); + setAddressBook(getAddressBook(account?.address)); toast.success("Address saved to address book! 📖"); } catch (error: any) { diff --git a/aastar-frontend/components/Layout.tsx b/aastar-frontend/components/Layout.tsx index 761bf91..3ee369a 100644 --- a/aastar-frontend/components/Layout.tsx +++ b/aastar-frontend/components/Layout.tsx @@ -199,6 +199,7 @@ export default function Layout({ children, requireAuth = false }: LayoutProps) { { title: "Settings", items: [ + { label: "Settings", path: "/settings", Icon: Cog6ToothIcon }, { label: "Tokens", path: "/tokens", Icon: WalletIcon }, { label: "NFTs", path: "/nfts", Icon: BookOpenIcon }, { label: "Address Book", path: "/address-book", Icon: BookOpenIcon }, diff --git a/aastar-frontend/components/TokenSelector.tsx b/aastar-frontend/components/TokenSelector.tsx index 626714d..434ec98 100644 --- a/aastar-frontend/components/TokenSelector.tsx +++ b/aastar-frontend/components/TokenSelector.tsx @@ -10,7 +10,7 @@ import { MagnifyingGlassIcon, } from "@heroicons/react/24/outline"; import { Token, UserTokenWithBalance } from "@/lib/types"; -import { userTokenAPI } from "@/lib/api"; +import { getUserTokens, addUserToken, initializeDefaultTokens } from "@/lib/user-token-store"; import TokenIcon from "@/components/TokenIcon"; import toast from "react-hot-toast"; @@ -77,30 +77,17 @@ export default function TokenSelector({ const loadTokens = async () => { setLoading(true); try { - // Load user tokens with balances - const response = await userTokenAPI.getUserTokens({ - activeOnly: true, - withBalances: !!accountAddress && showBalances, - }); - setUserTokens(response.data); - } catch (error: any) { - if (error.response?.status === 404) { - // User has no tokens yet, initialize with defaults - try { - await userTokenAPI.initializeDefaultTokens(); - const response = await userTokenAPI.getUserTokens({ - activeOnly: true, - withBalances: !!accountAddress && showBalances, - }); - setUserTokens(response.data); - } catch (initError) { - console.error("Failed to initialize tokens:", initError); - toast.error("Failed to load tokens"); - } - } else { - console.error("Failed to load tokens:", error); - toast.error("Failed to load tokens"); + const opts = { activeOnly: true, withBalances: !!accountAddress && showBalances }; + let tokens = await getUserTokens(accountAddress, opts); + // First use: seed the default preset tokens, then reload. + if (tokens.length === 0) { + initializeDefaultTokens(accountAddress); + tokens = await getUserTokens(accountAddress, opts); } + setUserTokens(tokens); + } catch (error) { + console.error("Failed to load tokens:", error); + toast.error("Failed to load tokens"); } finally { setLoading(false); } @@ -111,11 +98,7 @@ export default function TokenSelector({ setBalancesLoading(true); try { - const response = await userTokenAPI.getUserTokens({ - activeOnly: true, - withBalances: true, - }); - setUserTokens(response.data); + setUserTokens(await getUserTokens(accountAddress, { activeOnly: true, withBalances: true })); } catch (error) { console.error("Failed to load token balances:", error); // Don't show error toast for balances, as it's supplementary info @@ -132,8 +115,8 @@ export default function TokenSelector({ setValidatingToken(true); try { - const response = await userTokenAPI.addUserToken({ address: customTokenAddress }); - toast.success(`Added ${response.data.symbol} token`); + const token = await addUserToken(accountAddress, customTokenAddress); + toast.success(`Added ${token.symbol} token`); setCustomTokenAddress(""); setShowCustomTokenModal(false); await loadTokens(); diff --git a/aastar-frontend/contexts/DashboardContext.tsx b/aastar-frontend/contexts/DashboardContext.tsx index af7b30a..f6101c1 100644 --- a/aastar-frontend/contexts/DashboardContext.tsx +++ b/aastar-frontend/contexts/DashboardContext.tsx @@ -2,7 +2,10 @@ import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react"; import { Account, Transfer, TokenBalance } from "@/lib/types"; -import { accountAPI, transferAPI, paymasterAPI, tokenAPI, userTokenAPI } from "@/lib/api"; +import { accountAPI, transferAPI } from "@/lib/api"; +import { getTokenBalance } from "@/lib/token-balance"; +import { getUserTokens } from "@/lib/user-token-store"; +import { getAvailablePaymasters } from "@/lib/paymaster-store"; import { getStoredAuth } from "@/lib/auth"; interface DashboardData { @@ -126,11 +129,10 @@ export function DashboardProvider({ children }: { children: ReactNode }) { transfersData = []; } - // Get available paymasters + // Get available paymasters (client-side store) let paymastersData: any[] = []; try { - const paymasterResponse = await paymasterAPI.getAvailable(); - paymastersData = paymasterResponse.data; + paymastersData = getAvailablePaymasters(accountData?.address); } catch { paymastersData = []; } @@ -139,15 +141,13 @@ export function DashboardProvider({ children }: { children: ReactNode }) { let tokenBalancesData: TokenBalance[] = []; if (accountData?.address) { try { - // Get user's added tokens - const userTokensResponse = await userTokenAPI.getUserTokens({}); - const userTokens = userTokensResponse.data; + // Get user's added tokens (client-side store) + const userTokens = await getUserTokens(accountData.address, {}); - // Get balance for each user token + // Get balance for each user token (client-side on-chain read) const balancePromises = userTokens.map(async (userToken: any) => { try { - const balanceResponse = await tokenAPI.getTokenBalance(userToken.address); - return balanceResponse.data; + return await getTokenBalance(accountData.address, userToken.address); } catch (error) { console.error(`Failed to get balance for ${userToken.symbol}:`, error); return null; @@ -202,15 +202,13 @@ export function DashboardProvider({ children }: { children: ReactNode }) { let tokenBalancesData: TokenBalance[] = []; if (accountResponse.data?.address) { try { - // Get user's added tokens - const userTokensResponse = await userTokenAPI.getUserTokens({}); - const userTokens = userTokensResponse.data; + // Get user's added tokens (client-side store) + const userTokens = await getUserTokens(accountResponse.data.address, {}); - // Get balance for each user token + // Get balance for each user token (client-side on-chain read) const balancePromises = userTokens.map(async (userToken: any) => { try { - const balanceResponse = await tokenAPI.getTokenBalance(userToken.address); - return balanceResponse.data; + return await getTokenBalance(accountResponse.data.address, userToken.address); } catch (error) { console.error(`Failed to get balance for ${userToken.symbol}:`, error); return null; diff --git a/aastar-frontend/lib/address-book-store.ts b/aastar-frontend/lib/address-book-store.ts new file mode 100644 index 0000000..97e49f7 --- /dev/null +++ b/aastar-frontend/lib/address-book-store.ts @@ -0,0 +1,127 @@ +/** + * Client-side address book (zero-backend migration, step 1). + * + * Replaces the backend `/address-book*` endpoints + JSON store with per-account + * localStorage. Mirrors the previous server logic exactly (sort by usage then recency, + * keep the last 5 tx hashes, case-insensitive address match). Scoped by AA account + * address — a small semantic change from the old per-JWT-user store, consistent with + * how the default-paymaster preference is scoped. + */ +export interface AddressBookEntry { + address: string; + name?: string; + lastUsed: string; // ISO timestamp + usageCount: number; + firstUsed: string; // ISO timestamp + transactionHashes: string[]; +} + +const BASE_KEY = "yaa.addressBook"; + +function storageKey(account?: string | null): string { + return account ? `${BASE_KEY}:${account.toLowerCase()}` : BASE_KEY; +} + +function load(account?: string | null): AddressBookEntry[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(storageKey(account)); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function save(account: string | null | undefined, entries: AddressBookEntry[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey(account), JSON.stringify(entries)); + } catch { + /* private mode / quota — best effort */ + } +} + +/** Frequently-used addresses, sorted by usage count then recency (matches old backend). */ +export function getAddressBook(account?: string | null): AddressBookEntry[] { + return load(account).sort((a, b) => { + if (a.usageCount !== b.usageCount) return b.usageCount - a.usageCount; + return new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime(); + }); +} + +/** Add or update a display name for an address (creates a zero-usage entry if new). */ +export function setAddressName( + account: string | null | undefined, + address: string, + name: string +): void { + const entries = load(account); + const entry = entries.find(e => e.address.toLowerCase() === address.toLowerCase()); + if (entry) { + entry.name = name; + } else { + const now = new Date().toISOString(); + entries.push({ + address, + name, + lastUsed: now, + firstUsed: now, + usageCount: 0, + transactionHashes: [], + }); + } + save(account, entries); +} + +/** Remove an address; returns true if something was removed. */ +export function removeAddress(account: string | null | undefined, address: string): boolean { + const entries = load(account); + const filtered = entries.filter(e => e.address.toLowerCase() !== address.toLowerCase()); + if (filtered.length < entries.length) { + save(account, filtered); + return true; + } + return false; +} + +/** Partial match over address or name. */ +export function searchAddresses( + account: string | null | undefined, + query: string +): AddressBookEntry[] { + const q = query.toLowerCase(); + return getAddressBook(account).filter( + e => e.address.toLowerCase().includes(q) || (e.name && e.name.toLowerCase().includes(q)) + ); +} + +/** Record a confirmed transfer to an address (usage++, keep the last 5 tx hashes). */ +export function recordSuccessfulTransfer( + account: string | null | undefined, + toAddress: string, + transactionHash: string +): void { + if (!toAddress) return; + const entries = load(account); + const now = new Date().toISOString(); + const entry = entries.find(e => e.address.toLowerCase() === toAddress.toLowerCase()); + if (entry) { + entry.lastUsed = now; + entry.usageCount += 1; + if (transactionHash) { + entry.transactionHashes.unshift(transactionHash); + entry.transactionHashes = entry.transactionHashes.slice(0, 5); + } + } else { + entries.push({ + address: toAddress, + lastUsed: now, + firstUsed: now, + usageCount: 1, + transactionHashes: transactionHash ? [transactionHash] : [], + }); + } + save(account, entries); +} diff --git a/aastar-frontend/lib/api-key-store.ts b/aastar-frontend/lib/api-key-store.ts new file mode 100644 index 0000000..06e4d4d --- /dev/null +++ b/aastar-frontend/lib/api-key-store.ts @@ -0,0 +1,75 @@ +/** + * Client-side AAStar API-key store (zero-backend migration — foundation prep). + * + * The zero-backend model authorizes the browser's KMS + bundler calls with the user's + * OWN API key (a free-tier or provided key, scoped to their wallet / community identity) + * instead of a shared server secret. This holds that key (and optional endpoint overrides) + * in localStorage so the direct-KMS / direct-bundler paths can read it once the flows are + * switched over. Nothing load-bearing yet — the transfer/auth flows still use the backend + * until the KMS Origin+API-key infra is live. + * + * Per-device (not per-account): the key is the user's credential for this install. + */ +const API_KEY = "yaa.apiKey"; +const KMS_URL_KEY = "yaa.kmsUrl"; +const BUNDLER_URL_KEY = "yaa.bundlerUrl"; + +function get(key: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function set(key: string, value: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, value); + } catch { + /* private mode / quota — best effort */ + } +} + +function remove(key: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(key); + } catch { + /* ignore */ + } +} + +export function getApiKey(): string | null { + return get(API_KEY); +} +export function setApiKey(key: string): void { + set(API_KEY, key.trim()); +} +export function clearApiKey(): void { + remove(API_KEY); +} +export function hasApiKey(): boolean { + return !!getApiKey(); +} + +/** Optional KMS endpoint override (blank → default). */ +export function getKmsUrl(): string | null { + return get(KMS_URL_KEY); +} +export function setKmsUrl(url: string): void { + const u = url.trim(); + if (u) set(KMS_URL_KEY, u); + else remove(KMS_URL_KEY); +} + +/** Optional bundler endpoint override (blank → default). */ +export function getBundlerUrl(): string | null { + return get(BUNDLER_URL_KEY); +} +export function setBundlerUrl(url: string): void { + const u = url.trim(); + if (u) set(BUNDLER_URL_KEY, u); + else remove(BUNDLER_URL_KEY); +} diff --git a/aastar-frontend/lib/api.ts b/aastar-frontend/lib/api.ts index 2d37171..85064a0 100644 --- a/aastar-frontend/lib/api.ts +++ b/aastar-frontend/lib/api.ts @@ -168,33 +168,15 @@ export const transferAPI = { }; // BLS API -export const blsAPI = { - getNodes: () => api.get("/bls/nodes"), - - generateSignature: (data: { userOpHash: string; nodeIndices?: number[] }) => - api.post("/bls/sign", data), -}; +// blsAPI removed — the frontend never called it; BLS runs inside the backend transfer +// flow and moves client-side (direct to the BLS gossip network) with the transfer +// migration (step 4). Zero-backend migration step 3 (bls cleanup). // Paymaster API -export const paymasterAPI = { - getAvailable: () => api.get("/paymaster/available"), - - // Recommended presets (addresses sourced from @aastar/sdk canonical table) - getPresets: () => api.get("/paymaster/presets"), - - sponsor: (data: { paymasterName: string; userOp: any; entryPoint?: string }) => - api.post("/paymaster/sponsor", data), - - addCustom: (data: { - name: string; - address: string; - type?: "pimlico" | "stackup" | "alchemy" | "custom"; - apiKey?: string; - endpoint?: string; - }) => api.post("/paymaster/add", data), - - remove: (name: string) => api.delete(`/paymaster/${name}`), -}; +// paymasterAPI removed — the saved paymaster list + presets are now client-side +// (lib/paymaster-store.ts, localStorage + SDK canonical). Zero-backend migration step 2. +// (Sponsorship still runs in the backend transfer flow using the passed address; it +// moves client-side with the transfer migration, step 4.) // Token API export const tokenAPI = { @@ -204,7 +186,7 @@ export const tokenAPI = { validateToken: (data: { address: string }) => api.post("/tokens/validate", data), - getTokenBalance: (address: string) => api.get(`/tokens/balance/${address}`), + // getTokenBalance moved to a client-side on-chain read (lib/token-balance.ts). Step 1b. getTokenBalances: (accountAddress?: string) => { const params = accountAddress ? { address: accountAddress } : {}; @@ -224,40 +206,8 @@ export const tokenAPI = { api.get("/tokens/search", { params }), }; -// User Token API -export const userTokenAPI = { - getUserTokens: (params?: { activeOnly?: boolean; withBalances?: boolean }) => - api.get("/user-tokens", { params }), - - addUserToken: (data: { - address: string; - symbol?: string; - name?: string; - decimals?: number; - logoUrl?: string; - }) => api.post("/user-tokens", data), - - updateUserToken: ( - tokenId: string, - data: { - isActive?: boolean; - sortOrder?: number; - logoUrl?: string; - } - ) => api.put(`/user-tokens/${tokenId}`, data), - - removeUserToken: (tokenId: string) => api.delete(`/user-tokens/${tokenId}`), - - deleteUserToken: (tokenId: string) => api.delete(`/user-tokens/${tokenId}/permanent`), - - searchUserTokens: (params: { query?: string; customOnly?: boolean; activeOnly?: boolean }) => - api.get("/user-tokens/search", { params }), - - initializeDefaultTokens: () => api.post("/user-tokens/initialize-defaults"), - - updateTokensOrder: (tokenOrders: { tokenId: string; sortOrder: number }[]) => - api.put("/user-tokens/reorder", { tokenOrders }), -}; +// userTokenAPI removed — the user token list is now a client-side store +// (lib/user-token-store.ts, localStorage, account-scoped). Zero-backend migration step 1c. // Guardian & Recovery API export const guardianAPI = { @@ -293,13 +243,8 @@ export const guardianAPI = { }) => api.post("/guardian/recovery/p256/submit", data), }; -export const addressBookAPI = { - getAddressBook: () => api.get("/address-book"), - setAddressName: (address: string, name: string) => - api.post("/address-book/name", { address, name }), - removeAddress: (address: string) => api.delete(`/address-book/${address}`), - searchAddresses: (query: string) => api.get("/address-book/search", { params: { q: query } }), -}; +// addressBookAPI removed — the address book is now a client-side store +// (lib/address-book-store.ts, localStorage, account-scoped). Zero-backend migration step 1. // User NFT API export const userNFTAPI = { diff --git a/aastar-frontend/lib/kms-client.ts b/aastar-frontend/lib/kms-client.ts index 8a8b08d..20dfae8 100644 --- a/aastar-frontend/lib/kms-client.ts +++ b/aastar-frontend/lib/kms-client.ts @@ -5,6 +5,7 @@ * Signing operations go through the backend (which calls KMS internally), * but WebAuthn ceremonies must happen in the browser. */ +import { getApiKey, getKmsUrl } from "./api-key-store"; // ── Types ──────────────────────────────────────────────────────── @@ -208,3 +209,42 @@ export class KmsClient { }; } } + +// ── Direct-KMS seam (zero-backend migration — foundation prep) ──────────────── +// +// Today the app reaches KMS through the server-side `/kms-api` proxy (which injects the +// shared KMS_API_KEY). The zero-backend target is to call KMS DIRECTLY from the browser, +// authorized by the user's OWN API key (from api-key-store) + the browser Origin. These +// helpers wire the existing KmsClient to that model. NOT yet used by the transfer/auth +// flows — they keep using `/kms-api` until the KMS Origin+API-key path is live (revised +// plan step 2). The Settings page uses them to configure + test the key. + +export const DEFAULT_KMS_URL = "https://kms.aastar.io"; + +/** The KMS base URL: a user override (Settings), else the build-time default. */ +export function kmsBaseUrl(): string { + return getKmsUrl() || process.env.NEXT_PUBLIC_KMS_URL || DEFAULT_KMS_URL; +} + +/** True once the user has supplied an API key (the direct-KMS path is configured). */ +export function isDirectKmsReady(): boolean { + return !!getApiKey(); +} + +/** A KmsClient wired for DIRECT browser→KMS access with the user's API key. */ +export function directKmsClient(): KmsClient { + return new KmsClient(kmsBaseUrl(), getApiKey() ?? undefined); +} + +/** + * Best-effort KMS health probe — no infra assumption. Settings uses it to sanity-check the + * endpoint (and, once Origin-auth is live, whether the browser is allowed). Never throws. + */ +export async function pingKms(): Promise<{ ok: boolean; status?: number; error?: string }> { + try { + const res = await fetch(`${kmsBaseUrl().replace(/\/$/, "")}/health`, { method: "GET" }); + return { ok: res.ok, status: res.status }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/aastar-frontend/lib/paymaster-store.ts b/aastar-frontend/lib/paymaster-store.ts new file mode 100644 index 0000000..0870e50 --- /dev/null +++ b/aastar-frontend/lib/paymaster-store.ts @@ -0,0 +1,142 @@ +/** + * Client-side paymaster store + presets (zero-backend migration, step 2). + * + * The saved paymaster list was a frontend convenience: the transfer flow passes the + * chosen `paymasterAddress` to the backend, which sponsors from that address (the saved + * list / name lookup was never used for sponsorship, and `paymasterAPI.sponsor` was + * unused). So the list moves to per-account localStorage, and the recommended presets + * are built client-side from the SDK canonical table — no backend paymaster endpoints. + * + * Sponsorship itself still runs in the backend transfer flow (it uses the passed + * address); it moves client-side with the transfer migration (step 4). + */ +import { getCanonicalAddresses, CHAIN_SEPOLIA } from "@aastar/sdk/core"; + +export interface SavedPaymaster { + name: string; + address: string; + configured: boolean; +} + +export interface PaymasterPreset { + name: string; + address: string; + type: "custom"; + recommended: boolean; + requiresCommunity: boolean; + gasToken: string; + gasTokenAddress: string | null; + description: string; +} + +interface StoredPaymaster { + name: string; + address: string; + type?: string; + apiKey?: string; + endpoint?: string; +} + +const BASE_KEY = "yaa.paymasters"; + +function storageKey(account?: string | null): string { + return account ? `${BASE_KEY}:${account.toLowerCase()}` : BASE_KEY; +} + +function load(account?: string | null): StoredPaymaster[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(storageKey(account)); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function save(account: string | null | undefined, list: StoredPaymaster[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey(account), JSON.stringify(list)); + } catch { + /* private mode / quota — best effort */ + } +} + +/** The account's saved paymasters. `configured` = has an API key (vs address-only). */ +export function getAvailablePaymasters(account?: string | null): SavedPaymaster[] { + return load(account).map(p => ({ + name: p.name, + address: p.address, + configured: !!p.apiKey, + })); +} + +/** Add (or update, keyed by address) a saved paymaster. */ +export function addCustomPaymaster( + account: string | null | undefined, + data: { name: string; address: string; type?: string; apiKey?: string; endpoint?: string } +): void { + const list = load(account); + const idx = list.findIndex(p => p.address.toLowerCase() === data.address.toLowerCase()); + const entry: StoredPaymaster = { + name: data.name, + address: data.address, + type: data.type, + apiKey: data.apiKey, + endpoint: data.endpoint, + }; + if (idx >= 0) list[idx] = entry; + else list.push(entry); + save(account, list); +} + +/** Remove a saved paymaster by name; returns true if something was removed. */ +export function removeCustomPaymaster(account: string | null | undefined, name: string): boolean { + const list = load(account); + const next = list.filter(p => p.name !== name); + if (next.length < list.length) { + save(account, next); + return true; + } + return false; +} + +/** + * Recommended presets, addresses from the SDK canonical table (mirrors the old backend + * getPaymasterPresets copy verbatim). PaymasterV4 is a template (AAStar's instance, + * pay with aPNTs); SuperPaymaster is one shared contract for any community's xPNTs. + */ +export function getPaymasterPresets(): PaymasterPreset[] { + const a = getCanonicalAddresses(CHAIN_SEPOLIA) as Record | undefined; + if (!a) return []; + const presets: PaymasterPreset[] = []; + if (a.paymasterV4) { + presets.push({ + name: "AAStar PaymasterV4", + address: a.paymasterV4, + type: "custom", + recommended: true, + requiresCommunity: false, + gasToken: "aPNTs", + gasTokenAddress: a.aPNTs ?? null, + description: + "PaymasterV4 is a template — any community can deploy its own V4 (own gas token + deposit) via the factory, so there can be many. This is the AAStar community's instance: buy aPNTs and it sponsors your gas. You're in the default AAStar community, so no separate join is needed — you just need aPNTs.", + }); + } + if (a.superPaymaster) { + presets.push({ + name: "SuperPaymaster", + address: a.superPaymaster, + type: "custom", + recommended: false, + requiresCommunity: true, + gasToken: "xPNTs (community points)", + gasTokenAddress: null, + description: + "A single shared paymaster that accepts ANY community's points (xPNTs). Requires joining a community and earning its points by completing tasks — it will not work until you hold that community's points.", + }); + } + return presets; +} diff --git a/aastar-frontend/lib/token-balance.ts b/aastar-frontend/lib/token-balance.ts new file mode 100644 index 0000000..a58f082 --- /dev/null +++ b/aastar-frontend/lib/token-balance.ts @@ -0,0 +1,68 @@ +/** + * Client-side ERC-20 balance read (zero-backend migration, step 1b). + * + * Replaces the backend `GET /tokens/balance/:address` endpoint with a direct on-chain + * read via viem, returning the same `TokenBalance` shape the UI already consumes. Uses + * a lazily-created public client (same Sepolia config as the transfer flow) so callers + * only pass the account + token address. + */ +import { erc20Abi, formatUnits, type PublicClient } from "viem"; +import { CHAIN_SEPOLIA } from "@aastar/sdk/core"; +import { ensureSdkConfig, getPublicClient } from "@/lib/sdk/client"; +import { Token, TokenBalance } from "@/lib/types"; + +let cachedClient: PublicClient | null = null; + +function publicClient(): PublicClient { + if (!cachedClient) { + ensureSdkConfig(CHAIN_SEPOLIA); + cachedClient = getPublicClient(); + } + return cachedClient; +} + +/** Read an ERC-20 balance (+ decimals/symbol/name) for `accountAddress` on-chain. */ +export async function getTokenBalance( + accountAddress: string, + tokenAddress: string +): Promise { + const pc = publicClient(); + const token = tokenAddress as `0x${string}`; + const owner = accountAddress as `0x${string}`; + const [raw, decimalsRaw, symbol, name] = await Promise.all([ + pc.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }), + pc.readContract({ address: token, abi: erc20Abi, functionName: "decimals" }), + pc.readContract({ address: token, abi: erc20Abi, functionName: "symbol" }), + pc.readContract({ address: token, abi: erc20Abi, functionName: "name" }), + ]); + const decimals = Number(decimalsRaw); + const tokenInfo: Token = { + address: tokenAddress, + symbol: symbol as string, + name: name as string, + decimals, + }; + return { + token: tokenInfo, + balance: (raw as bigint).toString(), + formattedBalance: formatUnits(raw as bigint, decimals), + decimals, + }; +} + +/** Read only an ERC-20's metadata (no balance) — used when adding a custom token. */ +export async function getTokenMetadata(tokenAddress: string): Promise { + const pc = publicClient(); + const token = tokenAddress as `0x${string}`; + const [decimalsRaw, symbol, name] = await Promise.all([ + pc.readContract({ address: token, abi: erc20Abi, functionName: "decimals" }), + pc.readContract({ address: token, abi: erc20Abi, functionName: "symbol" }), + pc.readContract({ address: token, abi: erc20Abi, functionName: "name" }), + ]); + return { + address: tokenAddress, + symbol: symbol as string, + name: name as string, + decimals: Number(decimalsRaw), + }; +} diff --git a/aastar-frontend/lib/user-token-store.ts b/aastar-frontend/lib/user-token-store.ts new file mode 100644 index 0000000..06b44e2 --- /dev/null +++ b/aastar-frontend/lib/user-token-store.ts @@ -0,0 +1,166 @@ +/** + * Client-side user token list (zero-backend migration, step 1c). + * + * Replaces the backend `/user-tokens*` endpoints + DB with per-account localStorage. + * Only the three methods the UI actually used are implemented: getUserTokens, + * addUserToken, initializeDefaultTokens. Balances are attached via the client-side + * on-chain reader (step 1b), never from a server. + * + * NOTE: the default token list mirrors the old backend PRESET_TOKENS verbatim — they + * are Optimism (chainId 10) contracts, a pre-existing mismatch with this Sepolia app + * (their balances won't resolve on Sepolia). Preserved as-is to keep the migration + * behavior-identical; fixing the default set to Sepolia tokens is a separate change. + */ +import { UserToken, UserTokenWithBalance } from "@/lib/types"; +import { getTokenBalance, getTokenMetadata } from "@/lib/token-balance"; + +const BASE_KEY = "yaa.userTokens"; + +// Verbatim copy of the backend PRESET_TOKENS (address/symbol/name/decimals/logoUrl/chainId). +const DEFAULT_TOKENS: Omit[] = + [ + { + address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + symbol: "USDT", + name: "Tether USD", + decimals: 6, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/825.png", + isCustom: false, + chainId: 10, + }, + { + address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/3408.png", + isCustom: false, + chainId: 10, + }, + { + address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + symbol: "DAI", + name: "Dai Stablecoin", + decimals: 18, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/4943.png", + isCustom: false, + chainId: 10, + }, + { + address: "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", + symbol: "LINK", + name: "ChainLink Token", + decimals: 18, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/1975.png", + isCustom: false, + chainId: 10, + }, + { + address: "0x6fd9d7AD17242c41f7131d257212c54A0e816691", + symbol: "UNI", + name: "Uniswap Token", + decimals: 18, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/7083.png", + isCustom: false, + chainId: 10, + }, + { + address: "0x4200000000000000000000000000000000000042", + symbol: "OP", + name: "Optimism", + decimals: 18, + logoUrl: "https://s2.coinmarketcap.com/static/img/coins/32x32/11840.png", + isCustom: false, + chainId: 10, + }, + ]; + +function storageKey(account?: string | null): string { + return account ? `${BASE_KEY}:${account.toLowerCase()}` : BASE_KEY; +} + +function load(account?: string | null): UserToken[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(storageKey(account)); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function save(account: string | null | undefined, tokens: UserToken[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey(account), JSON.stringify(tokens)); + } catch { + /* private mode / quota — best effort */ + } +} + +/** Seed the default preset tokens (idempotent — no-op if the store is non-empty). */ +export function initializeDefaultTokens(account?: string | null): UserToken[] { + const existing = load(account); + if (existing.length) return existing; + const now = new Date().toISOString(); + const tokens: UserToken[] = DEFAULT_TOKENS.map((t, i) => ({ + ...t, + id: `${t.address}-${i}`, + userId: "local", + isActive: true, + sortOrder: i, + createdAt: now, + })); + save(account, tokens); + return tokens; +} + +/** List the account's tokens (sorted by sortOrder); optionally active-only and with on-chain balances. */ +export async function getUserTokens( + account: string | null | undefined, + opts?: { activeOnly?: boolean; withBalances?: boolean } +): Promise { + const tokens = load(account); + const filtered = (opts?.activeOnly ? tokens.filter(t => t.isActive) : tokens) + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder); + if (opts?.withBalances && account) { + return Promise.all( + filtered.map(async t => { + try { + return { ...t, balance: await getTokenBalance(account, t.address) }; + } catch { + return { ...t }; + } + }) + ); + } + return filtered; +} + +/** Add a custom token by address (metadata resolved on-chain). Returns the existing entry if already present. */ +export async function addUserToken( + account: string | null | undefined, + address: string +): Promise { + const tokens = load(account); + const existing = tokens.find(t => t.address.toLowerCase() === address.toLowerCase()); + if (existing) return existing; + const meta = await getTokenMetadata(address); + const token: UserToken = { + id: `${meta.address}-${tokens.length}-custom`, + userId: "local", + address: meta.address, + symbol: meta.symbol, + name: meta.name, + decimals: meta.decimals, + isCustom: true, + isActive: true, + sortOrder: tokens.length, + createdAt: new Date().toISOString(), + }; + save(account, [...tokens, token]); + return token; +} diff --git a/docs/PURE_FRONTEND_MIGRATION.md b/docs/PURE_FRONTEND_MIGRATION.md new file mode 100644 index 0000000..8513b5d --- /dev/null +++ b/docs/PURE_FRONTEND_MIGRATION.md @@ -0,0 +1,294 @@ +# YAA → Pure Frontend (Zero-Backend) Migration + +**Branch:** `refactor/pure-frontend` (off `feat/tier3-production-webauthn`) +**Goal:** incrementally remove the NestJS backend (`aastar/`) so YAA becomes a +**pure client-side app** — no server, no server-held secrets, no server DB — +that can then be packaged as a **Chrome extension (MV3)**, a **React Native** +app, or a **single-file embeddable widget**. + +Target confirmed: **true zero-backend** (not an edge-proxy compromise). + +--- + +## Status: 🟡 WIP — PAUSED (2026-07-01) + +The **storage-class migrations** and the **frontend foundation prep** are done +(~25% overall). Everything left is the **KMS/auth-rooted core** (auth, account, +transfer, guardian) and depends on **KMS/bundler infra changes that don't exist +yet** (see +[Infra requirements](#infra-requirements-blocking--for-the-kms--bundler-teams)). +The remaining work is large, so this branch is **paused**: resume once the infra +lands and other priorities clear. Branch `refactor/pure-frontend` / draft PR +#400 stay open. + +**Progress** + +| Area | % | Note | +| --------------------------------------------------------------------- | -------: | ---------------------------------------- | +| Storage-class data (address book, token balance/list, paymaster list) | ~85% | localStorage / on-chain; done | +| Frontend foundation prep (API-key store, KMS-direct seam, Settings) | done | not yet wired into flows | +| KMS/auth foundation (Origin-direct + API-key + passkey session) | ~15% | prep only; blocked on infra | +| Core path (auth / account / transfer / guardian) | ~5% | 0 flows moved; blocked | +| Delete `aastar/` + `output:'export'` | 0% | final step | +| **Overall toward true zero-backend** | **~25%** | app still cannot run without the backend | + +### Done (this branch) + +| Commit | What | +| ------- | --------------------------------------------------------------------------- | +| step 1a | Address book → `lib/address-book-store.ts` (localStorage, per-account) | +| step 1b | Token balance → `lib/token-balance.ts` (viem on-chain read) | +| step 1c | User token list → `lib/user-token-store.ts` (localStorage) | +| step 2 | Paymaster saved-list + presets → `lib/paymaster-store.ts` | +| step 3 | Dead `blsAPI` removed | +| prep | `lib/api-key-store.ts` + `lib/kms-client.ts` seam + `app/settings/page.tsx` | + +Removed from `lib/api.ts`: `addressBookAPI`, `userTokenAPI`, `paymasterAPI`, +`blsAPI`, `tokenAPI.getTokenBalance`. + +### Left to do (ordered — foundation first) + +1. **Infra** (other teams — see below): KMS Origin trust for non-`aastar.io` + shells + production per-user-API-key auth + CORS; same for the bundler. +2. **Wire direct-KMS** in the frontend (the `lib/kms-client.ts` seam) once infra + is live; retire `app/kms-api`. +3. **Auth root**: passkey/KMS session instead of backend JWT. +4. **account** (`getAccount` via client-side address derivation + create), + **transfer** (client-side KMS sign + BLS + paymaster + bundler), **guardian** + recovery. +5. Delete `aastar/`; flip `output: 'export'`; package (Chrome MV3 / RN / + single-file). + +--- + +## Infra requirements (blocking — for the KMS / bundler teams) + +To let the browser talk to KMS/bundler **directly** (true zero-backend), the +following must exist on the service side. These are **not** frontend edits. + +1. **KMS Origin trust (rp.id / allowed-origins).** KMS resolves rp.id and runs + an allowed-origin check from the request Origin. Today it trusts + `https://*.aastar.io` (so `cos72.aastar.io` already works via rpId + `aastar.io`). For other shells the Origin must be added: **Chrome extension** + (`chrome-extension://`), **local/dev** (`http://localhost:*`), **other + communities' own domains**. — _This is the part the team already understands; + it's necessary but not sufficient._ +2. **KMS accepts a per-user API key from the browser in PRODUCTION.** Today the + browser goes through the server-side `/kms-api` proxy, which injects the + shared `KMS_API_KEY`; a client-supplied `x-api-key` is honored **only outside + production**. For direct calls the production KMS must authorize a **user's + own API key** (free tier / provided) or an **SBT identity bound to their + wallet address**. Without this, the browser cannot reach production KMS + without the server proxy. +3. **KMS CORS.** Direct browser→KMS is cross-origin; KMS must return CORS + headers allowing the app Origin (`Access-Control-Allow-Origin` + preflight). + Via the same-origin proxy this isn't needed; direct calls require it. (Our + `Settings → Test KMS connection` probe will keep failing until this is + enabled — that failure is the readiness signal.) +4. **Bundler: same two.** Direct browser→bundler must accept the user's key (not + expose a shared `PIMLICO_API_KEY`) and send CORS headers — or provide a + public/sponsored endpoint usable from the browser. +5. **API-key onboarding.** A flow for a user to obtain a free-tier key + (self-apply or provided), with paid / aPoints upgrade for higher volume. The + frontend already has the input (`Settings`) + storage + (`lib/api-key-store.ts`); it needs a real key to store. + +Until 1–4 exist, the core flows stay on the backend and the migration cannot +proceed past the foundation prep. + +--- + +## Why this is possible + +The frontend already talks to `@aastar/sdk` directly in ~29 places (`core` / +`airaccount` / `kms` / `tokens` / `operator`). Most backend modules are thin +orchestration over that same SDK + the remote KMS / BLS / bundler / Paymaster +services. The backend is mostly a **trusted secret-holder + JSON store**, not +irreplaceable logic. + +--- + +## Hard constraints (server-only secrets) and their zero-backend resolution + +| Secret / server role | Today | Zero-backend resolution | +| -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `KMS_API_KEY` | injected server-side by `app/kms-api/[...path]/route.ts` | KMS already does **Origin allow-listing** (dev-rpid board matches `https://*.aastar.io`). Move to a **browser-direct KMS client** authorized by Origin; drop the key-injecting route. | +| `PIMLICO_API_KEY` (bundler) | backend calls Pimlico | Use a **public / SuperPaymaster-sponsored bundler endpoint**, or an origin-scoped key. No secret in the bundle. | +| `JWT_SECRET`, `USER_ENCRYPTION_KEY` | backend session + at-rest encryption | Auth root becomes the **passkey / KMS** itself (WebAuthn assertion = session proof). Client-side encryption keyed off the passkey/KMS, not a server secret. | +| `RESEND_API_KEY` (email OTP) | backend sends OTP | Drop email/OTP login in the pure-frontend build (passkey is primary), or use a serverless email provider with a public form endpoint. | +| DB (`JsonAdapter` / Postgres) | server file/Postgres | Move user-scoped state (address book, saved paymasters, tx history cache, default-paymaster) to **IndexedDB / localStorage**. On-chain + SDK are the source of truth. | +| dev EOAs (`PRIVATE_KEY*`, `ETH_PRIVATE_KEY`) | test signing | Not needed in a production frontend build. | + +If any single constraint can't be met (e.g. a bundler that _requires_ a secret +key), that becomes the one explicitly-tracked exception — surfaced here, not +silently kept. + +--- + +## Backend module inventory → destination + +Frontend-facing (must be replaced): **auth, account, transfer, paymaster, bls, +guardian, token, address-book**. + +| Module | Frontend endpoints | Destination | +| -------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------- | +| `address-book` | `/address-book*` | IndexedDB (client) | +| `token` / `user-token` | `/tokens/*` | `@aastar/sdk/tokens` + client cache | +| `paymaster` | `/paymaster/*` | `@aastar/sdk` + canonical addresses (partly client already) | +| `bls` | `/bls/*` | direct to BLS gossip network (already a remote service) | +| `account` | `/account/*` | `@aastar/sdk/airaccount` (create/nonce/balance) | +| `transfer` | `/transfer/*` | `@aastar/sdk` (device-passkey path is already half client-side) | +| `guardian` | `/guardian/*` | `@aastar/sdk/airaccount` guardian APIs | +| `auth` | `/auth/*` | passkey/KMS-rooted client session | +| `admin`/`operator`/`sale`/`registry`/`community`/`data-tools`/`email`/`user-nft` | (operator/admin surfaces) | out of scope for the enduser pure-frontend build; keep separate or drop | + +--- + +## Incremental extraction order (low-risk first, one PR each) + +1. **address-book + tokens** — read-mostly, client storage + SDK. Lowest risk; + proves the pattern. +2. **paymaster** — list/presets/sponsor via SDK + canonical (we just refactored + this UI). +3. **account + bls** — creation/nonce/balance + BLS node fetch via SDK. +4. **transfer** — full client-side orchestration (device-passkey path already + client-side). +5. **guardian** — recovery flows via SDK. +6. **auth** — replace JWT-from-backend with passkey/KMS-rooted session; retire + `/api` proxy. +7. **KMS route** — replace `app/kms-api` server route with a browser-direct, + Origin-authorized KMS client. Remove `next.config` `rewrites()` to the + backend. +8. **Delete `aastar/`** from the enduser runtime; flip frontend to + `output: 'export'` (static). This is the deferred static-export step. + +Each step: a module moves to client, the corresponding `lib/api.ts` calls are +replaced, the backend endpoint stops being required, tests/gates stay green, no +regression. + +--- + +## Packaging end-states (after zero-backend + static export) + +- **Chrome extension (MV3):** static assets as the popup/side-panel; passkey + + KMS work over `https://` origins; storage via `chrome.storage` / IndexedDB. +- **React Native:** reuse the SDK + client logic; native WebAuthn/passkey APIs. +- **Single-file embeddable widget:** bundle to a self-contained script/iframe a + host page can drop in. + +--- + +## Decided: the API-key model (resolves the bundler + KMS constraints) + +Each install obtains an **AAStar API key** (free tier with a base service quota; +more usage → paid / buy **aPoints** compute credit). That single key authorizes +**both the bundler and the KMS** from the browser — so the client needs no +AAStar-operated backend. + +Two ways a user gets bundler/KMS access: + +1. **Self-applied free key** — we give them a flow to apply for a free bundler + API key (e.g. Pimlico) and/or the AAStar key. Free tier is enough for normal + use; needs a little technical comfort. +2. **Our provided key, configured locally** — the AAStar-issued key (bundler + + KMS in one) stored client-side. + +**KMS is not AAStar-only.** Any community can run a KMS. A user authorizes +against a community's KMS via an **API key or an SBT identity**: they bind + +verify their wallet address, and subsequent transactions from that address are +authorized by that KMS before they reach chain (KMS allows → allowed). So both +bundler and KMS auth are keyed to the user's own credential, not a shared server +secret. + +Implications for the constraint table above: + +- `KMS_API_KEY` / `PIMLICO_API_KEY` → **replaced by the user's own API key** + (free tier or provided), held client-side and scoped to their wallet/community + identity. No shared secret ships in the bundle. + +## Remaining open questions + +1. **Auth model:** passkey assertion as the only session root — do we need any + server-issued token at all, or is on-chain + KMS sufficient? +2. **Operator/admin surfaces:** keep as a separate (backend-having) app, or drop + from the enduser build? +3. **API-key onboarding UX:** where the key is applied for / entered / stored + (settings screen), and quota/aPoints upsell flow. + +--- + +## Status + +- [x] Branch created, migration plan drafted. +- [x] Step 1a: **address book → client-side store** + (`lib/address-book-store.ts`, localStorage, account-scoped). Backend + `/address-book*` no longer called; transfer records the recipient + client-side on confirmation. (backend module left in place; deleted in + step 8) +- [x] Step 1b: **token balance → client-side on-chain read** + (`lib/token-balance.ts`, viem `erc20Abi` balanceOf/decimals/symbol/name). + The only used token endpoint was `getTokenBalance`; transfer + + DashboardContext now read on-chain (verified live on Sepolia). + `getTokenBalance` removed from `lib/api.ts`. The user's saved-token _list_ + (`userTokenAPI`) is still backend — separate step. +- [x] Step 1c: **user-token list → client-side store** + (`lib/user-token-store.ts`, localStorage, account-scoped). getUserTokens / + addUserToken / initializeDefaultTokens are client-side; custom-token + metadata resolves on-chain (`getTokenMetadata`); balances attach via step + 1b; defaults mirror the old PRESET_TOKENS. `userTokenAPI` removed. (Note: + presets are Optimism contracts — a pre-existing chain mismatch preserved + as-is.) +- [x] Step 2 (list): **paymaster saved-list + presets → client-side** + (`lib/paymaster-store.ts`, localStorage account-scoped list; presets built + from SDK canonical). `paymasterAPI` removed. NOTE: `paymasterAPI.sponsor` + was unused and the transfer backend sponsors from the passed + `paymasterAddress`, so sponsorship itself still runs server-side and moves + client-side with the transfer flow (step 4). +- [x] Step 3 (bls cleanup): **`blsAPI` removed** — the frontend never called it; + BLS runs inside the backend transfer flow and moves client-side (direct to + the gossip network) with the transfer migration. + +### Re-planning note (after finishing the storage-class migrations) + +The cleanly-separable work (localStorage stores + on-chain reads) is now done: +**address book, token balance, token list, paymaster list**. Everything left — +**account (`getAccount` / create), transfer signing, guardian recovery** — is +**KMS/auth-rooted**: the account address and every signature derive from the +user's KMS key via the backend's `resolveKmsKey(userId)` signer. They cannot +move to the browser until the **KMS/auth foundation** exists. + +So the original order (account → transfer → guardian → auth → KMS) is +**inverted**: the foundation must come first. + +**Revised order:** + +1. ✅ Storage-class: address book / token balance+list / paymaster list (done); + BLS dead-API removed. +2. **KMS Origin-direct + the API-key model** (was step 7) — browser talks to KMS + with the user's API key / SBT identity; retire the `app/kms-api` + key-injecting route. +3. **Auth root** (was step 6) — passkey/KMS session instead of backend JWT. +4. On that foundation: **account** (client-side address derivation + create), + **transfer** (client-side KMS sign + BLS + paymaster + bundler), + **guardian**. +5. Delete `aastar/`; flip `output: 'export'`. + +Step 2 (KMS/auth foundation) needs the real KMS Origin-auth + API-key infra to +be live (the open questions above) — it's a backend/infra dependency, not just a +frontend edit. + +**Foundation prep (frontend-only, done ahead of the infra — non-breaking, not +yet wired):** + +- `lib/api-key-store.ts` — holds the user's AAStar API key + optional + KMS/bundler endpoint overrides in localStorage. +- `lib/kms-client.ts` (extended) — a direct-KMS seam: `kmsBaseUrl()`, + `isDirectKmsReady()`, `directKmsClient()` (wires the existing `KmsClient` to + the user's key), `pingKms()`. Auth/transfer flows still use the `/kms-api` + proxy until the KMS Origin+API-key path is live. +- `app/settings/page.tsx` — enter/save/clear the API key + endpoint overrides, + test KMS connectivity, show direct-ready status (linked from the avatar menu). + `pingKms` will CORS-fail until KMS allows the browser Origin — expected, and + is itself the readiness signal. +- Note: `lib/auth.ts` already centralizes the JWT + (getStoredAuth/setStoredAuth/clear) — no convergence needed there.