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
2 changes: 2 additions & 0 deletions app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ export default function StreamPage() {
isSender={isSender}
isRecipient={isRecipient}
withdrawable={withdrawable}
totalDeposited={totalDeposited}
withdrawn={info.withdrawn}
token={tokenSymbol}
onSuccess={loadStream}
/>
Expand Down
78 changes: 71 additions & 7 deletions components/stream/StreamActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useWallet } from '@/contexts/WalletContext';
import { Input } from '@/components/ui/Input';
import * as streamLib from '@/lib/stream';
import { safeToStroops } from '@/lib/safe-operations';
import { fromStroops } from '@/lib/format';
import { queryClient } from '@/lib/queryClient';
import { queueTransaction } from '@/lib/offline-transactions';
import { invalidateStreamMutation, invalidateProfileAndAllowance } from '@/lib/query-keys';
Expand All @@ -23,6 +24,10 @@ interface StreamActionsProps {
isSender: boolean;
isRecipient: boolean;
withdrawable: bigint;
/** Total amount originally deposited into the stream, in stroops. */
totalDeposited?: bigint;
/** Amount already withdrawn by the recipient, in stroops. */
withdrawn?: bigint;
token: string;
onSuccess?: () => void;
}
Expand All @@ -33,7 +38,7 @@ interface StreamActionsProps {
*/
export function StreamActions({
streamAddress, status, clawbackEnabled,
isSender, isRecipient, withdrawable, token, onSuccess,
isSender, isRecipient, withdrawable, totalDeposited = withdrawable, withdrawn = 0n, token, onSuccess,
}: StreamActionsProps) {
const { publicKey, signTx } = useWallet();
const mounted = useRef(true);
Expand All @@ -43,6 +48,9 @@ export function StreamActions({
const [topUpOpen, setTopUpOpen] = useState(false);
const [topUpAmt, setTopUpAmt] = useState('');
const [topUpErr, setTopUpErr] = useState('');
const [cancelOpen, setCancelOpen] = useState(false);

const senderRefund = totalDeposited - withdrawn - withdrawable;

const closeTopUp = useCallback(() => {
setTopUpOpen(false);
Expand All @@ -60,7 +68,7 @@ export function StreamActions({
const isPaused = status === 'paused';
const canAct = isActive || isPaused;

async function run(name: string, fn: () => Promise<unknown>, optimisticStatus?: string) {
async function run(name: string, fn: () => Promise<unknown>, optimisticStatus?: string): Promise<boolean> {
setPending(name);
setActionError(null);
if (!navigator.onLine && (name === 'cancel' || name === 'topup')) {
Expand All @@ -74,7 +82,7 @@ export function StreamActions({
}
setActionError('Queued while offline. It will be submitted automatically when you reconnect.');
setPending(null);
return;
return true;
}

// Apply optimistic update before the mutation (#454)
Expand All @@ -85,23 +93,34 @@ export function StreamActions({

try {
await fn();
if (!mounted.current) return;
if (!mounted.current) return false;
await invalidateStreamMutation(queryClient, streamAddress);
await invalidateProfileAndAllowance(queryClient, publicKey);
onSuccess?.();
return true;
} catch (e) {
if (!mounted.current) return;
if (!mounted.current) return false;
// Roll back the optimistic update on error
if (optimisticStatus && snapshot !== undefined) {
rollbackStreamStatus(queryClient, streamAddress, snapshot);
}
console.error(`[${name}] error:`, e);
setActionError(e instanceof Error ? e.message : `Failed to ${name}.`);
return false;
} finally {
if (mounted.current) setPending(null);
}
}

async function confirmCancel() {
const succeeded = await run(
'cancel',
() => streamLib.cancel(publicKey, streamAddress, signTx),
'cancelled',
);
if (succeeded && mounted.current) setCancelOpen(false);
}

const submitTopUp = async () => {
if (!topUpAmt || topUpAmt.trim() === '') {
setTopUpErr('Enter a valid amount greater than 0.');
Expand Down Expand Up @@ -151,7 +170,7 @@ export function StreamActions({
{pending === 'pause' ? 'Pausing…' : 'Pause'}
</button>
<button
onClick={() => run('cancel', () => streamLib.cancel(publicKey, streamAddress, signTx), 'cancelled')}
onClick={() => setCancelOpen(true)}
disabled={pending !== null}
className="btn-secondary"
>
Expand All @@ -173,7 +192,7 @@ export function StreamActions({
{pending === 'resume' ? 'Resuming…' : 'Resume'}
</button>
<button
onClick={() => run('cancel', () => streamLib.cancel(publicKey, streamAddress, signTx), 'cancelled')}
onClick={() => setCancelOpen(true)}
disabled={pending !== null}
className="btn-secondary"
>
Expand Down Expand Up @@ -208,6 +227,51 @@ export function StreamActions({
)}
</div>

{/* Cancel confirmation modal */}
{cancelOpen && (
<Modal title="Cancel stream?" onClose={() => pending === null && setCancelOpen(false)}>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Cancellation is irreversible. The stream will settle immediately using the breakdown below.
</p>
<div className="rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 text-sm">
<div className="flex justify-between gap-4 px-3 py-2">
<span className="text-gray-500">Already paid to recipient</span>
<span className="font-mono">{fromStroops(withdrawn)} {token}</span>
</div>
<div className="flex justify-between gap-4 px-3 py-2">
<span className="text-gray-500">Recipient payout</span>
<span className="font-mono">{fromStroops(withdrawable)} {token}</span>
</div>
<div className="flex justify-between gap-4 px-3 py-2">
<span className="text-gray-500">Sender refund</span>
<span className="font-mono">{fromStroops(senderRefund)} {token}</span>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Total deposited: {fromStroops(totalDeposited)} {token}
</p>
{actionError && <p className="text-xs text-red-600">{actionError}</p>}
<div className="flex gap-2">
<button
className="btn-primary flex-1"
onClick={confirmCancel}
disabled={pending === 'cancel'}
>
{pending === 'cancel' ? 'Signing…' : 'Confirm cancellation'}
</button>
<button
className="btn-secondary"
onClick={() => setCancelOpen(false)}
disabled={pending !== null}
>
Keep stream
</button>
</div>
</div>
</Modal>
)}

{/* Top-up modal */}
{topUpOpen && (
<Modal title="Top up stream" onClose={closeTopUp}>
Expand Down
36 changes: 36 additions & 0 deletions components/stream/__tests__/StreamActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ describe('StreamActions — pending / in-flight state', () => {

const cancelBtn = button('Cancel')!;
await click(cancelBtn);
await click(button('Confirm cancellation')!);

expect(container.textContent).toContain('Simulation failed');
expect(cancelBtn.disabled).toBe(false);
Expand Down Expand Up @@ -273,6 +274,7 @@ describe('StreamActions — offline queueing', () => {

render(<StreamActions {...baseProps} isSender status="active" />);
await click(button('Cancel')!);
await click(button('Confirm cancellation')!);

expect(mockQueueTransaction).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'cancel', publicKey: PUBLIC_KEY, streamAddress: STREAM_ADDRESS }),
Expand All @@ -282,6 +284,40 @@ describe('StreamActions — offline queueing', () => {
});
});

describe('StreamActions — cancel confirmation', () => {
it('shows the settlement breakdown before invoking the contract', async () => {
render(
<StreamActions
{...baseProps}
isSender
withdrawn={20000000n}
withdrawable={30000000n}
totalDeposited={100000000n}
/>,
);

await click(button('Cancel')!);

expect(container.textContent).toContain('Cancellation is irreversible');
expect(container.textContent).toContain('Already paid to recipient');
expect(container.textContent).toContain('2.00 USDC');
expect(container.textContent).toContain('3.00 USDC');
expect(container.textContent).toContain('Sender refund');
expect(container.textContent).toContain('5.00 USDC');
expect(mockCancel).not.toHaveBeenCalled();
});

it('invokes cancel only after confirmation', async () => {
render(<StreamActions {...baseProps} isSender />);

await click(button('Cancel')!);
expect(mockCancel).not.toHaveBeenCalled();

await click(button('Confirm cancellation')!);
expect(mockCancel).toHaveBeenCalledWith(PUBLIC_KEY, STREAM_ADDRESS, expect.any(Function));
});
});

describe('StreamActions — top-up flow', () => {
function setInputValue(input: HTMLInputElement, value: string) {
act(() => {
Expand Down