diff --git a/docs/adr/branded-ids-plan.md b/docs/adr/branded-ids-plan.md new file mode 100644 index 0000000..8b2bf0b --- /dev/null +++ b/docs/adr/branded-ids-plan.md @@ -0,0 +1,28 @@ +# Branded token and wallet IDs: plan + +Tracks issue #553. The branded types exist in `src/lib/brands.ts`. This plan lists the +helpers that still take plain strings, and the order to change them. It is a scaffold: no +signatures have changed yet, so the build is unaffected. + +## Boundaries that brand a value once + +- `useAuth`: returns the session token as `AuthToken` (`asAuthToken` at the point it is stored). +- Route params: pages that call `use(params)` brand `id` as `WalletId` in one line + (12 pages at the time of writing). +- Lists: `w.id` from a `WalletView` is branded where it is passed to a helper. + +## Helpers to change, in order + +1. `src/lib/wallets.ts`: 24 exported functions. Start with the ones called from the most pages + (`getWallet`, `listAddressesPage`, `listTransactionsPage`). +2. `src/lib/gasTankApi.ts`, `src/lib/walletDetailsApi.ts`, `src/lib/uploads.ts`. +3. `src/lib/audit.ts` and `src/lib/auth.ts`. +4. `src/lib/sdk/client.ts`. + +Each helper changes its `token` parameter to `AuthToken` and its wallet ID parameter to +`WalletId`. Because branded types are still strings, callers that already pass a branded value +keep compiling. The remaining call sites are fixed as each helper changes. + +## Done when + +Swapping the token and wallet ID arguments of any helper is a type error. diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 05c402a..2ea0d89 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -64,6 +64,7 @@ export default function DashboardHome() { "Could not load your wallets.", Boolean(token), ); + const loadError = loadErrorMessage !== null; // Null while a page loads or before the first one arrives, so the list shows its skeleton. const wallets = loaded && !loadingPage ? pageWallets : null; diff --git a/src/app/dashboard/sponsorship/page.tsx b/src/app/dashboard/sponsorship/page.tsx index 930f76f..ea8651a 100644 --- a/src/app/dashboard/sponsorship/page.tsx +++ b/src/app/dashboard/sponsorship/page.tsx @@ -1,7 +1,7 @@ "use client"; import { errorMessage } from "@/lib/api"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useAuth } from "@/lib/useAuth"; import { listAllWallets, type WalletView } from "@/lib/wallets"; @@ -26,7 +26,8 @@ export default function SponsorshipPage() { const [rows, setRows] = useState(null); const [loadError, setLoadError] = useState(null); - function loadData(signal?: AbortSignal) { + const loadData = useCallback( + (signal?: AbortSignal) => { if (!token) return; setLoadError(null); listAllWallets(token) @@ -56,15 +57,16 @@ export default function SponsorshipPage() { setRows([]); setLoadError(errorMessage(e, "Could not load sponsorship data.")); }); - } + }, + [token], + ); useEffect(() => { if (!token) return; const controller = new AbortController(); loadData(controller.signal); return () => controller.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); + }, [token, loadData]); if (loading || !user) { return ( diff --git a/src/app/docs/gas-sponsorship/page.tsx b/src/app/docs/gas-sponsorship/page.tsx index c092fe1..dae6124 100644 --- a/src/app/docs/gas-sponsorship/page.tsx +++ b/src/app/docs/gas-sponsorship/page.tsx @@ -83,9 +83,9 @@ export default function GasSponsorship() { ]} /> - {`curl -X PUT ${API_URL}/v1/wallets//sponsorship \\\\ - -H "authorization: Bearer eyJ…" \\\\ - -H "content-type: application/json" \\\\ + {`curl -X PUT ${API_URL}/v1/wallets//sponsorship \\ + -H "authorization: Bearer eyJ…" \\ + -H "content-type: application/json" \\ -d '{ "enabled": true, "per_tx_fee_cap_stroops": 100000, // 0.01 XLM @@ -119,7 +119,7 @@ export default function GasSponsorship() { saved). Requires a dashboard login token.

