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
57 changes: 21 additions & 36 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
Expand All @@ -14,6 +14,7 @@ import { checkRecipientExists } from '@/lib/soroban';
import { refreshStreamData } from '@/lib/queryClient';
import { getFactoryContractId } from '@/lib/env';
import { getTokenAllowanceGateway } from '@/lib/token-allowance-gateway';
import { useDebounce } from '@/hooks/useDebounce';
import styles from './CreateStream.module.css';
import { toStroops, fromStroops, wouldRateTruncateToZero } from '@/lib/format';
import { isValidStellarAddress, isValidStellarContract } from '@/lib/stellar-address';
Expand Down Expand Up @@ -89,12 +90,6 @@ export default function CreatePage() {
const [recipientStatus, setRecipientStatus] = useState<
'idle' | 'checking' | 'valid' | 'not-found' | 'error'
>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// #309 — request-sequence guard so a stale in-flight checkRecipientExists()
// call can't overwrite recipientStatus after a newer one has already
// resolved (or started), mirroring app/stream/[id]/page.tsx's loadSeq/
// isCurrent() pattern.
const recipientSeqRef = useRef(0);

const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
Expand All @@ -121,7 +116,10 @@ export default function CreatePage() {
setValue('acknowledgeContractRecipient', false);
}, [recipient, setValue]);

// Debounced async on-chain account existence check. Only fires once the
// Debounce the recipient input with a 600ms delay to reduce RPC calls
const debouncedRecipient = useDebounce(recipient, 600);

// Async on-chain account existence check. Only fires once the
// 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.
Expand All @@ -135,10 +133,7 @@ export default function CreatePage() {
// 3. If the component unmounts mid-flight the state update is suppressed.
const RECIPIENT_CHECK_TIMEOUT_MS = 10_000;
useEffect(() => {
const seq = ++recipientSeqRef.current;
const isCurrent = () => seq === recipientSeqRef.current;

const validLength = recipient?.length === 56;
const validLength = debouncedRecipient?.length === 56;

if (!validLength) {
setRecipientStatus('idle');
Expand All @@ -147,44 +142,34 @@ export default function CreatePage() {

setRecipientStatus('checking');

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

const controller = new AbortController();
let isMounted = true;

(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);

debounceRef.current = setTimeout(async () => {
try {
// checkRecipientExists owns both the deadline (so the spinner is
// always cleared, #123) and the cancellation — the controller's
// signal is now actually passed through, where before it was created
// per effect run and never handed to anything.
const exists = await checkRecipientExists(recipient, {
timeoutMs: RECIPIENT_CHECK_TIMEOUT_MS,
signal: controller.signal,
});
if (!isCurrent()) return;
// Add a 10-second timeout to prevent an infinite loading state (#123)
const exists = await checkRecipientExists(debouncedRecipient, { timeoutMs: 10_000 });
if (!isMounted) return;
setRecipientStatus(exists ? 'valid' : 'not-found');
} catch (err) {
// Cancellation isn't a failure — the cleanup that aborted this check
// already reset the status for the address that replaced it.
if (controller.signal.aborted || !isCurrent()) return;
// Anything else means the check couldn't be made (#391): a hung or
// misconfigured RPC, a proxy error page. Warn, but never claim the
// recipient doesn't exist on this evidence.
// Network / RPC error — don't block the user, but surface a warning.
if (!isMounted) return;
console.error('Recipient check failed:', err);
setRecipientStatus('error');
}
}, 600);
})();

return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
isMounted = false;
// 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]);
}, [debouncedRecipient]);

// Tokens aren't all 7 decimals (the native XLM/SAC convention) — this app
// supports arbitrary TOKENS_TESTNET entries, so the preview must use each
Expand Down
38 changes: 22 additions & 16 deletions components/stream/RateTicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,33 @@ export function RateTicker({ ratePerSecond, startBalance, decimals = 7, endTime
}, [startBalance]);

useEffect(() => {
const id = setInterval(() => {
const elapsedMs = Date.now() - startRef.current.ts;
let elapsedSec = BigInt(Math.floor(elapsedMs / 1000));
// Align to the next whole second to avoid 9 no-op renders per 1 visible update.
// Calculate milliseconds until the next whole second.
const now = Date.now();
const msUntilNextSecond = 1000 - (now % 1000);

if (endTime > 0) {
const endMs = endTime * 1000;
const remainingMs = endMs - startRef.current.ts;
const remainingSec = BigInt(Math.max(0, Math.floor(remainingMs / 1000)));
if (elapsedSec > remainingSec) elapsedSec = remainingSec;
}
// Set initial timeout to align to the next whole second
const alignmentTimer = setTimeout(() => {
// Update display immediately when we hit a whole second
const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000));
const current = startRef.current.balance + elapsed * ratePerSecond;
setDisplay(fromStroops(current, decimals));

if (elapsedSec < 0n) elapsedSec = 0n;
// Then set up a 1-second interval that will naturally stay aligned
const id = setInterval(() => {
const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000));
const current = startRef.current.balance + elapsed * ratePerSecond;
setDisplay(fromStroops(current, decimals));
}, 1000);

const current = startRef.current.balance + elapsedSec * ratePerSecond;
setDisplay(fromStroops(current, decimals));
}, 100);
return () => clearInterval(id);
}, [ratePerSecond, decimals, endTime]);
return () => clearInterval(id);
}, msUntilNextSecond);

