From 6ce0bcc30c9565a16815c6be130a3f80c84f98e6 Mon Sep 17 00:00:00 2001 From: Justice Date: Wed, 30 Sep 2026 13:09:47 +0100 Subject: [PATCH] fix: confirm stream cancellation settlement --- app/stream/[id]/page.tsx | 2 + components/stream/StreamActions.tsx | 78 +++++++++++++++++-- .../stream/__tests__/StreamActions.test.tsx | 36 +++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/app/stream/[id]/page.tsx b/app/stream/[id]/page.tsx index 9446ba1..8740708 100644 --- a/app/stream/[id]/page.tsx +++ b/app/stream/[id]/page.tsx @@ -509,6 +509,8 @@ export default function StreamPage() { isSender={isSender} isRecipient={isRecipient} withdrawable={withdrawable} + totalDeposited={totalDeposited} + withdrawn={info.withdrawn} token={tokenSymbol} onSuccess={loadStream} /> diff --git a/components/stream/StreamActions.tsx b/components/stream/StreamActions.tsx index 6e3a113..071ce11 100644 --- a/components/stream/StreamActions.tsx +++ b/components/stream/StreamActions.tsx @@ -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'; @@ -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; } @@ -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); @@ -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); @@ -60,7 +68,7 @@ export function StreamActions({ const isPaused = status === 'paused'; const canAct = isActive || isPaused; - async function run(name: string, fn: () => Promise, optimisticStatus?: string) { + async function run(name: string, fn: () => Promise, optimisticStatus?: string): Promise { setPending(name); setActionError(null); if (!navigator.onLine && (name === 'cancel' || name === 'topup')) { @@ -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) @@ -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.'); @@ -151,7 +170,7 @@ export function StreamActions({ {pending === 'pause' ? 'Pausing…' : 'Pause'} + + + + + )} + {/* Top-up modal */} {topUpOpen && ( diff --git a/components/stream/__tests__/StreamActions.test.tsx b/components/stream/__tests__/StreamActions.test.tsx index c87e370..e404dbd 100644 --- a/components/stream/__tests__/StreamActions.test.tsx +++ b/components/stream/__tests__/StreamActions.test.tsx @@ -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); @@ -273,6 +274,7 @@ describe('StreamActions — offline queueing', () => { render(); await click(button('Cancel')!); + await click(button('Confirm cancellation')!); expect(mockQueueTransaction).toHaveBeenCalledWith( expect.objectContaining({ kind: 'cancel', publicKey: PUBLIC_KEY, streamAddress: STREAM_ADDRESS }), @@ -282,6 +284,40 @@ describe('StreamActions — offline queueing', () => { }); }); +describe('StreamActions — cancel confirmation', () => { + it('shows the settlement breakdown before invoking the contract', async () => { + render( + , + ); + + 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(); + + 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(() => {