@blazium/ton-connect-mobile 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /**
3
+ * Transaction Builder Utilities
4
+ * Helper functions for building TON Connect transaction requests
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.tonToNano = tonToNano;
8
+ exports.nanoToTon = nanoToTon;
9
+ exports.buildTransferTransaction = buildTransferTransaction;
10
+ exports.buildMultiTransferTransaction = buildMultiTransferTransaction;
11
+ exports.buildTransactionWithPayload = buildTransactionWithPayload;
12
+ exports.buildTransactionWithStateInit = buildTransactionWithStateInit;
13
+ exports.isValidTonAddress = isValidTonAddress;
14
+ exports.formatTonAddress = formatTonAddress;
15
+ /**
16
+ * Convert TON amount to nanotons
17
+ * @param tonAmount - Amount in TON (e.g., 1.5 for 1.5 TON)
18
+ * @returns Amount in nanotons as string
19
+ */
20
+ function tonToNano(tonAmount) {
21
+ const amount = typeof tonAmount === 'string' ? parseFloat(tonAmount) : tonAmount;
22
+ if (isNaN(amount) || amount < 0) {
23
+ throw new Error('Invalid TON amount');
24
+ }
25
+ const nanotons = Math.floor(amount * 1000000000);
26
+ return nanotons.toString();
27
+ }
28
+ /**
29
+ * Convert nanotons to TON
30
+ * @param nanotons - Amount in nanotons as string
31
+ * @returns Amount in TON as number
32
+ */
33
+ function nanoToTon(nanotons) {
34
+ const nano = BigInt(nanotons);
35
+ return Number(nano) / 1000000000;
36
+ }
37
+ /**
38
+ * Build a simple TON transfer transaction
39
+ * @param to - Recipient address (EQ... format)
40
+ * @param amount - Amount in TON (will be converted to nanotons)
41
+ * @param validUntil - Optional expiration timestamp (default: 5 minutes from now)
42
+ * @returns Transaction request
43
+ */
44
+ function buildTransferTransaction(to, amount, validUntil) {
45
+ return {
46
+ validUntil: validUntil || Date.now() + 5 * 60 * 1000, // 5 minutes default
47
+ messages: [
48
+ {
49
+ address: to,
50
+ amount: tonToNano(amount),
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ /**
56
+ * Build a transaction with multiple recipients
57
+ * @param transfers - Array of {to, amount} transfers
58
+ * @param validUntil - Optional expiration timestamp (default: 5 minutes from now)
59
+ * @returns Transaction request
60
+ */
61
+ function buildMultiTransferTransaction(transfers, validUntil) {
62
+ return {
63
+ validUntil: validUntil || Date.now() + 5 * 60 * 1000,
64
+ messages: transfers.map((transfer) => ({
65
+ address: transfer.to,
66
+ amount: tonToNano(transfer.amount),
67
+ })),
68
+ };
69
+ }
70
+ /**
71
+ * Build a transaction with custom payload
72
+ * @param to - Recipient address
73
+ * @param amount - Amount in TON
74
+ * @param payload - Base64 encoded payload
75
+ * @param validUntil - Optional expiration timestamp
76
+ * @returns Transaction request
77
+ */
78
+ function buildTransactionWithPayload(to, amount, payload, validUntil) {
79
+ return {
80
+ validUntil: validUntil || Date.now() + 5 * 60 * 1000,
81
+ messages: [
82
+ {
83
+ address: to,
84
+ amount: tonToNano(amount),
85
+ payload,
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ /**
91
+ * Build a transaction with state init (for contract deployment)
92
+ * @param to - Recipient address
93
+ * @param amount - Amount in TON
94
+ * @param stateInit - Base64 encoded state init
95
+ * @param validUntil - Optional expiration timestamp
96
+ * @returns Transaction request
97
+ */
98
+ function buildTransactionWithStateInit(to, amount, stateInit, validUntil) {
99
+ return {
100
+ validUntil: validUntil || Date.now() + 5 * 60 * 1000,
101
+ messages: [
102
+ {
103
+ address: to,
104
+ amount: tonToNano(amount),
105
+ stateInit,
106
+ },
107
+ ],
108
+ };
109
+ }
110
+ /**
111
+ * Validate TON address format
112
+ * @param address - Address to validate
113
+ * @returns true if address is valid
114
+ */
115
+ function isValidTonAddress(address) {
116
+ // TON addresses start with EQ or 0Q and are 48 characters long (base64)
117
+ return /^(EQ|0Q)[A-Za-z0-9_-]{46}$/.test(address);
118
+ }
119
+ /**
120
+ * Format TON address for display (with ellipsis)
121
+ * @param address - Full address
122
+ * @param startLength - Characters to show at start (default: 6)
123
+ * @param endLength - Characters to show at end (default: 4)
124
+ * @returns Formatted address
125
+ */
126
+ function formatTonAddress(address, startLength = 6, endLength = 4) {
127
+ if (address.length <= startLength + endLength) {
128
+ return address;
129
+ }
130
+ return `${address.substring(0, startLength)}...${address.substring(address.length - endLength)}`;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blazium/ton-connect-mobile",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "Production-ready TON Connect Mobile SDK for React Native and Expo. Implements the real TonConnect protocol for mobile applications using deep links and callbacks.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,7 +32,7 @@ export const SUPPORTED_WALLETS: WalletDefinition[] = [
32
32
  universalLink: 'https://app.tonkeeper.com/ton-connect',
33
33
  deepLink: 'tonkeeper://',
34
34
  platforms: ['ios', 'android'],
35
- preferredReturnStrategy: 'back',
35
+ preferredReturnStrategy: 'post_redirect', // CRITICAL FIX: 'back' strategy may not send callback properly, use 'post_redirect'
36
36
  requiresReturnScheme: true, // CRITICAL FIX: Mobile apps need returnScheme for proper callback handling
37
37
  },
38
38
  {
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ import { getWalletByName, getDefaultWallet, SUPPORTED_WALLETS, type WalletDefini
40
40
  * Custom error classes
41
41
  */
42
42
  export class TonConnectError extends Error {
43
- constructor(message: string, public code?: string) {
43
+ constructor(message: string, public code?: string, public recoverySuggestion?: string) {
44
44
  super(message);
45
45
  this.name = 'TonConnectError';
46
46
  }
@@ -48,21 +48,33 @@ export class TonConnectError extends Error {
48
48
 
49
49
  export class ConnectionTimeoutError extends TonConnectError {
50
50
  constructor() {
51
- super('Connection request timed out', 'CONNECTION_TIMEOUT');
51
+ super(
52
+ 'Connection request timed out. The wallet did not respond in time.',
53
+ 'CONNECTION_TIMEOUT',
54
+ 'Please make sure the wallet app is installed and try again. If the issue persists, check your internet connection.'
55
+ );
52
56
  this.name = 'ConnectionTimeoutError';
53
57
  }
54
58
  }
55
59
 
56
60
  export class TransactionTimeoutError extends TonConnectError {
57
61
  constructor() {
58
- super('Transaction request timed out', 'TRANSACTION_TIMEOUT');
62
+ super(
63
+ 'Transaction request timed out. The wallet did not respond in time.',
64
+ 'TRANSACTION_TIMEOUT',
65
+ 'Please check the wallet app and try again. Make sure you approve or reject the transaction in the wallet.'
66
+ );
59
67
  this.name = 'TransactionTimeoutError';
60
68
  }
61
69
  }
62
70
 
63
71
  export class UserRejectedError extends TonConnectError {
64
- constructor() {
65
- super('User rejected the request', 'USER_REJECTED');
72
+ constructor(message?: string) {
73
+ super(
74
+ message || 'User rejected the request',
75
+ 'USER_REJECTED',
76
+ 'The user cancelled the operation in the wallet app.'
77
+ );
66
78
  this.name = 'UserRejectedError';
67
79
  }
68
80
  }
@@ -754,6 +766,32 @@ export class TonConnectMobile {
754
766
  return this.currentWallet;
755
767
  }
756
768
 
769
+ /**
770
+ * Check if a wallet is available on the current platform
771
+ * Note: This is a best-effort check and may not be 100% accurate
772
+ */
773
+ async isWalletAvailable(walletName?: string): Promise<boolean> {
774
+ const wallet = walletName ? getWalletByName(walletName) : this.currentWallet;
775
+ if (!wallet) {
776
+ return false;
777
+ }
778
+
779
+ // On web, check if wallet supports web platform
780
+ // eslint-disable-next-line no-undef
781
+ if (typeof globalThis !== 'undefined' && (globalThis as any).window) {
782
+ return wallet.platforms.includes('web');
783
+ }
784
+
785
+ // On mobile, we can't reliably check if wallet is installed
786
+ // Return true if wallet supports the current platform
787
+ // eslint-disable-next-line no-undef
788
+ const platform = typeof globalThis !== 'undefined' && (globalThis as any).Platform
789
+ ? (globalThis as any).Platform.OS === 'ios' ? 'ios' : 'android'
790
+ : 'android';
791
+
792
+ return wallet.platforms.includes(platform);
793
+ }
794
+
757
795
  /**
758
796
  * Set preferred wallet for connections
759
797
  */
@@ -928,3 +966,7 @@ export * from './types';
928
966
  export type { WalletDefinition } from './core/wallets';
929
967
  export { SUPPORTED_WALLETS, getWalletByName, getDefaultWallet, getWalletsForPlatform } from './core/wallets';
930
968
 
969
+ // Export utilities
970
+ export * from './utils/transactionBuilder';
971
+ export * from './utils/retry';
972
+
@@ -6,6 +6,7 @@
6
6
  import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
7
7
  import { TonConnectMobile, ConnectionStatus, WalletInfo, SendTransactionRequest } from '../index';
8
8
  import type { TonConnectMobileConfig } from '../types';
9
+ import { WalletSelectionModal } from './WalletSelectionModal';
9
10
 
10
11
  /**
11
12
  * Account information (compatible with @tonconnect/ui-react)
@@ -160,8 +161,10 @@ export function TonConnectUIProvider({
160
161
 
161
162
  // Open modal
162
163
  const openModal = useCallback(async () => {
163
- setModalOpen(true);
164
- }, []);
164
+ if (!walletState?.connected) {
165
+ setModalOpen(true);
166
+ }
167
+ }, [walletState?.connected]);
165
168
 
166
169
  // Close modal
167
170
  const closeModal = useCallback(() => {
@@ -240,7 +243,16 @@ export function TonConnectUIProvider({
240
243
  sdk,
241
244
  };
242
245
 
243
- return <TonConnectUIContext.Provider value={contextValue}>{children}</TonConnectUIContext.Provider>;
246
+ return (
247
+ <TonConnectUIContext.Provider value={contextValue}>
248
+ {children}
249
+ {/* Auto-show wallet selection modal when modalOpen is true */}
250
+ <WalletSelectionModal
251
+ visible={modalOpen && !walletState?.connected}
252
+ onClose={closeModal}
253
+ />
254
+ </TonConnectUIContext.Provider>
255
+ );
244
256
  }
245
257
 
246
258
  /**
@@ -0,0 +1,292 @@
1
+ /**
2
+ * WalletSelectionModal component
3
+ * Provides a beautiful wallet selection UI compatible with @tonconnect/ui-react
4
+ */
5
+
6
+ import React from 'react';
7
+ import {
8
+ Modal,
9
+ View,
10
+ Text,
11
+ TouchableOpacity,
12
+ ScrollView,
13
+ StyleSheet,
14
+ Image,
15
+ Platform,
16
+ } from 'react-native';
17
+ import { useTonConnectUI, useTonConnectSDK } from './index';
18
+ import type { WalletDefinition } from '../index';
19
+
20
+ export interface WalletSelectionModalProps {
21
+ /** Whether the modal is visible */
22
+ visible: boolean;
23
+ /** Callback when modal should close */
24
+ onClose: () => void;
25
+ /** Custom wallet list (optional, uses SDK's supported wallets by default) */
26
+ wallets?: WalletDefinition[];
27
+ /** Custom styles */
28
+ style?: any;
29
+ }
30
+
31
+ /**
32
+ * WalletSelectionModal - Beautiful wallet selection modal
33
+ * Compatible with @tonconnect/ui-react modal behavior
34
+ */
35
+ export function WalletSelectionModal({
36
+ visible,
37
+ onClose,
38
+ wallets: customWallets,
39
+ style,
40
+ }: WalletSelectionModalProps): JSX.Element {
41
+ const tonConnectUI = useTonConnectUI();
42
+ const sdk = useTonConnectSDK();
43
+ const [wallets, setWallets] = React.useState<WalletDefinition[]>([]);
44
+ const [connectingWallet, setConnectingWallet] = React.useState<string | null>(null);
45
+
46
+ // Load wallets
47
+ React.useEffect(() => {
48
+ if (customWallets) {
49
+ setWallets(customWallets);
50
+ } else {
51
+ const supportedWallets = sdk.getSupportedWallets();
52
+ // Filter wallets for current platform
53
+ const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web';
54
+ const platformWallets = supportedWallets.filter((w) => w.platforms.includes(platform));
55
+ setWallets(platformWallets);
56
+ }
57
+ }, [sdk, customWallets]);
58
+
59
+ // Handle wallet selection
60
+ const handleSelectWallet = async (wallet: WalletDefinition) => {
61
+ try {
62
+ setConnectingWallet(wallet.name);
63
+
64
+ // Set preferred wallet
65
+ sdk.setPreferredWallet(wallet.name);
66
+
67
+ // Close modal
68
+ onClose();
69
+
70
+ // Small delay to ensure modal closes
71
+ await new Promise<void>((resolve) => setTimeout(() => resolve(), 100));
72
+
73
+ // Connect
74
+ await tonConnectUI.connectWallet();
75
+ } catch (error) {
76
+ console.error('Wallet connection error:', error);
77
+ setConnectingWallet(null);
78
+ // Re-open modal on error
79
+ onClose();
80
+ setTimeout(() => {
81
+ // Modal will be re-opened by parent if needed
82
+ }, 500);
83
+ }
84
+ };
85
+
86
+ return (
87
+ <Modal
88
+ visible={visible}
89
+ animationType="slide"
90
+ transparent={true}
91
+ onRequestClose={onClose}
92
+ >
93
+ <View style={[styles.overlay, style]}>
94
+ <View style={styles.modalContainer}>
95
+ {/* Header */}
96
+ <View style={styles.header}>
97
+ <Text style={styles.title}>Connect your TON wallet</Text>
98
+ <Text style={styles.subtitle}>Choose a wallet to connect to this app</Text>
99
+ <TouchableOpacity style={styles.closeButton} onPress={onClose}>
100
+ <Text style={styles.closeButtonText}>✕</Text>
101
+ </TouchableOpacity>
102
+ </View>
103
+
104
+ {/* Wallet List */}
105
+ <ScrollView style={styles.walletList} showsVerticalScrollIndicator={false}>
106
+ {wallets.length === 0 ? (
107
+ <View style={styles.emptyState}>
108
+ <Text style={styles.emptyStateText}>No wallets available</Text>
109
+ <Text style={styles.emptyStateSubtext}>
110
+ Please install a TON wallet app to continue
111
+ </Text>
112
+ </View>
113
+ ) : (
114
+ wallets.map((wallet) => {
115
+ const isConnecting = connectingWallet === wallet.name;
116
+ return (
117
+ <TouchableOpacity
118
+ key={wallet.name}
119
+ style={[styles.walletItem, isConnecting && styles.walletItemConnecting]}
120
+ onPress={() => handleSelectWallet(wallet)}
121
+ disabled={isConnecting}
122
+ >
123
+ <View style={styles.walletIconContainer}>
124
+ {wallet.iconUrl ? (
125
+ <Image source={{ uri: wallet.iconUrl }} style={styles.walletIcon} />
126
+ ) : (
127
+ <View style={styles.walletIconPlaceholder}>
128
+ <Text style={styles.walletIconText}>
129
+ {wallet.name.charAt(0).toUpperCase()}
130
+ </Text>
131
+ </View>
132
+ )}
133
+ </View>
134
+ <View style={styles.walletInfo}>
135
+ <Text style={styles.walletName}>{wallet.name}</Text>
136
+ <Text style={styles.walletAppName}>{wallet.appName}</Text>
137
+ </View>
138
+ {isConnecting && (
139
+ <View style={styles.connectingIndicator}>
140
+ <Text style={styles.connectingText}>Connecting...</Text>
141
+ </View>
142
+ )}
143
+ </TouchableOpacity>
144
+ );
145
+ })
146
+ )}
147
+ </ScrollView>
148
+
149
+ {/* Footer */}
150
+ <View style={styles.footer}>
151
+ <Text style={styles.footerText}>
152
+ By connecting, you agree to the app's Terms of Service and Privacy Policy
153
+ </Text>
154
+ </View>
155
+ </View>
156
+ </View>
157
+ </Modal>
158
+ );
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ overlay: {
163
+ flex: 1,
164
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
165
+ justifyContent: 'flex-end',
166
+ },
167
+ modalContainer: {
168
+ backgroundColor: '#1a1a1a',
169
+ borderTopLeftRadius: 24,
170
+ borderTopRightRadius: 24,
171
+ maxHeight: '85%',
172
+ paddingBottom: Platform.OS === 'ios' ? 34 : 20,
173
+ },
174
+ header: {
175
+ padding: 24,
176
+ borderBottomWidth: 1,
177
+ borderBottomColor: '#2a2a2a',
178
+ position: 'relative',
179
+ },
180
+ title: {
181
+ fontSize: 28,
182
+ fontWeight: 'bold',
183
+ color: '#ffffff',
184
+ marginBottom: 8,
185
+ },
186
+ subtitle: {
187
+ fontSize: 15,
188
+ color: '#999999',
189
+ lineHeight: 20,
190
+ },
191
+ closeButton: {
192
+ position: 'absolute',
193
+ top: 24,
194
+ right: 24,
195
+ width: 32,
196
+ height: 32,
197
+ borderRadius: 16,
198
+ backgroundColor: '#2a2a2a',
199
+ justifyContent: 'center',
200
+ alignItems: 'center',
201
+ },
202
+ closeButtonText: {
203
+ color: '#ffffff',
204
+ fontSize: 18,
205
+ fontWeight: '600',
206
+ },
207
+ walletList: {
208
+ padding: 16,
209
+ maxHeight: 400,
210
+ },
211
+ walletItem: {
212
+ flexDirection: 'row',
213
+ alignItems: 'center',
214
+ padding: 16,
215
+ backgroundColor: '#2a2a2a',
216
+ borderRadius: 16,
217
+ marginBottom: 12,
218
+ minHeight: 72,
219
+ },
220
+ walletItemConnecting: {
221
+ opacity: 0.7,
222
+ },
223
+ walletIconContainer: {
224
+ marginRight: 16,
225
+ },
226
+ walletIcon: {
227
+ width: 48,
228
+ height: 48,
229
+ borderRadius: 12,
230
+ },
231
+ walletIconPlaceholder: {
232
+ width: 48,
233
+ height: 48,
234
+ borderRadius: 12,
235
+ backgroundColor: '#3a3a3a',
236
+ justifyContent: 'center',
237
+ alignItems: 'center',
238
+ },
239
+ walletIconText: {
240
+ color: '#ffffff',
241
+ fontSize: 20,
242
+ fontWeight: 'bold',
243
+ },
244
+ walletInfo: {
245
+ flex: 1,
246
+ },
247
+ walletName: {
248
+ fontSize: 17,
249
+ fontWeight: '600',
250
+ color: '#ffffff',
251
+ marginBottom: 4,
252
+ },
253
+ walletAppName: {
254
+ fontSize: 14,
255
+ color: '#999999',
256
+ },
257
+ connectingIndicator: {
258
+ marginLeft: 12,
259
+ },
260
+ connectingText: {
261
+ fontSize: 14,
262
+ color: '#0088cc',
263
+ fontWeight: '500',
264
+ },
265
+ emptyState: {
266
+ padding: 40,
267
+ alignItems: 'center',
268
+ },
269
+ emptyStateText: {
270
+ fontSize: 18,
271
+ fontWeight: '600',
272
+ color: '#ffffff',
273
+ marginBottom: 8,
274
+ },
275
+ emptyStateSubtext: {
276
+ fontSize: 14,
277
+ color: '#999999',
278
+ textAlign: 'center',
279
+ },
280
+ footer: {
281
+ padding: 16,
282
+ borderTopWidth: 1,
283
+ borderTopColor: '#2a2a2a',
284
+ },
285
+ footerText: {
286
+ fontSize: 12,
287
+ color: '#666666',
288
+ textAlign: 'center',
289
+ lineHeight: 16,
290
+ },
291
+ });
292
+
@@ -21,4 +21,6 @@ export type {
21
21
  } from './TonConnectUIProvider';
22
22
  export { TonConnectButton } from './TonConnectButton';
23
23
  export type { TonConnectButtonProps } from './TonConnectButton';
24
+ export { WalletSelectionModal } from './WalletSelectionModal';
25
+ export type { WalletSelectionModalProps } from './WalletSelectionModal';
24
26
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Retry Utilities
3
+ * Helper functions for retrying operations with exponential backoff
4
+ */
5
+
6
+ export interface RetryOptions {
7
+ /** Maximum number of retry attempts (default: 3) */
8
+ maxAttempts?: number;
9
+ /** Initial delay in milliseconds (default: 1000) */
10
+ initialDelay?: number;
11
+ /** Maximum delay in milliseconds (default: 10000) */
12
+ maxDelay?: number;
13
+ /** Multiplier for exponential backoff (default: 2) */
14
+ multiplier?: number;
15
+ /** Function to determine if error should be retried (default: retry all errors) */
16
+ shouldRetry?: (error: Error) => boolean;
17
+ }
18
+
19
+ /**
20
+ * Retry a function with exponential backoff
21
+ * @param fn - Function to retry
22
+ * @param options - Retry options
23
+ * @returns Promise that resolves with the function result
24
+ */
25
+ export async function retry<T>(
26
+ fn: () => Promise<T>,
27
+ options: RetryOptions = {}
28
+ ): Promise<T> {
29
+ const {
30
+ maxAttempts = 3,
31
+ initialDelay = 1000,
32
+ maxDelay = 10000,
33
+ multiplier = 2,
34
+ shouldRetry = () => true,
35
+ } = options;
36
+
37
+ let lastError: Error | null = null;
38
+ let delay = initialDelay;
39
+
40
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
41
+ try {
42
+ return await fn();
43
+ } catch (error) {
44
+ lastError = error instanceof Error ? error : new Error(String(error));
45
+
46
+ // Don't retry if shouldRetry returns false
47
+ if (!shouldRetry(lastError)) {
48
+ throw lastError;
49
+ }
50
+
51
+ // Don't retry on last attempt
52
+ if (attempt === maxAttempts) {
53
+ throw lastError;
54
+ }
55
+
56
+ // Wait before retrying
57
+ await new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
58
+
59
+ // Increase delay for next attempt (exponential backoff)
60
+ delay = Math.min(delay * multiplier, maxDelay);
61
+ }
62
+ }
63
+
64
+ // This should never be reached, but TypeScript needs it
65
+ throw lastError || new Error('Retry failed');
66
+ }
67
+
68
+ /**
69
+ * Retry with custom delay function
70
+ * @param fn - Function to retry
71
+ * @param getDelay - Function that returns delay for each attempt (attempt number, last error)
72
+ * @param maxAttempts - Maximum number of attempts
73
+ * @returns Promise that resolves with the function result
74
+ */
75
+ export async function retryWithCustomDelay<T>(
76
+ fn: () => Promise<T>,
77
+ getDelay: (attempt: number, error: Error | null) => number,
78
+ maxAttempts: number = 3
79
+ ): Promise<T> {
80
+ let lastError: Error | null = null;
81
+
82
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
83
+ try {
84
+ return await fn();
85
+ } catch (error) {
86
+ lastError = error instanceof Error ? error : new Error(String(error));
87
+
88
+ // Don't retry on last attempt
89
+ if (attempt === maxAttempts) {
90
+ throw lastError;
91
+ }
92
+
93
+ // Wait before retrying
94
+ const delay = getDelay(attempt, lastError);
95
+ await new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
96
+ }
97
+ }
98
+
99
+ throw lastError || new Error('Retry failed');
100
+ }
101
+