return () => clearTimeout(alignmentTimer);
}, [ratePerSecond, decimals]);

return (
<span className="amount" aria-live="polite" aria-atomic="true">
<span className="amount">
{display}
</span>
);
Expand Down
63 changes: 31 additions & 32 deletions lib/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,29 @@ export async function getStreamInfo(

// ── Mutating ──────────────────────────────────────────────────────────────────

/**
* Internal helper to invoke a contract method with optional abort signal support.
* Avoids code duplication across the 8 mutating wrappers.
*/
async function mutate(
sender: string,
streamAddress: string,
method: string,
args: xdr.ScVal[],
signTx: (xdr: string, signal?: AbortSignal) => Promise<string>,
signal?: AbortSignal,
): Promise<string> {
const { hash } = await invokeContract(
sender,
streamAddress,
method,
args,
signTx,
signal ? { signal } : undefined,
);
return hash;
}

/**
* Withdraw the available balance from a stream.
* Supports abort signal for cancellation.
Expand All @@ -197,10 +220,7 @@ export async function withdraw(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_withdraw';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx, { signal })
: await invokeContract(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx);
return hash;
return mutate(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx, signal);
}

/**
Expand All @@ -213,10 +233,7 @@ export async function cancel(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_cancel';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'cancel', [], signTx, { signal })
: await invokeContract(sender, streamAddress, 'cancel', [], signTx);
return hash;
return mutate(sender, streamAddress, 'cancel', [], signTx, signal);
}

/**
Expand All @@ -229,10 +246,7 @@ export async function forceCancel(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_force_cancel';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'force_cancel', [], signTx, { signal })
: await invokeContract(sender, streamAddress, 'force_cancel', [], signTx);
return hash;
return mutate(sender, streamAddress, 'force_cancel', [], signTx, signal);
}

/**
Expand All @@ -245,10 +259,7 @@ export async function pause(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_pause';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'pause', [], signTx, { signal })
: await invokeContract(sender, streamAddress, 'pause', [], signTx);
return hash;
return mutate(sender, streamAddress, 'pause', [], signTx, signal);
}

/**
Expand All @@ -261,10 +272,7 @@ export async function resume(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_resume';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'resume', [], signTx, { signal })
: await invokeContract(sender, streamAddress, 'resume', [], signTx);
return hash;
return mutate(sender, streamAddress, 'resume', [], signTx, signal);
}

/**
Expand All @@ -278,10 +286,7 @@ export async function topUp(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_topup';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx, { signal })
: await invokeContract(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx);
return hash;
return mutate(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx, signal);
}

/**
Expand All @@ -294,10 +299,7 @@ export async function clawback(
signal?: AbortSignal,
): Promise<string> {
if (isMock()) return 'mock_tx_hash_clawback';
const { hash } = signal
? await invokeContract(sender, streamAddress, 'clawback', [], signTx, { signal })
: await invokeContract(sender, streamAddress, 'clawback', [], signTx);
return hash;
return mutate(sender, streamAddress, 'clawback', [], signTx, signal);
}

/**
Expand All @@ -312,8 +314,5 @@ export async function transferRecipient(
): Promise<string> {
if (isMock()) return 'mock_tx_hash_transfer_recipient';
const args = [new Address(newRecipient).toScVal()];
const { hash } = signal
? await invokeContract(sender, streamAddress, 'transfer_recipient', args, signTx, { signal })
: await invokeContract(sender, streamAddress, 'transfer_recipient', args, signTx);
return hash;
return mutate(sender, streamAddress, 'transfer_recipient', args, signTx, signal);
}