Skip to content
Open
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
31 changes: 29 additions & 2 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ export default function CreatePage() {
// address satisfies the Zod schema (56 chars, starts with G) so we never
// waste an RPC call on a partially-typed address — Zod already owns
// partial-input/format feedback exclusively.
//
// An AbortController is created per effect run so that:
// 1. If the address changes before the debounce fires, the in-flight
// check (if any) is cancelled and the loading state is cleared.
// 2. If the RPC provider hangs indefinitely, a 10s timeout rejects the
// promise and transitions status to 'error' rather than leaving the
// user stuck on "Verifying recipient…" forever.
// 3. If the component unmounts mid-flight the state update is suppressed.
const RECIPIENT_CHECK_TIMEOUT_MS = 10_000;
useEffect(() => {
const validLength = recipient?.length === 56;

Expand All @@ -114,18 +123,36 @@ export default function CreatePage() {

if (debounceRef.current) clearTimeout(debounceRef.current);

const controller = new AbortController();

debounceRef.current = setTimeout(async () => {
// Hard timeout: if the RPC never responds, reject after 10s so the
// spinner is always cleared.
const timeoutId = setTimeout(() => controller.abort('timeout'), RECIPIENT_CHECK_TIMEOUT_MS);

try {
const exists = await checkRecipientExists(recipient);
setRecipientStatus(exists ? 'valid' : 'not-found');
if (!controller.signal.aborted) {
setRecipientStatus(exists ? 'valid' : 'not-found');
}
} catch {
// Network / RPC error — don't block the user, but surface a warning.
setRecipientStatus('error');
if (!controller.signal.aborted) {
setRecipientStatus('error');
}
} finally {
clearTimeout(timeoutId);
}
}, 600);

return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
// Cancel any in-flight check so the status doesn't flip back to
// 'valid'/'not-found'/'error' after the address has already changed.
controller.abort('cancelled');
// Immediately clear the checking state on cleanup so the button
// label resets if the user clears the field while a check is pending.
setRecipientStatus('idle');
};
}, [recipient]);

Expand Down
28 changes: 25 additions & 3 deletions lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,13 +468,35 @@ export function scValToU64(val: xdr.ScVal): bigint {
* Throws for any other network error so callers can distinguish
* "definitely does not exist" from "couldn't reach the network".
*
* @param address Stellar G… public key
* Accepts an optional AbortSignal so callers can cancel in-flight checks
* (e.g. when the user changes the address or navigates away) and an optional
* timeoutMs so a hung RPC provider never keeps the caller waiting forever.
* Defaults to 10s if not specified.
*
* @param address Stellar G… public key
* @param options Optional signal and timeout
*/
export async function checkRecipientExists(address: string): Promise<boolean> {
export async function checkRecipientExists(
address: string,
options?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<boolean> {
const timeoutMs = options?.timeoutMs ?? 10_000;

if (options?.signal?.aborted) throw new OperationAbortedError();

try {
await getServer().getAccount(address);
await withTimeout(
getServer().getAccount(address),
timeoutMs,
'checkRecipientExists',
options?.signal,
);
return true;
} catch (err: unknown) {
// Re-throw abort/cancellation so callers can distinguish it from a
// network failure and skip updating React state after unmount.
if (err instanceof OperationAbortedError) throw err;

// stellar-sdk throws an error whose message contains "404" or
// "Account not found" when the account has never been funded.
const message = err instanceof Error ? err.message : String(err);
Expand Down