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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http
## [Unreleased]

### Added
- `@streamfi/react` stream lifecycle hooks: `useCreateBatchStreams().estimateTotalBatchCost(configs)` sums batch deposits into stroops before submission; `useCancelStream().estimatedRefund` exposes the sender's unstreamed balance (`null` when unknown); `usePauseStream().isPausing` is true only while the pause transaction is pending; and `useResumeStream().resume()` rejects without submitting when the loaded stream is active or cancelled.
- `@streamfi/react`: `useTopUpStream()` returns `projectNewEndTime(amount)`, the stream's end date after a top-up, computed from the stream's rate and end time (the top-up adds `amount / ratePerSecond`, rounded up to whole seconds). It returns `null` while the stream is loading, for open-ended streams, zero rates and non-positive amounts. The stream is refetched after each successful top-up (#990).
- `@streamfi/react`: `useTokenAllowance()` returns `approveInfinite(expirationLedger)`, which approves `i128::MAX` so future streams need no re-approval. The expiration is passed explicitly because SEP-41 requires a valid expiration for a non-zero amount (#991).
- `@streamfi/react`: `useStreamHistoryInfinite(streamId, pageSize, { filterEventTypes })` keeps only the requested categories from the loaded entries: `Deposit` (created and top-up events), `Withdraw` and `Cancel` (#992).
Expand Down
40 changes: 40 additions & 0 deletions docs/react-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,46 @@ interface UseStreamCountdownResult {

---

## Stream lifecycle helpers

Four lifecycle hooks expose state that lets a UI preview or label an action
before the user commits to it.

```tsx
import { useCancelStream, useCreateBatchStreams, usePauseStream, useResumeStream } from '@streamfi/react';

function StreamControls({ streamId }) {
const { estimatedRefund, cancel } = useCancelStream(streamId);
const { isPausing, pause } = usePauseStream(streamId);
const { resume } = useResumeStream(streamId);
const { estimateTotalBatchCost } = useCreateBatchStreams();

return (
<div>
<p>Refund if cancelled: {estimatedRefund === null ? 'Unknown' : estimatedRefund.toString()}</p>
<button onClick={() => pause().catch(() => {})} disabled={isPausing}>
{isPausing ? 'Pausing Stream...' : 'Pause'}
</button>
<button onClick={() => resume().catch(() => {})}>Resume</button>
<button onClick={() => cancel().catch(() => {})}>Cancel</button>
<p>Batch total: {estimateTotalBatchCost(batchConfigs).toString()} stroops</p>
</div>
);
}
```

- `useCancelStream(streamId).estimatedRefund` is the sender's unstreamed balance
in stroops, or `null` until the stream loads and for open-ended streams.
- `usePauseStream(streamId).isPausing` is `true` only while the pause
transaction is in flight.
- `useResumeStream(streamId).resume()` rejects without contacting the wallet
when the loaded stream is active or cancelled. Streams that have not loaded
yet are not pre-checked.
- `useCreateBatchStreams().estimateTotalBatchCost(configs)` returns the sum of
every config's `depositAmount` in stroops. It is pure and makes no network call.

---

## Error Handling

Each hook exposes an `error` field that captures:
Expand Down
266 changes: 266 additions & 0 deletions packages/react/src/__tests__/issue-986-987-988-989.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import type { StreamInfo } from '@conduit-protocol/sdk';
import {
StreamFiProvider,
useCancelStream,
useCreateBatchStreams,
usePauseStream,
useResumeStream,
} from '../index.js';

const mockGet = vi.fn();
const mockPause = vi.fn();
const mockResume = vi.fn();
const mockCancel = vi.fn();
const mockCreateBatchStreams = vi.fn();

vi.mock('@conduit-protocol/sdk', () => ({
SUPPORTED_NETWORKS: ['testnet'],
UnsupportedChainError: class UnsupportedChainError extends Error {},
clearServerCache: vi.fn(),
clearTokenDecimalsCache: vi.fn(),
// Stand-in for the SDK's toStroops (7 decimals) so the batch cost sum can be asserted exactly.
toStroops: (amount: string) => BigInt(Math.round(Number(amount) * 1e7)),
ConduitClient: vi.fn(function () {
return {
streams: {
get: mockGet,
pause: mockPause,
resume: mockResume,
cancel: mockCancel,
createBatchStreams: mockCreateBatchStreams,
},
};
}),
}));

const wrapper = ({ children }: { children: ReactNode }) => (
<StreamFiProvider config={{ network: 'testnet' as const }}>{children}</StreamFiProvider>
);

function makeStream(overrides: Partial<StreamInfo> = {}): StreamInfo {
return {
id: 7n,
address: 'CSTREAM',
sender: 'GSENDER',
recipient: 'GRECIPIENT',
token: 'native',
ratePerSecond: 5n,
startTime: 0,
endTime: 0,
withdrawn: 0n,
paused: false,
pausedAt: 0,
cancelled: false,
clawbackEnabled: false,
...overrides,
};
}

/** Lets the hook's initial `streams.get` resolve and commit its state. */
const flush = () => act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});

beforeEach(() => {
mockGet.mockReset();
mockPause.mockReset().mockResolvedValue('pause-tx');
mockResume.mockReset().mockResolvedValue('resume-tx');
mockCancel.mockReset().mockResolvedValue('cancel-tx');
mockCreateBatchStreams.mockReset().mockResolvedValue([]);
});

// ═══════════════════════════════════════════════════════════════════════════════
// Issue #986: useCreateBatchStreams - estimateTotalBatchCost helper
// ═══════════════════════════════════════════════════════════════════════════════

describe('Issue #986 - useCreateBatchStreams.estimateTotalBatchCost', () => {
it('sums deposit amounts across all configs in stroops', () => {
const { result } = renderHook(() => useCreateBatchStreams(), { wrapper });

const total = result.current.estimateTotalBatchCost([
{ recipient: 'GALICE', token: 'native', depositAmount: '10' },
{ recipient: 'GBOB', token: 'native', depositAmount: '2.5' },
]);

expect(total).toBe(125_000_000n);
});

it('handles fractional deposit amounts', () => {
const { result } = renderHook(() => useCreateBatchStreams(), { wrapper });

expect(result.current.estimateTotalBatchCost([
{ recipient: 'GALICE', token: 'native', depositAmount: '0.5' },
{ recipient: 'GBOB', token: 'native', depositAmount: '1' },
])).toBe(15_000_000n);
});

it('returns 0n for an empty batch', () => {
const { result } = renderHook(() => useCreateBatchStreams(), { wrapper });

expect(result.current.estimateTotalBatchCost([])).toBe(0n);
});

it('does not contact the network or change hook state', async () => {
const { result } = renderHook(() => useCreateBatchStreams(), { wrapper });

result.current.estimateTotalBatchCost([{ recipient: 'GALICE', token: 'native', depositAmount: '1' }]);

expect(mockCreateBatchStreams).not.toHaveBeenCalled();
expect(result.current.loading).toBe(false);
expect(result.current.results).toEqual([]);
});
});

// ═══════════════════════════════════════════════════════════════════════════════
// Issue #987: useCancelStream - return estimated refund amount in cancel simulation
// ═══════════════════════════════════════════════════════════════════════════════

describe('Issue #987 - useCancelStream.estimatedRefund', () => {
it('is null until the stream has loaded', () => {
mockGet.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useCancelStream(7n), { wrapper });

expect(result.current.estimatedRefund).toBeNull();
});

it('equals the unstreamed balance for a stream that has not started yet', async () => {
// Start is in the future, so nothing has accrued: the full deposit (rate × duration) is refundable.
const now = Math.floor(Date.now() / 1000);
mockGet.mockResolvedValue(makeStream({ ratePerSecond: 5n, startTime: now + 1000, endTime: now + 1100 }));
const { result } = renderHook(() => useCancelStream(7n), { wrapper });

await waitFor(() => expect(result.current.estimatedRefund).toBe(500n));
});

it('reflects accrued time: refund is total minus what has already streamed', async () => {
const start = 1_700_000_000;
const now = vi.spyOn(Date, 'now').mockReturnValue((start + 50) * 1000);
try {
// 100s deposit at 5 stroops/s = 500 total; 50s elapsed accrued 250, so 250 is refundable.
mockGet.mockResolvedValue(makeStream({ ratePerSecond: 5n, startTime: start, endTime: start + 100 }));
const { result } = renderHook(() => useCancelStream(7n), { wrapper });

await waitFor(() => expect(result.current.estimatedRefund).toBe(250n));
} finally {
now.mockRestore();
}
});

it('is 0n once the stream has fully ended', async () => {
const start = 1_700_000_000;
const now = vi.spyOn(Date, 'now').mockReturnValue((start + 200) * 1000);
try {
mockGet.mockResolvedValue(makeStream({ ratePerSecond: 5n, startTime: start, endTime: start + 100 }));
const { result } = renderHook(() => useCancelStream(7n), { wrapper });

await waitFor(() => expect(result.current.estimatedRefund).toBe(0n));
} finally {
now.mockRestore();
}
});

it('is null for open-ended streams because the funded balance is unknown', async () => {
mockGet.mockResolvedValue(makeStream({ endTime: 0 }));
const { result } = renderHook(() => useCancelStream(7n), { wrapper });

await flush();

expect(mockGet).toHaveBeenCalledWith(7n);
expect(result.current.estimatedRefund).toBeNull();
});
});

// ═══════════════════════════════════════════════════════════════════════════════
// Issue #988: usePauseStream - add isPausing state indicator
// ═══════════════════════════════════════════════════════════════════════════════

describe('Issue #988 - usePauseStream.isPausing', () => {
it('is true only while the pause transaction is in flight', async () => {
let resolvePause!: (hash: string) => void;
mockPause.mockReturnValue(new Promise<string>((resolve) => { resolvePause = resolve; }));
mockGet.mockResolvedValue(makeStream());
const { result } = renderHook(() => usePauseStream(7n), { wrapper });
await flush();

expect(result.current.isPausing).toBe(false);

let pending: Promise<string> | undefined;
act(() => {
pending = result.current.pause();
});
await waitFor(() => expect(result.current.isPausing).toBe(true));

await act(async () => {
resolvePause('pause-tx');
await pending;
});

expect(result.current.isPausing).toBe(false);
expect(result.current.txHash).toBe('pause-tx');
});

it('resets isPausing when the pause transaction fails', async () => {
mockPause.mockRejectedValue(new Error('rejected by wallet'));
mockGet.mockResolvedValue(makeStream());
const { result } = renderHook(() => usePauseStream(7n), { wrapper });
await flush();

await act(async () => {
await result.current.pause().catch(() => {});
});

expect(result.current.isPausing).toBe(false);
expect(result.current.error?.message).toBe('rejected by wallet');
});
});

// ═══════════════════════════════════════════════════════════════════════════════
// Issue #989: useResumeStream - validate stream is Paused before submitting
// ═══════════════════════════════════════════════════════════════════════════════

describe('Issue #989 - useResumeStream pre-submit validation', () => {
it('rejects resume on an active stream without submitting a transaction', async () => {
mockGet.mockResolvedValue(makeStream({ paused: false }));
const { result } = renderHook(() => useResumeStream(7n), { wrapper });
await flush();

await expect(result.current.resume()).rejects.toThrow(/not paused/i);
expect(mockResume).not.toHaveBeenCalled();
});

it('rejects resume on a cancelled stream even if its paused flag is set', async () => {
mockGet.mockResolvedValue(makeStream({ paused: true, cancelled: true }));
const { result } = renderHook(() => useResumeStream(7n), { wrapper });
await flush();

await expect(result.current.resume()).rejects.toThrow(/not paused/i);
expect(mockResume).not.toHaveBeenCalled();
});

it('submits resume when the stream is paused', async () => {
mockGet.mockResolvedValue(makeStream({ paused: true, pausedAt: 1_700_000_000 }));
const { result } = renderHook(() => useResumeStream(7n), { wrapper });
await flush();

await act(async () => {
await result.current.resume();
});

expect(mockResume).toHaveBeenCalledWith(7n);
expect(result.current.txHash).toBe('resume-tx');
});

it('still submits resume when the stream has not loaded, since its state cannot be known', async () => {
mockGet.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useResumeStream(7n), { wrapper });

await act(async () => {
await result.current.resume();
});

expect(mockResume).toHaveBeenCalledWith(7n);
});
});
3 changes: 3 additions & 0 deletions packages/react/src/hooks/useCancelStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface UseCancelStreamResult extends UseStreamMutationState {
isConfirming: boolean;
isCancelling: boolean;
settlement: CancelSettlement | null;
/** Unstreamed balance the sender would get back if cancelled now; null when unknown (e.g. open-ended streams). */
estimatedRefund: bigint | null;
reset: () => void;
}

