From 3140352b353bd48741f658f357484d5d70493d0e Mon Sep 17 00:00:00 2001
From: adeshi
- 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 "`}
Idempotency-Key returns the
- original result instead of paying twice.
+ Amounts are integer stroops (1 XLM = 10,000,000 stroops).
{`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…"`}
{`{
From d8b8d3891b198cb2b00687a52242fa2e5885fe85 Mon Sep 17 00:00:00 2001
From: adeshi
Date: Mon, 19 Oct 2026 14:35:04 +0100
Subject: [PATCH 3/4] refactor: remove the react-hooks/exhaustive-deps
suppressions (#552)
Callbacks that effects depend on are memoized (markConfirmed, applyStatus,
loadData, loadPage). The auth hook reads its sign-out and retry functions
through refs, so its effects keep their existing dependencies and do not
re-run on every render.
Closes #552
Co-Authored-By: Claude Haiku 5.5
---
src/app/dashboard/page.tsx | 3 +-
src/app/dashboard/sponsorship/page.tsx | 12 +++---
src/app/pay/[slug]/page.tsx | 53 ++++++++++++++------------
src/lib/useAuth.ts | 19 +++++----
4 files changed, 49 insertions(+), 38 deletions(-)
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index a85e5d4..a4c0311 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -82,8 +82,7 @@ export default function DashboardHome() {
useEffect(() => {
loadPage(null);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [token]);
+ }, [loadPage]);
function goNext() {
if (!nextCursor) return;
diff --git a/src/app/dashboard/sponsorship/page.tsx b/src/app/dashboard/sponsorship/page.tsx
index d66854c..784fe13 100644
--- a/src/app/dashboard/sponsorship/page.tsx
+++ b/src/app/dashboard/sponsorship/page.tsx
@@ -1,6 +1,6 @@
"use client";
-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";
@@ -25,7 +25,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)
@@ -55,15 +56,16 @@ export default function SponsorshipPage() {
setRows([]);
setLoadError(e instanceof Error ? e.message : "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/pay/[slug]/page.tsx b/src/app/pay/[slug]/page.tsx
index 634a7c6..d872848 100644
--- a/src/app/pay/[slug]/page.tsx
+++ b/src/app/pay/[slug]/page.tsx
@@ -1,6 +1,6 @@
"use client";
-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";
@@ -162,29 +162,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;
@@ -270,8 +276,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 4446e18..1633597 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,7 +82,7 @@ function useAuthSession() {
.catch((err: unknown) => {
if (!active) return;
if (isAuthenticationFailure(err)) {
- doSignOut("expired");
+ signOutRef.current("expired");
return;
}
const message =
@@ -84,7 +91,7 @@ function useAuthSession() {
: "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(() => {
@@ -100,25 +107,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);
@@ -128,7 +134,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). */
From 15faa0290303f4a0d42e563c5a0a06b322bee7e3 Mon Sep 17 00:00:00 2001
From: adeshi
Date: Mon, 19 Oct 2026 14:35:40 +0100
Subject: [PATCH 4/4] docs: plan the branded token and wallet ID conversion
(#553)
Scaffold: lists the helpers, the boundaries that brand a value, and the order to
change them. No signatures changed yet.
Closes #553
Co-Authored-By: Claude Haiku 5.5
---
docs/adr/branded-ids-plan.md | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
create mode 100644 docs/adr/branded-ids-plan.md
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.