From 3140352b353bd48741f658f357484d5d70493d0e Mon Sep 17 00:00:00 2001 From: adeshi Date: Mon, 19 Oct 2026 14:33:29 +0100 Subject: [PATCH 1/4] docs: describe the non-custodial payout in Getting Started (#554) Closes #554 Co-Authored-By: Claude Haiku 5.5 --- src/app/docs/getting-started/page.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) 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).
From 9a22564993784b127968c8098143e7c9e95082f9 Mon Sep 17 00:00:00 2001 From: adeshi Date: Mon, 19 Oct 2026 14:33:29 +0100 Subject: [PATCH 2/4] docs: fix the line continuations in the gas sponsorship curl samples (#555) Closes #555 Co-Authored-By: Claude Haiku 5.5 --- src/app/docs/gas-sponsorship/page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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…"`} {`{ 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.