Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/adr/branded-ids-plan.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions src/app/dashboard/sponsorship/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,7 +26,8 @@ export default function SponsorshipPage() {
const [rows, setRows] = useState<WalletSponsorship[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);

function loadData(signal?: AbortSignal) {
const loadData = useCallback(
(signal?: AbortSignal) => {
if (!token) return;
setLoadError(null);
listAllWallets(token)
Expand Down Expand Up @@ -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 (
Expand Down
16 changes: 8 additions & 8 deletions src/app/docs/gas-sponsorship/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ export default function GasSponsorship() {
]}
/>

<Code label="Request">{`curl -X PUT ${API_URL}/v1/wallets/<WALLET_ID>/sponsorship \\\\
-H "authorization: Bearer eyJ…" \\\\
-H "content-type: application/json" \\\\
<Code label="Request">{`curl -X PUT ${API_URL}/v1/wallets/<WALLET_ID>/sponsorship \\
-H "authorization: Bearer eyJ…" \\
-H "content-type: application/json" \\
-d '{
"enabled": true,
"per_tx_fee_cap_stroops": 100000, // 0.01 XLM
Expand Down Expand Up @@ -119,7 +119,7 @@ export default function GasSponsorship() {
saved). Requires a dashboard login token.
</p>

<Code label="Request">{`curl ${API_URL}/v1/wallets/<WALLET_ID>/sponsorship \\\\
<Code label="Request">{`curl ${API_URL}/v1/wallets/<WALLET_ID>/sponsorship \\
-H "authorization: Bearer eyJ…"`}</Code>

<Code label="Response (200)">{`{
Expand Down Expand Up @@ -168,9 +168,9 @@ export default function GasSponsorship() {
wallet itself.
</Callout>

<Code label="Request">{`curl -X POST ${API_URL}/v1/wallets/<WALLET_ID>/sponsor \\\\
-H "authorization: Bearer octo_sk_test_abc123…" \\\\
-H "content-type: application/json" \\\\
<Code label="Request">{`curl -X POST ${API_URL}/v1/wallets/<WALLET_ID>/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
Expand Down Expand Up @@ -227,7 +227,7 @@ export default function GasSponsorship() {
]}
/>

<Code label="Request">{`curl "${API_URL}/v1/wallets/<WALLET_ID>/sponsored-transactions?limit=50&status=confirmed" \\\\
<Code label="Request">{`curl "${API_URL}/v1/wallets/<WALLET_ID>/sponsored-transactions?limit=50&status=confirmed" \\
-H "authorization: Bearer eyJ…"`}</Code>

<Code label="Response (200)">{`{
Expand Down
20 changes: 8 additions & 12 deletions src/app/docs/getting-started/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,17 @@ X-Octo-Signature: <hmac-sha256 hex>
</p>
</Step>

<Step n={5} title="Pay out (withdraw)">
<Step n={5} title="Pay out (non-custodial)">
<p>
To send funds out of the master wallet, call withdraw. For safety,
withdrawals require a <strong>dashboard login token</strong>, not an
API key. An <code>Idempotency-Key</code> 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 <Link href="/docs/api/withdrawals">Withdrawals</Link> page.
</p>
<Code label="cURL">{`curl -X POST ${API_URL}/v1/wallets/<WALLET_ID>/withdraw \\
-H "authorization: Bearer <LOGIN_TOKEN>" \\
-H "Idempotency-Key: payout-9f3c" \\
-H "content-type: application/json" \\
-d '{ "destination": "G…DEST", "amount_stroops": 10000000 }'`}</Code>
<Code label="cURL">{`curl ${API_URL}/v1/wallets/<WALLET_ID>/signing-info \\
-H "authorization: Bearer <LOGIN_TOKEN>"`}</Code>
<Callout type="warning">
Amounts are integer <strong>stroops</strong> (1 XLM = 10,000,000
stroops). Reusing an <code>Idempotency-Key</code> returns the
original result instead of paying twice.
Amounts are integer <strong>stroops</strong> (1 XLM = 10,000,000 stroops).
</Callout>
</Step>
</div>
Expand Down
53 changes: 29 additions & 24 deletions src/app/pay/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down
19 changes: 12 additions & 7 deletions src/lib/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() => {
Expand All @@ -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);
Expand All @@ -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). */
Expand Down