Skip to content

Commit b175f28

Browse files
feat: add stream search, history pagination, allowance checks, and balance guard (#951)
Co-authored-by: TheBoyHasNoNamee <jaiyeolawety705@gmail.com>
1 parent 59dc1e2 commit b175f28

9 files changed

Lines changed: 179 additions & 35 deletions

‎packages/react/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
"devDependencies": {
3434
"@conduit-protocol/sdk": "file:../..",
3535
"@tanstack/react-query": "~5.59.0",
36-
"@testing-library/react": "^14.2.0",
3736
"@testing-library/jest-dom": "^6.4.0",
37+
"@testing-library/react": "^14.2.0",
3838
"@types/react": "^18.2.0",
3939
"jsdom": "^24.0.0",
4040
"react": "^18.2.0",

‎packages/react/src/__tests__/useStreamHistoryInfinite.test.tsx‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ const mockGetHistory = vi.fn();
88
vi.mock('@conduit-protocol/sdk', () => ({
99
SUPPORTED_NETWORKS: ['testnet', 'mainnet', 'local'],
1010
UnsupportedChainError: class extends Error {},
11+
InsufficientBalanceError: class extends Error {
12+
constructor(currentBalance: bigint, requiredBalance: bigint, detail?: string) {
13+
super(detail ?? `Insufficient balance: ${currentBalance} < ${requiredBalance}`);
14+
this.name = 'InsufficientBalanceError';
15+
this.currentBalance = currentBalance;
16+
this.requiredBalance = requiredBalance;
17+
}
18+
currentBalance: bigint;
19+
requiredBalance: bigint;
20+
},
1121
clearServerCache: vi.fn(),
1222
clearTokenDecimalsCache: vi.fn(),
1323
ConduitClient: vi.fn(function () {
@@ -17,8 +27,8 @@ vi.mock('@conduit-protocol/sdk', () => ({
1727

1828
const entry = (id: string) => ({ id, kind: 'withdraw', ledger: 1, timestamp: '', txHash: id, event: {} });
1929

20-
function History({ id, pageSize }: { id: string | undefined; pageSize?: number }) {
21-
const h = useStreamHistoryInfinite(id, pageSize);
30+
function History({ id, pageSize, direction, startLedger }: { id: string | undefined; pageSize?: number; direction?: 'forward' | 'backward'; startLedger?: number }) {
31+
const h = useStreamHistoryInfinite(id, pageSize, { direction, startLedger });
2232
if (h.error) return <div data-testid="error">{h.error.message}</div>;
2333
return (
2434
<div>
@@ -62,6 +72,22 @@ describe('useStreamHistoryInfinite', () => {
6272
expect(screen.getByTestId('more')).toHaveTextContent('false');
6373
});
6474

75+
it('supports forward pagination from the earliest ledger', async () => {
76+
mockGetHistory
77+
.mockResolvedValueOnce({ entries: [entry('a'), entry('b')], nextCursor: 'c1' })
78+
.mockResolvedValueOnce({ entries: [entry('c')], nextCursor: undefined });
79+
80+
renderHistory(<History id="7" pageSize={2} direction="forward" startLedger={100} />);
81+
82+
await waitFor(() => expect(screen.getByTestId('ids')).toHaveTextContent('a,b'));
83+
expect(mockGetHistory).toHaveBeenNthCalledWith(1, '7', { limit: 2, startLedger: 100 });
84+
85+
fireEvent.click(screen.getByText('more'));
86+
87+
await waitFor(() => expect(screen.getByTestId('ids')).toHaveTextContent('a,b,c'));
88+
expect(mockGetHistory).toHaveBeenNthCalledWith(2, '7', { limit: 2, cursor: 'c1' });
89+
});
90+
6591
it('does not fetch until a stream id is provided', async () => {
6692
renderHistory(<History id={undefined} />);
6793
await waitFor(() => expect(screen.getByTestId('ids')).toBeInTheDocument());

‎packages/react/src/__tests__/useStreamLifecycleActions.test.tsx‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { act, render, screen, waitFor } from '@testing-library/react';
33
import {
44
StreamFiProvider,
55
useCancelStream,
@@ -25,6 +25,12 @@ vi.mock('@conduit-protocol/sdk', () => ({
2525
ConduitClient: vi.fn(function () {
2626
return {
2727
streams: {
28+
get: vi.fn().mockResolvedValue({
29+
ratePerSecond: 5n,
30+
startTime: 0,
31+
endTime: 100,
32+
withdrawn: 1n,
33+
}),
2834
withdraw: mockWithdraw,
2935
cancel: mockCancel,
3036
pause: mockPause,
@@ -36,20 +42,23 @@ vi.mock('@conduit-protocol/sdk', () => ({
3642
}),
3743
}));
3844

39-
function LifecycleActions() {
45+
function LifecycleActions({ tokenBalance }: { tokenBalance?: bigint | string }) {
4046
const withdraw = useWithdrawStream(7n);
4147
const cancel = useCancelStream(7n);
4248
const pause = usePauseStream(7n);
4349
const resume = useResumeStream(7n);
44-
const topUp = useTopUpStream(7n);
50+
const topUp = useTopUpStream(7n, { tokenBalance });
4551

4652
return (
4753
<div>
4854
<button data-testid="withdraw" onClick={() => withdraw.withdraw().catch(() => {})}>withdraw</button>
4955
<button data-testid="cancel" onClick={() => cancel.cancel().catch(() => {})}>cancel</button>
56+
<button data-testid="confirm-cancel" onClick={() => cancel.confirmCancel().catch(() => {})}>confirm cancel</button>
5057
<button data-testid="pause" onClick={() => pause.pause().catch(() => {})}>pause</button>
5158
<button data-testid="resume" onClick={() => resume.resume().catch(() => {})}>resume</button>
5259
<button data-testid="top-up" onClick={() => topUp.topUp('25').catch(() => {})}>top up</button>
60+
<div data-testid="cancel-confirming">{String(cancel.isConfirming)}</div>
61+
<div data-testid="cancel-settlement">{String(Boolean(cancel.settlement))}</div>
5362
<div data-testid="withdraw-hash">{withdraw.txHash ?? ''}</div>
5463
<div data-testid="cancel-hash">{cancel.txHash ?? ''}</div>
5564
<div data-testid="pause-hash">{pause.txHash ?? ''}</div>
@@ -101,26 +110,50 @@ describe('stream lifecycle hooks', () => {
101110
it('wraps withdraw, cancel, pause, resume, and top up stream actions', async () => {
102111
render(
103112
<StreamFiProvider config={{ network: 'testnet' as const }}>
104-
<LifecycleActions />
113+
<LifecycleActions tokenBalance={100n} />
105114
</StreamFiProvider>,
106115
);
107116

108-
screen.getByTestId('withdraw').click();
109-
screen.getByTestId('cancel').click();
110-
screen.getByTestId('pause').click();
111-
screen.getByTestId('resume').click();
112-
screen.getByTestId('top-up').click();
117+
await act(async () => {
118+
screen.getByTestId('withdraw').click();
119+
screen.getByTestId('cancel').click();
120+
});
121+
122+
await waitFor(() => expect(screen.getByTestId('cancel-confirming')).toHaveTextContent('true'));
123+
await waitFor(() => expect(screen.getByTestId('cancel-settlement')).toHaveTextContent('true'));
124+
125+
await act(async () => {
126+
screen.getByTestId('confirm-cancel').click();
127+
screen.getByTestId('pause').click();
128+
screen.getByTestId('resume').click();
129+
screen.getByTestId('top-up').click();
130+
});
113131

114-
await waitFor(() => expect(screen.getByTestId('withdraw-hash')).toHaveTextContent('withdraw-tx'));
115-
expect(screen.getByTestId('cancel-hash')).toHaveTextContent('cancel-tx');
116-
expect(screen.getByTestId('pause-hash')).toHaveTextContent('pause-tx');
117-
expect(screen.getByTestId('resume-hash')).toHaveTextContent('resume-tx');
118-
expect(screen.getByTestId('top-up-hash')).toHaveTextContent('top-up-tx');
132+
await waitFor(() => {
133+
expect(screen.getByTestId('withdraw-hash')).toHaveTextContent('withdraw-tx');
134+
expect(screen.getByTestId('cancel-hash')).toHaveTextContent('cancel-tx');
135+
expect(screen.getByTestId('pause-hash')).toHaveTextContent('pause-tx');
136+
expect(screen.getByTestId('resume-hash')).toHaveTextContent('resume-tx');
137+
expect(screen.getByTestId('top-up-hash')).toHaveTextContent('top-up-tx');
138+
});
119139

120140
expect(mockWithdraw).toHaveBeenCalledWith(7n, undefined);
121141
expect(mockCancel).toHaveBeenCalledWith(7n);
122142
expect(mockPause).toHaveBeenCalledWith(7n);
123143
expect(mockResume).toHaveBeenCalledWith(7n);
124144
expect(mockTopUp).toHaveBeenCalledWith(7n, 25n);
125145
});
146+
147+
it('throws before wallet submission when the top-up exceeds the available token balance', async () => {
148+
render(
149+
<StreamFiProvider config={{ network: 'testnet' as const }}>
150+
<LifecycleActions tokenBalance={10n} />
151+
</StreamFiProvider>,
152+
);
153+
154+
screen.getByTestId('top-up').click();
155+
156+
await waitFor(() => expect(screen.getByTestId('top-up-hash')).toHaveTextContent(''));
157+
expect(mockTopUp).not.toHaveBeenCalled();
158+
});
126159
});

‎packages/react/src/__tests__/useTokenAllowance.test.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { act, renderHook } from '@testing-library/react';
34
import { useTokenAllowance } from '../hooks/useTokenAllowance.js';
45

56
const mockAllowance = vi.fn();
@@ -66,6 +67,24 @@ describe('useTokenAllowance', () => {
6667
expect(screen.getByTestId('pending')).toHaveTextContent('false');
6768
});
6869

70+
it('checks and approves only when the current allowance is insufficient', async () => {
71+
const { result } = renderHook(() => useTokenAllowance('CTOKEN', 'GOWNER', 'CSPENDER'));
72+
73+
await waitFor(() => expect(result.current.allowance).toBe(100n));
74+
75+
await act(async () => {
76+
const hash = await result.current.checkAndApprove(50n);
77+
expect(hash).toBeNull();
78+
});
79+
expect(mockApprove).not.toHaveBeenCalled();
80+
81+
await act(async () => {
82+
const hash = await result.current.checkAndApprove(150n);
83+
expect(hash).toBe('approve-hash');
84+
});
85+
expect(mockApprove).toHaveBeenCalledWith('CTOKEN', 'GOWNER', 'CSPENDER', 150n, 0);
86+
});
87+
6988
it('surfaces approval errors', async () => {
7089
mockApprove.mockRejectedValue(new Error('approval rejected'));
7190
render(<TokenAllowance />);

‎packages/react/src/hooks/useStreamHistoryInfinite.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { useInfiniteQuery } from '@tanstack/react-query';
33
import type { StreamHistoryEntry, StreamHistoryPage } from '@conduit-protocol/sdk';
44
import { useStreamFiClient } from '../context/useStreamFiClient.js';
55

6+
export interface UseStreamHistoryInfiniteOptions {
7+
/** Start by walking forward from the oldest ledger, or backward from the newest. */
8+
direction?: 'forward' | 'backward';
9+
/** Optional ledger to start from when direction is `forward`. */
10+
startLedger?: number;
11+
}
12+
613
export interface UseStreamHistoryInfiniteResult {
714
/** All loaded entries across pages, oldest first. */
815
entries: StreamHistoryEntry[];
@@ -32,24 +39,34 @@ export interface UseStreamHistoryInfiniteResult {
3239
export function useStreamHistoryInfinite(
3340
streamId: string | bigint | undefined,
3441
pageSize = 50,
42+
options: UseStreamHistoryInfiniteOptions = {},
3543
): UseStreamHistoryInfiniteResult {
3644
const { client, isReady, network } = useStreamFiClient();
3745
const id = streamId === undefined ? undefined : String(streamId);
46+
const { direction = 'backward', startLedger } = options;
3847

3948
const query = useInfiniteQuery<
4049
StreamHistoryPage,
4150
Error,
42-
{ pages: StreamHistoryPage[]; pageParams: (string | undefined)[] },
51+
{ pages: StreamHistoryPage[]; pageParams: (string | number | undefined)[] },
4352
readonly unknown[],
44-
string | undefined
53+
string | number | undefined
4554
>({
46-
queryKey: ['streamfi', 'streamHistory', network, id, pageSize],
47-
queryFn: ({ pageParam }) =>
48-
client!.streams.getHistory(id!, {
49-
limit: pageSize,
50-
...(pageParam !== undefined && { cursor: pageParam }),
51-
}),
52-
initialPageParam: undefined,
55+
queryKey: ['streamfi', 'streamHistory', network, id, pageSize, direction, startLedger],
56+
queryFn: ({ pageParam }) => {
57+
const params: { limit: number; cursor?: string; startLedger?: number } = { limit: pageSize };
58+
if (pageParam !== undefined) {
59+
if (typeof pageParam === 'number') {
60+
params.startLedger = pageParam;
61+
} else {
62+
params.cursor = pageParam;
63+
}
64+
} else if (direction === 'forward' && startLedger !== undefined) {
65+
params.startLedger = startLedger;
66+
}
67+
return client!.streams.getHistory(id!, params);
68+
},
69+
initialPageParam: direction === 'forward' ? (startLedger ?? undefined) : undefined,
5370
getNextPageParam: (lastPage) => lastPage.nextCursor,
5471
enabled: isReady && !!client && id !== undefined,
5572
});

‎packages/react/src/hooks/useStreamSearch.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ function fieldValues<T extends SearchableStream>(stream: T, fields?: UseStreamSe
7171
* field by substring, or, when `fuzzy` is on, by in-order subsequence.
7272
* Substring hits rank above fuzzy-only hits; input order is kept within a rank.
7373
*/
74+
function scoreMatch(term: string, value: string): number {
75+
const normalized = value.toLowerCase();
76+
const idx = normalized.indexOf(term);
77+
if (idx >= 0) {
78+
return idx + term.length * 0.01;
79+
}
80+
return Number.POSITIVE_INFINITY;
81+
}
82+
83+
function fuzzyScore(term: string, value: string): number {
84+
return isSubsequence(term, value) ? term.length : Number.POSITIVE_INFINITY;
85+
}
86+
7487
export function useStreamSearch<T extends SearchableStream>(
7588
streams: readonly T[],
7689
query: string,
@@ -96,18 +109,24 @@ export function useStreamSearch<T extends SearchableStream>(
96109
let score = 0;
97110
let matched = true;
98111
for (const term of terms) {
99-
if (values.some((v) => v.includes(term))) continue;
100-
if (fuzzy && values.some((v) => isSubsequence(term, v))) {
101-
score += 1;
112+
const substringScore = values.reduce((best, value) => Math.min(best, scoreMatch(term, value)), Number.POSITIVE_INFINITY);
113+
if (Number.isFinite(substringScore)) {
114+
score += substringScore;
102115
continue;
103116
}
117+
if (fuzzy) {
118+
const fuzzyMatchScore = values.reduce((best, value) => Math.min(best, fuzzyScore(term, value)), Number.POSITIVE_INFINITY);
119+
if (Number.isFinite(fuzzyMatchScore)) {
120+
score += fuzzyMatchScore + 1000;
121+
continue;
122+
}
123+
}
104124
matched = false;
105125
break;
106126
}
107127
if (matched) scored.push({ stream, score });
108128
}
109129

110-
// Array#sort is stable, so equal scores keep input order.
111130
scored.sort((a, b) => a.score - b.score);
112131
const all = scored.map((s) => s.stream);
113132
return { results: limit !== undefined ? all.slice(0, limit) : all, total: all.length };

‎packages/react/src/hooks/useTokenAllowance.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface UseTokenAllowanceResult {
1313
error: Error | null;
1414
txHash: string | null;
1515
approve: ApproveTokenAllowanceFn;
16+
checkAndApprove: (amount: bigint | string, expirationLedger?: number) => Promise<string | null>;
1617
refetch: () => void;
1718
reset: () => void;
1819
}
@@ -124,6 +125,17 @@ export function useTokenAllowance(
124125
[client, isReady, ownerAddress, spenderAddress, tokenAddress],
125126
);
126127

128+
const checkAndApprove = useCallback(
129+
async (amount: bigint | string, expirationLedger = 0) => {
130+
const parsedAmount = BigInt(amount);
131+
if (allowance !== null && allowance >= parsedAmount) {
132+
return null;
133+
}
134+
return approve(parsedAmount, expirationLedger);
135+
},
136+
[allowance, approve],
137+
);
138+
127139
const reset = useCallback(() => {
128140
++requestIdRef.current;
129141
setAllowance(null);
@@ -133,5 +145,5 @@ export function useTokenAllowance(
133145
setTxHash(null);
134146
}, []);
135147

136-
return { allowance, loading, isPending, error, txHash, approve, refetch, reset };
148+
return { allowance, loading, isPending, error, txHash, approve, checkAndApprove, refetch, reset };
137149
}
Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
import { useCallback } from 'react';
2+
import { InsufficientBalanceError } from '@conduit-protocol/sdk';
23
import { useStreamMutation, type StreamMutationId, type UseStreamMutationState } from './useStreamMutation.js';
34

45
export type TopUpStreamFn = (amount: bigint | string) => Promise<string>;
56

7+
export interface UseTopUpStreamOptions {
8+
tokenBalance?: bigint | string | null;
9+
}
10+
611
export interface UseTopUpStreamResult extends UseStreamMutationState {
712
topUp: TopUpStreamFn;
813
isTopUpPending: boolean;
914
reset: () => void;
1015
}
1116

12-
export function useTopUpStream(streamId: StreamMutationId): UseTopUpStreamResult {
17+
export function useTopUpStream(
18+
streamId: StreamMutationId,
19+
options: UseTopUpStreamOptions = {},
20+
): UseTopUpStreamResult {
21+
const { tokenBalance } = options;
1322
const { mutate, isPending, error, txHash, reset } = useStreamMutation(
1423
streamId,
1524
(client, id, amount: bigint | string) => client.streams.topUp(id, BigInt(amount)),
1625
);
1726

18-
const topUp = useCallback<TopUpStreamFn>((amount) => mutate(amount), [mutate]);
27+
const topUp = useCallback<TopUpStreamFn>(async (amount) => {
28+
const parsedAmount = BigInt(amount);
29+
if (tokenBalance !== undefined && tokenBalance !== null) {
30+
const balance = BigInt(tokenBalance);
31+
if (balance < parsedAmount) {
32+
throw new InsufficientBalanceError(balance, parsedAmount, 'Top-up amount exceeds the available token balance.');
33+
}
34+
}
35+
return mutate(amount);
36+
}, [mutate, tokenBalance]);
1937

2038
return { topUp, isTopUpPending: isPending, isPending, error, txHash, reset };
2139
}

0 commit comments

Comments
 (0)