- {`curl ${API_URL}/v1/wallets//sponsorship \\\\ + {`curl ${API_URL}/v1/wallets//sponsorship \\ -H "authorization: Bearer eyJ…"`} {`{ @@ -168,9 +168,9 @@ export default function GasSponsorship() { wallet itself. - {`curl -X POST ${API_URL}/v1/wallets//sponsor \\\\ - -H "authorization: Bearer octo_sk_test_abc123…" \\\\ - -H "content-type: application/json" \\\\ + {`curl -X POST ${API_URL}/v1/wallets//sponsor \\ + -H "authorization: Bearer octo_sk_test_abc123…" \\ + -H "content-type: application/json" \\ -d '{ "transaction_xdr": "AAAAAgAAAAD…", "max_base_fee_stroops": 100000 // 0.01 XLM @@ -227,7 +227,7 @@ export default function GasSponsorship() { ]} /> - {`curl "${API_URL}/v1/wallets//sponsored-transactions?limit=50&status=confirmed" \\\\ + {`curl "${API_URL}/v1/wallets//sponsored-transactions?limit=50&status=confirmed" \\ -H "authorization: Bearer eyJ…"`} {`{ diff --git a/src/app/docs/getting-started/page.tsx b/src/app/docs/getting-started/page.tsx index 1847bd3..5bc1425 100644 --- a/src/app/docs/getting-started/page.tsx +++ b/src/app/docs/getting-started/page.tsx @@ -117,21 +117,17 @@ X-Octo-Signature:

- +

- To send funds out of the master wallet, call withdraw. For safety, - withdrawals require a dashboard login token, not an - API key. An Idempotency-Key makes retries safe. + Funds leave the wallet only when the owner signs the payment, so the server never + holds a key that can move them. Read the signing info, build and sign the + transaction in your client, request a one-time code, then confirm. The full + sequence is on the Withdrawals page.

- {`curl -X POST ${API_URL}/v1/wallets//withdraw \\ - -H "authorization: Bearer " \\ - -H "Idempotency-Key: payout-9f3c" \\ - -H "content-type: application/json" \\ - -d '{ "destination": "G…DEST", "amount_stroops": 10000000 }'`} + {`curl ${API_URL}/v1/wallets//signing-info \\ + -H "authorization: Bearer "`} - Amounts are integer stroops (1 XLM = 10,000,000 - stroops). Reusing an Idempotency-Key returns the - original result instead of paying twice. + Amounts are integer stroops (1 XLM = 10,000,000 stroops).
diff --git a/src/app/pay/[slug]/page.tsx b/src/app/pay/[slug]/page.tsx index 4a9c713..a5a2640 100644 --- a/src/app/pay/[slug]/page.tsx +++ b/src/app/pay/[slug]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { formatCountdownMs } from "@/lib/format"; -import { use, useEffect, useRef, useState } from "react"; +import { use, useCallback, useEffect, useRef, useState } from "react"; import { Logo } from "@/components/Logo"; import { CopyButton } from "@/components/CopyButton"; import { AddressQrCode } from "@/components/qr/AddressQrCode"; @@ -154,29 +154,35 @@ export default function PayPage({ } // The single path for every confirmation source (poll, check-now, Freighter). - function markConfirmed(paymentId: string) { - if (confirmedRef.current) return; - confirmedRef.current = true; - setStep("confirmed"); - celebrate(); - if (link?.redirect_url) { - redirectAfterConfirm(link.redirect_url, paymentId, slug); - } - } + const markConfirmed = useCallback( + (paymentId: string) => { + if (confirmedRef.current) return; + confirmedRef.current = true; + setStep("confirmed"); + celebrate(); + if (link?.redirect_url) { + redirectAfterConfirm(link.redirect_url, paymentId, slug); + } + }, + [link, slug], + ); // Handles a status response for `paymentId`; returns true once resolved. Stale intents are ignored. - function applyStatus(paymentId: string, status: PaymentStatus): boolean { - if (intentRef.current?.intent.payment_id !== paymentId || confirmedRef.current) return true; - if (status.status === "confirmed") { - markConfirmed(paymentId); - return true; - } - if (status.status === "expired" || status.status === "underpaid" || status.status === "overpaid") { - setResolvedStatus(status); - return true; - } - return false; - } + const applyStatus = useCallback( + (paymentId: string, status: PaymentStatus): boolean => { + if (intentRef.current?.intent.payment_id !== paymentId || confirmedRef.current) return true; + if (status.status === "confirmed") { + markConfirmed(paymentId); + return true; + } + if (status.status === "expired" || status.status === "underpaid" || status.status === "overpaid") { + setResolvedStatus(status); + return true; + } + return false; + }, + [markConfirmed], + ); // Expiry is derived from the intent's expires_at so the UI can't drift from the backend. const expiresAtMs = intent?.expires_at ? Date.parse(intent.expires_at) : NaN; @@ -262,8 +268,7 @@ export default function PayPage({ clearTimeout(timer); document.removeEventListener("visibilitychange", onVisibility); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [intent, step, slug, link, resolvedStatus, pollStopped, freighterBusy]); + }, [intent, step, slug, link, resolvedStatus, pollStopped, freighterBusy, applyStatus]); // Flips the UI to the expired state the moment the countdown hits zero. useEffect(() => { diff --git a/src/lib/useAuth.ts b/src/lib/useAuth.ts index 4ee939c..d6c3370 100644 --- a/src/lib/useAuth.ts +++ b/src/lib/useAuth.ts @@ -57,6 +57,13 @@ function useAuthSession() { setRetryNonce((n) => n + 1); } + // The effects below read the latest sign-out and retry functions through refs, so each + // effect only re-runs when its own inputs change. + const signOutRef = useRef(doSignOut); + signOutRef.current = doSignOut; + const retryRef = useRef(retrySessionCheck); + retryRef.current = retrySessionCheck; + useEffect(() => { const startedAt = Date.now(); let active = true; @@ -75,14 +82,14 @@ function useAuthSession() { .catch((err: unknown) => { if (!active) return; if (isAuthenticationFailure(err)) { - doSignOut("expired"); + signOutRef.current("expired"); return; } const message = errorMessage(err, "We could not verify your session. Check your connection and try again."); setAuthError(message); toast.error(message, { - action: { label: "Retry", onClick: retrySessionCheck }, + action: { label: "Retry", onClick: () => retryRef.current() }, }); }) .finally(() => { @@ -98,25 +105,24 @@ function useAuthSession() { return () => { active = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [router, retryNonce]); useEffect(() => { // Handle 401 from any apiFetch anywhere on the page — apiFetch dispatches this event. function onSessionExpired() { - doSignOut("expired"); + signOutRef.current("expired"); } // Sign out when another tab removes the token from storage. function onStorage(e: StorageEvent) { if (e.key === "octo_token" && e.newValue === null) { - doSignOut(); + signOutRef.current(); } } // Sign out when another tab broadcasts a logout over BroadcastChannel. const unsubscribeLogout = subscribeToLogout(() => { - doSignOut(); + signOutRef.current(); }); window.addEventListener("session-expired", onSessionExpired); @@ -126,7 +132,6 @@ function useAuthSession() { window.removeEventListener("session-expired", onSessionExpired); window.removeEventListener("storage", onStorage); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]); /** Explicit logout (e.g. sidebar button). */