Skip to content

Commit 6976c3d

Browse files
authored
Merge pull request #160 from od-hunter/feat/offline-queue
feat(queue): implement offline transaction queue with auto-retry
2 parents 8b3b867 + 0167289 commit 6976c3d

7 files changed

Lines changed: 637 additions & 23 deletions

File tree

App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { StatusBar } from 'expo-status-bar';
33
import { AppNavigator } from './src/navigation/AppNavigator';
44
import { useNotifications } from './src/hooks/useNotifications';
5+
import { useTransactionQueue } from './src/hooks/useTransactionQueue';
56

67
// Import WalletConnect compatibility layer
78
import '@walletconnect/react-native-compat';
@@ -64,6 +65,7 @@ createAppKit({
6465

6566
function NotificationBootstrap() {
6667
useNotifications();
68+
useTransactionQueue();
6769
return null;
6870
}
6971

src/hooks/useTransactionQueue.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useEffect } from 'react';
2+
3+
import { useTransactionQueueStore } from '../store/transactionQueueStore';
4+
5+
export function useTransactionQueue(): void {
6+
useEffect(() => {
7+
const unsubscribe = useTransactionQueueStore.getState().initializeConnectivityListener();
8+
void useTransactionQueueStore.getState().refreshConnectivity();
9+
void useTransactionQueueStore.getState().processQueue();
10+
11+
return unsubscribe;
12+
}, []);
13+
}

src/screens/CryptoPaymentScreen.tsx

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import walletServiceManager, {
2121
WalletConnection,
2222
TokenBalance,
2323
} from '../services/walletService';
24+
import { useTransactionQueueStore } from '../store/transactionQueueStore';
2425

2526
interface RouteParams {
2627
subscriptionId?: string;
@@ -48,6 +49,17 @@ const CryptoPaymentScreen: React.FC = () => {
4849
const [availableTokens, setAvailableTokens] = useState<TokenBalance[]>([]);
4950
const [connection, setConnection] = useState<WalletConnection | null>(null);
5051

52+
const isOnline = useTransactionQueueStore((state) => state.isOnline);
53+
const isQueueProcessing = useTransactionQueueStore((state) => state.isProcessing);
54+
const queuedTransactions = useTransactionQueueStore((state) => state.queuedTransactions);
55+
const executeOrQueueTransaction = useTransactionQueueStore(
56+
(state) => state.executeOrQueueTransaction
57+
);
58+
59+
const pendingCount = queuedTransactions.filter(
60+
(tx) => tx.status === 'pending' || tx.status === 'processing'
61+
).length;
62+
5163
useEffect(() => {
5264
loadWalletData();
5365
}, []);
@@ -148,31 +160,35 @@ const CryptoPaymentScreen: React.FC = () => {
148160

149161
try {
150162
setIsLoading(true);
151-
let streamId: string;
152-
let txHash: string | undefined;
153-
154-
if (selectedProtocol === 'superfluid') {
155-
const result = await walletServiceManager.createSuperfluidStream(
156-
selectedToken,
157-
amount,
158-
recipientAddress,
159-
connection.chainId
160-
);
161-
streamId = result.streamId;
162-
txHash = result.txHash;
163-
} else {
164-
const startTime = Math.floor(Date.now() / 1000);
165-
const stopTime = startTime + 30 * 24 * 60 * 60; // 30 days from now
166-
streamId = await walletServiceManager.createSablierStream(
167-
selectedToken,
168-
amount,
169-
startTime,
170-
stopTime,
171-
recipientAddress,
172-
connection.chainId
163+
const selectedTokenInfo = availableTokens.find((token) => token.symbol === selectedToken);
164+
const tokenForExecution =
165+
selectedProtocol === 'sablier' ? selectedTokenInfo?.address ?? selectedToken : selectedToken;
166+
167+
const startTime = Math.floor(Date.now() / 1000);
168+
const stopTime = startTime + 30 * 24 * 60 * 60;
169+
170+
const result = await executeOrQueueTransaction({
171+
protocol: selectedProtocol,
172+
token: tokenForExecution,
173+
amount,
174+
recipientAddress,
175+
chainId: connection.chainId,
176+
startTime,
177+
stopTime,
178+
});
179+
180+
if (result.queued) {
181+
Alert.alert(
182+
'Queued',
183+
'You are offline or the network is unstable. Your transaction is queued and will run automatically when back online.',
184+
[{ text: 'OK', onPress: () => navigation.goBack() }]
173185
);
186+
return;
174187
}
175188

189+
const streamId = result.streamId ?? 'unknown';
190+
const txHash = result.txHash;
191+
176192
const successBody =
177193
selectedProtocol === 'superfluid' && txHash
178194
? `Stream created on-chain.\n\nTx hash:\n${txHash}\n\nStream ID (subgraph):\n${streamId}\n\nQuery the Superfluid subgraph using sender, receiver, and super token.`
@@ -216,6 +232,26 @@ const CryptoPaymentScreen: React.FC = () => {
216232
</Text>
217233
</View>
218234

235+
{!isOnline && (
236+
<Card variant="elevated" padding="large">
237+
<Text style={styles.offlineTitle}>Offline mode enabled</Text>
238+
<Text style={styles.offlineText}>
239+
Transactions are queued locally and sent automatically when your connection returns.
240+
</Text>
241+
</Card>
242+
)}
243+
244+
{pendingCount > 0 && (
245+
<Card variant="elevated" padding="large">
246+
<Text style={styles.pendingTitle}>Pending transactions: {pendingCount}</Text>
247+
<Text style={styles.pendingText}>
248+
{isQueueProcessing
249+
? 'Processing queued transactions now.'
250+
: 'Waiting for connectivity to process queued transactions.'}
251+
</Text>
252+
</Card>
253+
)}
254+
219255
{/* Token Selection */}
220256
<Card variant="elevated" padding="large">
221257
<Text style={styles.sectionTitle}>Select Payment Token</Text>
@@ -324,7 +360,13 @@ const CryptoPaymentScreen: React.FC = () => {
324360
{/* Create Stream Button */}
325361
<View style={styles.footer}>
326362
<Button
327-
title={isLoading ? 'Creating Stream...' : 'Create Payment Stream'}
363+
title={
364+
isLoading
365+
? 'Creating Stream...'
366+
: isOnline
367+
? 'Create Payment Stream'
368+
: 'Queue Payment Stream'
369+
}
328370
onPress={handleCreateStream}
329371
loading={isLoading}
330372
variant="crypto"
@@ -362,6 +404,24 @@ const styles = StyleSheet.create({
362404
...typography.body,
363405
color: colors.textSecondary,
364406
},
407+
offlineTitle: {
408+
...typography.h3,
409+
color: colors.text,
410+
marginBottom: spacing.xs,
411+
},
412+
offlineText: {
413+
...typography.body,
414+
color: colors.textSecondary,
415+
},
416+
pendingTitle: {
417+
...typography.h3,
418+
color: colors.text,
419+
marginBottom: spacing.xs,
420+
},
421+
pendingText: {
422+
...typography.body,
423+
color: colors.textSecondary,
424+
},
365425
sectionTitle: {
366426
...typography.h3,
367427
color: colors.text,

src/services/notificationService.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const NOTIFICATION_DATA_TYPE = {
88
RENEWAL_REMINDER: 'renewal_reminder',
99
CHARGE_SUCCESS: 'charge_success',
1010
CHARGE_FAILED: 'charge_failed',
11+
TRANSACTION_QUEUE: 'transaction_queue',
1112
} as const;
1213

1314
const ANDROID_CHANNEL_ID = 'billing';
@@ -171,6 +172,27 @@ export async function presentChargeFailedNotification(
171172
});
172173
}
173174

175+
export async function presentTransactionQueueNotification(
176+
title: string,
177+
body: string
178+
): Promise<void> {
179+
if (!isNotificationsSupported()) return;
180+
const status = await getPermissionStatus();
181+
if (status !== Notifications.PermissionStatus.GRANTED) return;
182+
183+
await Notifications.scheduleNotificationAsync({
184+
content: {
185+
title,
186+
body,
187+
data: {
188+
type: NOTIFICATION_DATA_TYPE.TRANSACTION_QUEUE,
189+
},
190+
sound: 'default',
191+
},
192+
trigger: null,
193+
});
194+
}
195+
174196
export function navigateToSubscriptionFromNotification(subscriptionId: string): void {
175197
if (!navigationRef.isReady()) return;
176198
navigationRef.navigate('HomeTab', {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { act } from 'react';
2+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
3+
4+
import { useTransactionQueueStore } from '../transactionQueueStore';
5+
6+
const mockCreateSuperfluidStream = jest.fn();
7+
const mockCreateSablierStream = jest.fn();
8+
9+
jest.mock('@react-native-async-storage/async-storage', () => ({
10+
setItem: jest.fn(() => Promise.resolve()),
11+
getItem: jest.fn(() => Promise.resolve(null)),
12+
removeItem: jest.fn(() => Promise.resolve()),
13+
}));
14+
15+
jest.mock('@react-native-community/netinfo', () => ({
16+
__esModule: true,
17+
default: {
18+
addEventListener: jest.fn(() => jest.fn()),
19+
fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true })),
20+
},
21+
}));
22+
23+
jest.mock('../../services/notificationService', () => ({
24+
presentTransactionQueueNotification: jest.fn(() => Promise.resolve()),
25+
}));
26+
27+
jest.mock('../../services/walletService', () => ({
28+
__esModule: true,
29+
default: {
30+
createSuperfluidStream: (...args: unknown[]) => mockCreateSuperfluidStream(...args),
31+
createSablierStream: (...args: unknown[]) => mockCreateSablierStream(...args),
32+
},
33+
}));
34+
35+
const samplePayload = {
36+
protocol: 'superfluid' as const,
37+
token: 'USDC',
38+
amount: '12.5',
39+
recipientAddress: '0x1111111111111111111111111111111111111111',
40+
chainId: 137,
41+
};
42+
43+
describe('transactionQueueStore', () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
47+
useTransactionQueueStore.setState({
48+
isOnline: true,
49+
isProcessing: false,
50+
queuedTransactions: [],
51+
lastError: null,
52+
});
53+
});
54+
55+
it('queues transactions while offline', async () => {
56+
useTransactionQueueStore.setState({ isOnline: false });
57+
58+
let result;
59+
await act(async () => {
60+
result = await useTransactionQueueStore.getState().executeOrQueueTransaction(samplePayload);
61+
});
62+
63+
expect(result?.queued).toBe(true);
64+
expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(1);
65+
expect(mockCreateSuperfluidStream).not.toHaveBeenCalled();
66+
});
67+
68+
it('replaces conflicting queued transactions', async () => {
69+
useTransactionQueueStore.setState({ isOnline: false });
70+
71+
await act(async () => {
72+
await useTransactionQueueStore.getState().queueTransaction(samplePayload);
73+
await useTransactionQueueStore.getState().queueTransaction({
74+
...samplePayload,
75+
amount: '20',
76+
});
77+
});
78+
79+
const queued = useTransactionQueueStore.getState().queuedTransactions;
80+
expect(queued).toHaveLength(1);
81+
expect(queued[0].payload.amount).toBe('20');
82+
});
83+
84+
it('executes pending transactions when online and clears queue', async () => {
85+
mockCreateSuperfluidStream.mockResolvedValue({ streamId: 'stream:1', txHash: '0xhash' });
86+
87+
await act(async () => {
88+
await useTransactionQueueStore.getState().queueTransaction(samplePayload);
89+
await useTransactionQueueStore.getState().processQueue();
90+
});
91+
92+
expect(mockCreateSuperfluidStream).toHaveBeenCalledTimes(1);
93+
expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0);
94+
});
95+
96+
it('drops stale transactions during processing', async () => {
97+
const staleTimestamp = Date.now() - 31 * 60 * 1000;
98+
99+
useTransactionQueueStore.setState({
100+
queuedTransactions: [
101+
{
102+
id: 'stale_tx',
103+
createdAt: staleTimestamp,
104+
updatedAt: staleTimestamp,
105+
attempts: 0,
106+
conflictKey: 'superfluid:137:usdc:0x1111111111111111111111111111111111111111',
107+
status: 'pending',
108+
payload: samplePayload,
109+
},
110+
],
111+
});
112+
113+
await act(async () => {
114+
await useTransactionQueueStore.getState().processQueue();
115+
});
116+
117+
expect(mockCreateSuperfluidStream).not.toHaveBeenCalled();
118+
expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0);
119+
});
120+
});

src/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { useSubscriptionStore } from './subscriptionStore';
2+
export { useTransactionQueueStore } from './transactionQueueStore';
23
export { useWalletStore } from './walletStore';

0 commit comments

Comments
 (0)