toss-expo-sdk 0.1.2 → 1.0.1
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.
- package/README.md +368 -15
- package/lib/module/ble.js +59 -4
- package/lib/module/ble.js.map +1 -1
- package/lib/module/client/BLETransactionHandler.js +277 -0
- package/lib/module/client/BLETransactionHandler.js.map +1 -0
- package/lib/module/client/NonceAccountManager.js +364 -0
- package/lib/module/client/NonceAccountManager.js.map +1 -0
- package/lib/module/client/TossClient.js +1 -1
- package/lib/module/client/TossClient.js.map +1 -1
- package/lib/module/hooks/useOfflineBLETransactions.js +314 -0
- package/lib/module/hooks/useOfflineBLETransactions.js.map +1 -0
- package/lib/module/index.js +12 -8
- package/lib/module/index.js.map +1 -1
- package/lib/module/intent.js +129 -0
- package/lib/module/intent.js.map +1 -1
- package/lib/module/noise.js +175 -0
- package/lib/module/noise.js.map +1 -1
- package/lib/module/reconciliation.js +155 -0
- package/lib/module/reconciliation.js.map +1 -1
- package/lib/module/services/authService.js +164 -1
- package/lib/module/services/authService.js.map +1 -1
- package/lib/module/storage/secureStorage.js +102 -0
- package/lib/module/storage/secureStorage.js.map +1 -1
- package/lib/module/sync.js +25 -1
- package/lib/module/sync.js.map +1 -1
- package/lib/module/types/nonceAccount.js +2 -0
- package/lib/module/types/nonceAccount.js.map +1 -0
- package/lib/module/types/tossUser.js +16 -1
- package/lib/module/types/tossUser.js.map +1 -1
- package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts +8 -0
- package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts.map +1 -0
- package/lib/typescript/src/ble.d.ts +31 -2
- package/lib/typescript/src/ble.d.ts.map +1 -1
- package/lib/typescript/src/client/BLETransactionHandler.d.ts +98 -0
- package/lib/typescript/src/client/BLETransactionHandler.d.ts.map +1 -0
- package/lib/typescript/src/client/NonceAccountManager.d.ts +82 -0
- package/lib/typescript/src/client/NonceAccountManager.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useOfflineBLETransactions.d.ts +91 -0
- package/lib/typescript/src/hooks/useOfflineBLETransactions.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/intent.d.ts +15 -0
- package/lib/typescript/src/intent.d.ts.map +1 -1
- package/lib/typescript/src/noise.d.ts +62 -0
- package/lib/typescript/src/noise.d.ts.map +1 -1
- package/lib/typescript/src/reconciliation.d.ts +6 -0
- package/lib/typescript/src/reconciliation.d.ts.map +1 -1
- package/lib/typescript/src/services/authService.d.ts +26 -1
- package/lib/typescript/src/services/authService.d.ts.map +1 -1
- package/lib/typescript/src/storage/secureStorage.d.ts +16 -0
- package/lib/typescript/src/storage/secureStorage.d.ts.map +1 -1
- package/lib/typescript/src/sync.d.ts +6 -1
- package/lib/typescript/src/sync.d.ts.map +1 -1
- package/lib/typescript/src/types/nonceAccount.d.ts +59 -0
- package/lib/typescript/src/types/nonceAccount.d.ts.map +1 -0
- package/lib/typescript/src/types/tossUser.d.ts +16 -0
- package/lib/typescript/src/types/tossUser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/solana-program-simple.test.ts +256 -0
- package/src/ble.ts +105 -4
- package/src/client/BLETransactionHandler.ts +364 -0
- package/src/client/NonceAccountManager.ts +444 -0
- package/src/client/TossClient.ts +1 -1
- package/src/hooks/useOfflineBLETransactions.ts +438 -0
- package/src/index.tsx +40 -6
- package/src/intent.ts +166 -0
- package/src/noise.ts +238 -0
- package/src/reconciliation.ts +184 -0
- package/src/services/authService.ts +188 -1
- package/src/storage/secureStorage.ts +138 -0
- package/src/sync.ts +40 -0
- package/src/types/nonceAccount.ts +75 -0
- package/src/types/tossUser.ts +35 -2
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Device } from 'react-native-ble-plx';
|
|
3
|
+
import { Connection } from '@solana/web3.js';
|
|
4
|
+
import type { SolanaIntent } from '../intent';
|
|
5
|
+
import type {
|
|
6
|
+
OfflineTransaction,
|
|
7
|
+
NonceAccountCacheEntry,
|
|
8
|
+
} from '../types/nonceAccount';
|
|
9
|
+
import type { TossUser } from '../types/tossUser';
|
|
10
|
+
import { BLETransactionHandler } from '../client/BLETransactionHandler';
|
|
11
|
+
import { NonceAccountManager } from '../client/NonceAccountManager';
|
|
12
|
+
import { AuthService } from '../services/authService';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* State for BLE transaction transmission
|
|
16
|
+
*/
|
|
17
|
+
export interface BLETransmissionState {
|
|
18
|
+
isTransmitting: boolean;
|
|
19
|
+
progress: {
|
|
20
|
+
sentFragments: number;
|
|
21
|
+
totalFragments: number;
|
|
22
|
+
messageId?: string;
|
|
23
|
+
};
|
|
24
|
+
error?: string;
|
|
25
|
+
lastSent?: {
|
|
26
|
+
messageId: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* State for offline transaction preparation
|
|
33
|
+
*/
|
|
34
|
+
export interface OfflineTransactionState {
|
|
35
|
+
isPreparing: boolean;
|
|
36
|
+
transaction?: OfflineTransaction;
|
|
37
|
+
error?: string;
|
|
38
|
+
isReady: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* useOfflineTransaction Hook
|
|
43
|
+
* Manages offline transaction creation with nonce accounts
|
|
44
|
+
* Handles biometric protection and secure storage
|
|
45
|
+
*/
|
|
46
|
+
export function useOfflineTransaction(user: TossUser, connection: Connection) {
|
|
47
|
+
const [state, setState] = useState<OfflineTransactionState>({
|
|
48
|
+
isPreparing: false,
|
|
49
|
+
isReady: false,
|
|
50
|
+
});
|
|
51
|
+
const nonceManagerRef = useRef<NonceAccountManager | null>(null);
|
|
52
|
+
|
|
53
|
+
// Initialize nonce manager
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
nonceManagerRef.current = new NonceAccountManager(connection);
|
|
56
|
+
|
|
57
|
+
// Cleanup expired cache periodically
|
|
58
|
+
const interval = setInterval(
|
|
59
|
+
() => {
|
|
60
|
+
nonceManagerRef.current?.cleanupExpiredCache();
|
|
61
|
+
},
|
|
62
|
+
5 * 60 * 1000
|
|
63
|
+
); // Every 5 minutes
|
|
64
|
+
|
|
65
|
+
return () => clearInterval(interval);
|
|
66
|
+
}, [connection]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create offline transaction with nonce account
|
|
70
|
+
* Requires biometric verification if enabled
|
|
71
|
+
*/
|
|
72
|
+
const createOfflineTransaction = useCallback(
|
|
73
|
+
async (
|
|
74
|
+
instructions: any[], // TransactionInstruction[]
|
|
75
|
+
metadata?: { description?: string; tags?: string[] }
|
|
76
|
+
): Promise<OfflineTransaction | null> => {
|
|
77
|
+
if (!user.nonceAccount) {
|
|
78
|
+
setState((prev) => ({
|
|
79
|
+
...prev,
|
|
80
|
+
error: 'User does not have nonce account configured',
|
|
81
|
+
}));
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setState((prev) => ({
|
|
86
|
+
...prev,
|
|
87
|
+
isPreparing: true,
|
|
88
|
+
error: undefined,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Verify nonce account access (requires biometric if enabled)
|
|
93
|
+
if (user.security.nonceAccountRequiresBiometric) {
|
|
94
|
+
const hasAccess = await AuthService.verifyNonceAccountAccess(
|
|
95
|
+
user.userId
|
|
96
|
+
);
|
|
97
|
+
if (!hasAccess) {
|
|
98
|
+
throw new Error('Biometric verification failed');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Get cached nonce account or retrieve from storage
|
|
103
|
+
const nonceManager = nonceManagerRef.current!;
|
|
104
|
+
let nonceAccountData = nonceManager.getCachedNonceAccount(user.userId);
|
|
105
|
+
|
|
106
|
+
let nonceAccountInfo: NonceAccountCacheEntry | null = null;
|
|
107
|
+
if (nonceAccountData) {
|
|
108
|
+
nonceAccountInfo = nonceAccountData;
|
|
109
|
+
} else {
|
|
110
|
+
const retrievedInfo = await nonceManager.getNonceAccountSecure(
|
|
111
|
+
user.userId
|
|
112
|
+
);
|
|
113
|
+
if (retrievedInfo) {
|
|
114
|
+
nonceAccountInfo = nonceManager.getCachedNonceAccount(user.userId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!nonceAccountInfo) {
|
|
119
|
+
throw new Error('Failed to retrieve nonce account information');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Validate nonce account
|
|
123
|
+
if (!nonceManager.isNonceAccountValid(nonceAccountInfo.accountInfo)) {
|
|
124
|
+
throw new Error('Nonce account is no longer valid');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Prepare offline transaction
|
|
128
|
+
const transaction = await nonceManager.prepareOfflineTransaction(
|
|
129
|
+
user,
|
|
130
|
+
instructions,
|
|
131
|
+
nonceAccountInfo.accountInfo
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
transaction.metadata = metadata || {};
|
|
135
|
+
|
|
136
|
+
setState((prev) => ({
|
|
137
|
+
...prev,
|
|
138
|
+
transaction,
|
|
139
|
+
isReady: true,
|
|
140
|
+
isPreparing: false,
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return transaction;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const errorMessage =
|
|
146
|
+
error instanceof Error ? error.message : String(error);
|
|
147
|
+
setState((prev) => ({
|
|
148
|
+
...prev,
|
|
149
|
+
error: errorMessage,
|
|
150
|
+
isPreparing: false,
|
|
151
|
+
isReady: false,
|
|
152
|
+
}));
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[user]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Clear current offline transaction
|
|
161
|
+
*/
|
|
162
|
+
const clearTransaction = useCallback(() => {
|
|
163
|
+
setState({
|
|
164
|
+
isPreparing: false,
|
|
165
|
+
isReady: false,
|
|
166
|
+
});
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
...state,
|
|
171
|
+
createOfflineTransaction,
|
|
172
|
+
clearTransaction,
|
|
173
|
+
nonceManager: nonceManagerRef.current,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* useBLETransactionTransmission Hook
|
|
179
|
+
* Handles secure BLE transmission of fragmented transactions
|
|
180
|
+
* with Noise Protocol encryption
|
|
181
|
+
*/
|
|
182
|
+
export function useBLETransactionTransmission(
|
|
183
|
+
platform: 'android' | 'ios' = 'android'
|
|
184
|
+
) {
|
|
185
|
+
const [state, setState] = useState<BLETransmissionState>({
|
|
186
|
+
isTransmitting: false,
|
|
187
|
+
progress: {
|
|
188
|
+
sentFragments: 0,
|
|
189
|
+
totalFragments: 0,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const bleHandlerRef = useRef(new BLETransactionHandler(platform));
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send transaction over BLE with fragmentation
|
|
197
|
+
*/
|
|
198
|
+
const sendTransactionBLE = useCallback(
|
|
199
|
+
async (
|
|
200
|
+
device: Device,
|
|
201
|
+
transaction: OfflineTransaction | SolanaIntent,
|
|
202
|
+
sendFn: (
|
|
203
|
+
deviceId: string,
|
|
204
|
+
characteristicUUID: string,
|
|
205
|
+
data: Buffer
|
|
206
|
+
) => Promise<void>,
|
|
207
|
+
noiseEncryptFn?: (data: Uint8Array) => Promise<any>,
|
|
208
|
+
isIntent: boolean = false
|
|
209
|
+
): Promise<boolean> => {
|
|
210
|
+
setState((prev) => ({
|
|
211
|
+
...prev,
|
|
212
|
+
isTransmitting: true,
|
|
213
|
+
error: undefined,
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const bleHandler = bleHandlerRef.current;
|
|
218
|
+
const result = await bleHandler.sendFragmentedTransactionBLE(
|
|
219
|
+
device,
|
|
220
|
+
transaction,
|
|
221
|
+
sendFn,
|
|
222
|
+
noiseEncryptFn,
|
|
223
|
+
isIntent
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!result.success) {
|
|
227
|
+
const failedCount = result.failedFragments.length;
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Failed to send ${failedCount} fragment(s): ${result.failedFragments.join(', ')}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setState((prev) => ({
|
|
234
|
+
...prev,
|
|
235
|
+
isTransmitting: false,
|
|
236
|
+
progress: {
|
|
237
|
+
sentFragments: result.sentFragments,
|
|
238
|
+
totalFragments:
|
|
239
|
+
result.sentFragments + result.failedFragments.length,
|
|
240
|
+
messageId: result.messageId,
|
|
241
|
+
},
|
|
242
|
+
lastSent: {
|
|
243
|
+
messageId: result.messageId,
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
},
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
return true;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const errorMessage =
|
|
251
|
+
error instanceof Error ? error.message : String(error);
|
|
252
|
+
setState((prev) => ({
|
|
253
|
+
...prev,
|
|
254
|
+
isTransmitting: false,
|
|
255
|
+
error: errorMessage,
|
|
256
|
+
}));
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[]
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Receive fragmented transaction
|
|
265
|
+
*/
|
|
266
|
+
const receiveTransactionFragment = useCallback(
|
|
267
|
+
async (
|
|
268
|
+
fragment: any, // BLEFragment
|
|
269
|
+
noiseDecryptFn?: (encrypted: any) => Promise<Uint8Array>
|
|
270
|
+
): Promise<{
|
|
271
|
+
complete: boolean;
|
|
272
|
+
transaction?: OfflineTransaction | SolanaIntent;
|
|
273
|
+
progress: { received: number; total: number };
|
|
274
|
+
}> => {
|
|
275
|
+
try {
|
|
276
|
+
const bleHandler = bleHandlerRef.current;
|
|
277
|
+
const result = await bleHandler.receiveFragmentedMessage(
|
|
278
|
+
fragment,
|
|
279
|
+
noiseDecryptFn
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
setState((prev) => ({
|
|
283
|
+
...prev,
|
|
284
|
+
progress: {
|
|
285
|
+
sentFragments: result.progress.received,
|
|
286
|
+
totalFragments: result.progress.total,
|
|
287
|
+
messageId: fragment.messageId,
|
|
288
|
+
},
|
|
289
|
+
}));
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
const errorMessage =
|
|
294
|
+
error instanceof Error ? error.message : String(error);
|
|
295
|
+
setState((prev) => ({
|
|
296
|
+
...prev,
|
|
297
|
+
error: errorMessage,
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
complete: false,
|
|
302
|
+
progress: { received: 0, total: 0 },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
[]
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const getMTUConfig = useCallback(() => {
|
|
310
|
+
return bleHandlerRef.current.getMTUConfig();
|
|
311
|
+
}, []);
|
|
312
|
+
|
|
313
|
+
const setMTUConfig = useCallback((config: Partial<any>) => {
|
|
314
|
+
bleHandlerRef.current.setMTUConfig(config);
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...state,
|
|
319
|
+
sendTransactionBLE,
|
|
320
|
+
receiveTransactionFragment,
|
|
321
|
+
getMTUConfig,
|
|
322
|
+
setMTUConfig,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* useNonceAccountManagement Hook
|
|
328
|
+
* Manages nonce account lifecycle: creation, renewal, revocation
|
|
329
|
+
*/
|
|
330
|
+
export function useNonceAccountManagement(
|
|
331
|
+
user: TossUser,
|
|
332
|
+
connection: Connection
|
|
333
|
+
) {
|
|
334
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
335
|
+
const [error, setError] = useState<string | undefined>();
|
|
336
|
+
const nonceManagerRef = useRef(new NonceAccountManager(connection));
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create nonce account with biometric protection
|
|
340
|
+
*/
|
|
341
|
+
const createNonceAccount = useCallback(
|
|
342
|
+
async (userKeypair: any) => {
|
|
343
|
+
setIsLoading(true);
|
|
344
|
+
setError(undefined);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const updatedUser = await AuthService.createSecureNonceAccount(
|
|
348
|
+
user,
|
|
349
|
+
connection,
|
|
350
|
+
userKeypair
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
return updatedUser;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
356
|
+
setError(errorMessage);
|
|
357
|
+
return null;
|
|
358
|
+
} finally {
|
|
359
|
+
setIsLoading(false);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
[user, connection]
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Renew nonce account (refresh from blockchain)
|
|
367
|
+
*/
|
|
368
|
+
const renewNonceAccount = useCallback(async () => {
|
|
369
|
+
if (!user.nonceAccount) {
|
|
370
|
+
setError('No nonce account to renew');
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
setIsLoading(true);
|
|
375
|
+
setError(undefined);
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const updated = await nonceManagerRef.current.renewNonceAccount(
|
|
379
|
+
user.userId,
|
|
380
|
+
user.nonceAccount.address
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return updated;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
386
|
+
setError(errorMessage);
|
|
387
|
+
return null;
|
|
388
|
+
} finally {
|
|
389
|
+
setIsLoading(false);
|
|
390
|
+
}
|
|
391
|
+
}, [user.userId, user.nonceAccount]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Revoke nonce account
|
|
395
|
+
*/
|
|
396
|
+
const revokeNonceAccount = useCallback(async () => {
|
|
397
|
+
setIsLoading(true);
|
|
398
|
+
setError(undefined);
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const updatedUser = await AuthService.revokeNonceAccount(
|
|
402
|
+
user.userId,
|
|
403
|
+
user
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
return updatedUser;
|
|
407
|
+
} catch (err) {
|
|
408
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
409
|
+
setError(errorMessage);
|
|
410
|
+
return null;
|
|
411
|
+
} finally {
|
|
412
|
+
setIsLoading(false);
|
|
413
|
+
}
|
|
414
|
+
}, [user]);
|
|
415
|
+
|
|
416
|
+
const isNonceAccountValid = useCallback(() => {
|
|
417
|
+
if (!user.nonceAccount) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const cached = nonceManagerRef.current.getCachedNonceAccount(user.userId);
|
|
422
|
+
if (cached) {
|
|
423
|
+
return nonceManagerRef.current.isNonceAccountValid(cached.accountInfo);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return user.nonceAccount.status === 'active';
|
|
427
|
+
}, [user.userId, user.nonceAccount]);
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
isLoading,
|
|
431
|
+
error,
|
|
432
|
+
createNonceAccount,
|
|
433
|
+
renewNonceAccount,
|
|
434
|
+
revokeNonceAccount,
|
|
435
|
+
isNonceAccountValid,
|
|
436
|
+
hasNonceAccount: !!user.nonceAccount,
|
|
437
|
+
};
|
|
438
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -3,10 +3,39 @@ export {
|
|
|
3
3
|
createIntent,
|
|
4
4
|
createUserIntent,
|
|
5
5
|
createSignedIntent,
|
|
6
|
+
createOfflineIntent,
|
|
6
7
|
type SolanaIntent,
|
|
7
8
|
type IntentStatus,
|
|
8
9
|
} from './intent';
|
|
9
10
|
|
|
11
|
+
// Nonce Account Management (for offline transactions)
|
|
12
|
+
export { NonceAccountManager } from './client/NonceAccountManager';
|
|
13
|
+
export type {
|
|
14
|
+
NonceAccountInfo,
|
|
15
|
+
NonceAccountCacheEntry,
|
|
16
|
+
CreateNonceAccountOptions,
|
|
17
|
+
OfflineTransaction,
|
|
18
|
+
} from './types/nonceAccount';
|
|
19
|
+
|
|
20
|
+
// BLE Transaction Handling (fragmentation & Noise encryption)
|
|
21
|
+
export { BLETransactionHandler } from './client/BLETransactionHandler';
|
|
22
|
+
export type {
|
|
23
|
+
BLEFragment,
|
|
24
|
+
EncryptedBLEMessage,
|
|
25
|
+
BLEMTUConfig,
|
|
26
|
+
} from './client/BLETransactionHandler';
|
|
27
|
+
|
|
28
|
+
// Custom Hooks for Offline BLE Transactions
|
|
29
|
+
export {
|
|
30
|
+
useOfflineTransaction,
|
|
31
|
+
useBLETransactionTransmission,
|
|
32
|
+
useNonceAccountManagement,
|
|
33
|
+
} from './hooks/useOfflineBLETransactions';
|
|
34
|
+
export type {
|
|
35
|
+
BLETransmissionState,
|
|
36
|
+
OfflineTransactionState,
|
|
37
|
+
} from './hooks/useOfflineBLETransactions';
|
|
38
|
+
|
|
10
39
|
// Intent management
|
|
11
40
|
export {
|
|
12
41
|
verifyIntentSignature,
|
|
@@ -24,8 +53,15 @@ export {
|
|
|
24
53
|
clearPendingIntents,
|
|
25
54
|
} from './storage';
|
|
26
55
|
|
|
27
|
-
// Transport methods
|
|
28
|
-
export {
|
|
56
|
+
// Transport methods (enhanced with fragmentation)
|
|
57
|
+
export {
|
|
58
|
+
startTossScan,
|
|
59
|
+
requestBLEPermissions,
|
|
60
|
+
sendOfflineTransactionFragmented,
|
|
61
|
+
receiveOfflineTransactionFragment,
|
|
62
|
+
getBLEMTUConfig,
|
|
63
|
+
setBLEMTUConfig,
|
|
64
|
+
} from './ble';
|
|
29
65
|
export { initNFC, readNFCUser, writeUserToNFC, writeIntentToNFC } from './nfc';
|
|
30
66
|
export { QRScanner } from './qr';
|
|
31
67
|
|
|
@@ -34,10 +70,8 @@ export { TossClient, type TossConfig } from './client/TossClient';
|
|
|
34
70
|
export type { TossUser } from './types/tossUser';
|
|
35
71
|
export { WalletProvider, useWallet } from './contexts/WalletContext';
|
|
36
72
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
export const createClient = TossClient.createClient;
|
|
40
|
-
|
|
73
|
+
// Authentication Service (enhanced with nonce accounts)
|
|
74
|
+
export { AuthService } from './services/authService';
|
|
41
75
|
// Sync and settlement
|
|
42
76
|
export { syncToChain, checkSyncStatus, type SyncResult } from './sync';
|
|
43
77
|
|
package/src/intent.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
5
5
|
import { sign } from 'tweetnacl';
|
|
6
6
|
import nacl from 'tweetnacl';
|
|
7
7
|
import type { TossUser, TossUserContext } from './types/tossUser';
|
|
8
|
+
import type { OfflineTransaction } from './types/nonceAccount';
|
|
8
9
|
import {
|
|
9
10
|
encryptForArciumInternal,
|
|
10
11
|
type ArciumEncryptedOutput,
|
|
@@ -18,6 +19,7 @@ export type IntentStatus = 'pending' | 'settled' | 'failed' | 'expired';
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Core type for an offline intent following TOSS specification
|
|
22
|
+
* Enhanced with durable nonce account support
|
|
21
23
|
*/
|
|
22
24
|
export interface SolanaIntent {
|
|
23
25
|
// Core fields
|
|
@@ -43,6 +45,12 @@ export interface SolanaIntent {
|
|
|
43
45
|
serialized?: string; // Optional: Serialized transaction
|
|
44
46
|
nonceAccount?: string; // Optional: Public key of the nonce account
|
|
45
47
|
nonceAuth?: string; // Optional: Public key authorized to use the nonce
|
|
48
|
+
nonceAccountAddress?: string; // Durable nonce account address (from nonce account)
|
|
49
|
+
nonceAccountAuth?: string; // Authority for durable nonce account
|
|
50
|
+
|
|
51
|
+
// Offline transaction support
|
|
52
|
+
offlineTransaction?: OfflineTransaction; // Associated offline transaction
|
|
53
|
+
requiresBiometric?: boolean; // Requires biometric to sign/execute
|
|
46
54
|
|
|
47
55
|
// Privacy features
|
|
48
56
|
encrypted?: ArciumEncryptedOutput; // Optional encrypted payload
|
|
@@ -143,6 +151,8 @@ export const nonceManager = new NonceManager();
|
|
|
143
151
|
/**
|
|
144
152
|
* Creates a signed intent between two TOSS users (User-centric API)
|
|
145
153
|
* Recommended for application developers - validates user wallets
|
|
154
|
+
*
|
|
155
|
+
* GAP #8 FIX: Requires biometric authentication for sensitive transactions
|
|
146
156
|
*/
|
|
147
157
|
export async function createUserIntent(
|
|
148
158
|
senderUser: TossUser,
|
|
@@ -152,6 +162,29 @@ export async function createUserIntent(
|
|
|
152
162
|
connection: Connection,
|
|
153
163
|
options: CreateIntentOptions = {}
|
|
154
164
|
): Promise<SolanaIntent> {
|
|
165
|
+
// GAP #8 FIX: Require biometric verification if enabled
|
|
166
|
+
if (senderUser.security?.biometricEnabled) {
|
|
167
|
+
try {
|
|
168
|
+
const LocalAuthentication = await import('expo-local-authentication');
|
|
169
|
+
const compatible = await LocalAuthentication.default.hasHardwareAsync();
|
|
170
|
+
if (compatible) {
|
|
171
|
+
const authenticated =
|
|
172
|
+
await LocalAuthentication.default.authenticateAsync({
|
|
173
|
+
disableDeviceFallback: false,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!authenticated.success) {
|
|
177
|
+
throw new Error('Biometric authentication failed');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.warn(
|
|
182
|
+
'Biometric verification not available, proceeding without',
|
|
183
|
+
error
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
155
188
|
// Verify sender's keypair matches their wallet
|
|
156
189
|
if (
|
|
157
190
|
senderKeypair.publicKey.toBase58() !==
|
|
@@ -414,3 +447,136 @@ export function updateIntentStatus(
|
|
|
414
447
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
415
448
|
};
|
|
416
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Creates an offline intent with durable nonce account support
|
|
452
|
+
* Enables replay-protected offline transactions using nonce accounts
|
|
453
|
+
* Requires biometric authentication for enhanced security
|
|
454
|
+
*/
|
|
455
|
+
export async function createOfflineIntent(
|
|
456
|
+
senderUser: TossUser,
|
|
457
|
+
senderKeypair: Keypair,
|
|
458
|
+
recipientUser: TossUser,
|
|
459
|
+
amount: number,
|
|
460
|
+
nonceAccountInfo: any, // NonceAccountInfo from NonceAccountManager
|
|
461
|
+
connection: Connection,
|
|
462
|
+
options: CreateIntentOptions = {}
|
|
463
|
+
): Promise<SolanaIntent> {
|
|
464
|
+
// Verify sender has nonce account enabled
|
|
465
|
+
if (
|
|
466
|
+
!senderUser.nonceAccount ||
|
|
467
|
+
!senderUser.tossFeatures.offlineTransactionsEnabled
|
|
468
|
+
) {
|
|
469
|
+
throw new Error('Offline transactions not enabled for this user');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Verify sender's keypair matches their wallet
|
|
473
|
+
if (
|
|
474
|
+
senderKeypair.publicKey.toBase58() !==
|
|
475
|
+
senderUser.wallet.publicKey.toBase58()
|
|
476
|
+
) {
|
|
477
|
+
throw new Error('Sender keypair does not match user wallet');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Verify both users can transact
|
|
481
|
+
if (!senderUser.tossFeatures.canSend) {
|
|
482
|
+
throw new Error('Sender account is not enabled for sending');
|
|
483
|
+
}
|
|
484
|
+
if (!recipientUser.tossFeatures.canReceive) {
|
|
485
|
+
throw new Error('Recipient account is not enabled for receiving');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Verify transaction amount is within limits
|
|
489
|
+
if (amount > senderUser.tossFeatures.maxTransactionAmount) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`Transaction amount exceeds limit of ${senderUser.tossFeatures.maxTransactionAmount} lamports`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const now = Math.floor(Date.now() / 1000);
|
|
496
|
+
const defaultExpiry = 24 * 60 * 60; // 24 hours default
|
|
497
|
+
|
|
498
|
+
// Get latest blockhash for nonce account
|
|
499
|
+
const { blockhash } = await connection.getLatestBlockhash();
|
|
500
|
+
|
|
501
|
+
// Prepare user contexts
|
|
502
|
+
const senderCtx: TossUserContext = {
|
|
503
|
+
userId: senderUser.userId,
|
|
504
|
+
username: senderUser.username,
|
|
505
|
+
wallet: {
|
|
506
|
+
publicKey: senderUser.wallet.publicKey,
|
|
507
|
+
isVerified: senderUser.wallet.isVerified,
|
|
508
|
+
createdAt: senderUser.wallet.createdAt,
|
|
509
|
+
},
|
|
510
|
+
status: senderUser.status,
|
|
511
|
+
deviceId: senderUser.device.id,
|
|
512
|
+
sessionId: uuidv4(),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const recipientCtx: TossUserContext = {
|
|
516
|
+
userId: recipientUser.userId,
|
|
517
|
+
username: recipientUser.username,
|
|
518
|
+
wallet: {
|
|
519
|
+
publicKey: recipientUser.wallet.publicKey,
|
|
520
|
+
isVerified: recipientUser.wallet.isVerified,
|
|
521
|
+
createdAt: recipientUser.wallet.createdAt,
|
|
522
|
+
},
|
|
523
|
+
status: recipientUser.status,
|
|
524
|
+
deviceId: recipientUser.device.id,
|
|
525
|
+
sessionId: uuidv4(),
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Create base intent with nonce account support
|
|
529
|
+
const baseIntent: Omit<SolanaIntent, 'signature'> = {
|
|
530
|
+
id: uuidv4(),
|
|
531
|
+
from: senderKeypair.publicKey.toBase58(),
|
|
532
|
+
to: recipientUser.wallet.publicKey.toBase58(),
|
|
533
|
+
fromUser: senderCtx,
|
|
534
|
+
toUser: recipientCtx,
|
|
535
|
+
amount,
|
|
536
|
+
nonce: nonceAccountInfo.currentNonce,
|
|
537
|
+
expiry: now + (options.expiresIn || defaultExpiry),
|
|
538
|
+
blockhash,
|
|
539
|
+
feePayer: senderKeypair.publicKey.toBase58(),
|
|
540
|
+
status: 'pending',
|
|
541
|
+
createdAt: now,
|
|
542
|
+
updatedAt: now,
|
|
543
|
+
// Nonce account support
|
|
544
|
+
nonceAccountAddress: nonceAccountInfo.address,
|
|
545
|
+
nonceAccountAuth: nonceAccountInfo.authorizedSigner,
|
|
546
|
+
requiresBiometric: senderUser.security.nonceAccountRequiresBiometric,
|
|
547
|
+
// Backward compatibility
|
|
548
|
+
nonceAccount: nonceAccountInfo.address,
|
|
549
|
+
nonceAuth: nonceAccountInfo.authorizedSigner,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// Sign the intent
|
|
553
|
+
const signature = sign(
|
|
554
|
+
Buffer.from(JSON.stringify(baseIntent)),
|
|
555
|
+
senderKeypair.secretKey
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
const intent: SolanaIntent = {
|
|
559
|
+
...baseIntent,
|
|
560
|
+
signature: bs58.encode(signature),
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// If private transaction, encrypt the intent data
|
|
564
|
+
if (options.privateTransaction) {
|
|
565
|
+
if (!options.mxeProgramId) {
|
|
566
|
+
throw new Error('MXE Program ID is required for private transactions');
|
|
567
|
+
}
|
|
568
|
+
if (!options.provider) {
|
|
569
|
+
throw new Error('Provider is required for private transactions');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const plaintextValues: bigint[] = [BigInt(amount)];
|
|
573
|
+
|
|
574
|
+
intent.encrypted = await encryptForArciumInternal(
|
|
575
|
+
options.mxeProgramId,
|
|
576
|
+
plaintextValues,
|
|
577
|
+
options.provider
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return intent;
|
|
582
|
+
}
|