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{`curl -X PUT ${API_URL}/v1/wallets/{`curl -X PUT ${API_URL}/v1/wallets/
{`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). */