Expand Down Expand Up @@ -105,6 +107,7 @@ export function useCancelStream(streamId: StreamMutationId): UseCancelStreamResu
isConfirming,
isCancelling: mutation.isPending,
settlement,
estimatedRefund: settlement?.senderRefundAmount ?? null,
reset,
};
}
18 changes: 16 additions & 2 deletions packages/react/src/hooks/useCreateBatchStreams.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { useStreamFiClient } from '../context/useStreamFiClient.js';
import type { StreamConfig, BatchCreateStreamResult } from '@conduit-protocol/sdk';
import { toStroops, type StreamConfig, type BatchCreateStreamResult } from '@conduit-protocol/sdk';

export interface UseCreateBatchStreamsState {
loading: boolean;
Expand All @@ -15,11 +15,25 @@ export type CreateBatchStreamsFn = (
onProgress?: OnBatchProgress,
) => Promise<BatchCreateStreamResult[]>;

export type EstimateTotalBatchCostFn = (configs: StreamConfig[]) => bigint;

export interface UseCreateBatchStreamsResult extends UseCreateBatchStreamsState {
createBatchStreams: CreateBatchStreamsFn;
/** Total deposit across all configs, in stroops. Pure: does not touch the network or hook state. */
estimateTotalBatchCost: EstimateTotalBatchCostFn;
reset: () => void;
}

/**
* Sums the deposit of every config in a batch, in stroops, so a UI can show
* the treasury balance needed before submitting.
*
* @example
* estimateTotalBatchCost([{ depositAmount: '10' }, { depositAmount: '2.5' }]) // 125_000_000n
*/
export const estimateTotalBatchCost: EstimateTotalBatchCostFn = (configs) =>
configs.reduce((total, config) => total + toStroops(config.depositAmount), 0n);

export function useCreateBatchStreams(): UseCreateBatchStreamsResult {
const { client, isReady } = useStreamFiClient();
const [state, setState] = useState<UseCreateBatchStreamsState>({
Expand Down Expand Up @@ -69,5 +83,5 @@ export function useCreateBatchStreams(): UseCreateBatchStreamsResult {
[client, isReady],
);

return { ...state, createBatchStreams, reset };
return { ...state, createBatchStreams, estimateTotalBatchCost, reset };
}
4 changes: 3 additions & 1 deletion packages/react/src/hooks/usePauseStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type PauseStreamFn = () => Promise<string>;
export interface UsePauseStreamResult extends UseStreamMutationState {
mutate: PauseStreamFn;
pause: PauseStreamFn;
/** True only while the pause transaction is in flight; lets UIs show "Pausing Stream...". */
isPausing: boolean;
reset: () => void;
}

Expand All @@ -28,5 +30,5 @@ export function usePauseStream(streamId: StreamMutationId): UsePauseStreamResult
}
}, [mutation, setStream, stream]);

return { ...mutation, mutate: pause, pause };
return { ...mutation, mutate: pause, pause, isPausing: mutation.isPending };
}
Loading
Loading