toss-expo-sdk 0.1.1 → 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.
Files changed (93) hide show
  1. package/README.md +490 -81
  2. package/lib/module/ble.js +59 -4
  3. package/lib/module/ble.js.map +1 -1
  4. package/lib/module/client/BLETransactionHandler.js +277 -0
  5. package/lib/module/client/BLETransactionHandler.js.map +1 -0
  6. package/lib/module/client/NonceAccountManager.js +364 -0
  7. package/lib/module/client/NonceAccountManager.js.map +1 -0
  8. package/lib/module/client/TossClient.js +27 -44
  9. package/lib/module/client/TossClient.js.map +1 -1
  10. package/lib/module/contexts/WalletContext.js +4 -4
  11. package/lib/module/contexts/WalletContext.js.map +1 -1
  12. package/lib/module/discovery.js +35 -8
  13. package/lib/module/discovery.js.map +1 -1
  14. package/lib/module/examples/offlinePaymentFlow.js +27 -2
  15. package/lib/module/examples/offlinePaymentFlow.js.map +1 -1
  16. package/lib/module/hooks/useOfflineBLETransactions.js +314 -0
  17. package/lib/module/hooks/useOfflineBLETransactions.js.map +1 -0
  18. package/lib/module/index.js +13 -8
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/intent.js +198 -0
  21. package/lib/module/intent.js.map +1 -1
  22. package/lib/module/nfc.js +1 -1
  23. package/lib/module/noise.js +176 -1
  24. package/lib/module/noise.js.map +1 -1
  25. package/lib/module/reconciliation.js +155 -0
  26. package/lib/module/reconciliation.js.map +1 -1
  27. package/lib/module/services/authService.js +164 -1
  28. package/lib/module/services/authService.js.map +1 -1
  29. package/lib/module/storage/secureStorage.js +102 -0
  30. package/lib/module/storage/secureStorage.js.map +1 -1
  31. package/lib/module/storage.js +4 -4
  32. package/lib/module/sync.js +25 -1
  33. package/lib/module/sync.js.map +1 -1
  34. package/lib/module/types/nonceAccount.js +2 -0
  35. package/lib/module/types/nonceAccount.js.map +1 -0
  36. package/lib/module/types/tossUser.js +16 -1
  37. package/lib/module/types/tossUser.js.map +1 -1
  38. package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts +8 -0
  39. package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts.map +1 -0
  40. package/lib/typescript/src/ble.d.ts +31 -2
  41. package/lib/typescript/src/ble.d.ts.map +1 -1
  42. package/lib/typescript/src/client/BLETransactionHandler.d.ts +98 -0
  43. package/lib/typescript/src/client/BLETransactionHandler.d.ts.map +1 -0
  44. package/lib/typescript/src/client/NonceAccountManager.d.ts +82 -0
  45. package/lib/typescript/src/client/NonceAccountManager.d.ts.map +1 -0
  46. package/lib/typescript/src/client/TossClient.d.ts +10 -12
  47. package/lib/typescript/src/client/TossClient.d.ts.map +1 -1
  48. package/lib/typescript/src/discovery.d.ts +8 -2
  49. package/lib/typescript/src/discovery.d.ts.map +1 -1
  50. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts +9 -1
  51. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts.map +1 -1
  52. package/lib/typescript/src/hooks/useOfflineBLETransactions.d.ts +91 -0
  53. package/lib/typescript/src/hooks/useOfflineBLETransactions.d.ts.map +1 -0
  54. package/lib/typescript/src/index.d.ts +11 -4
  55. package/lib/typescript/src/index.d.ts.map +1 -1
  56. package/lib/typescript/src/intent.d.ts +26 -0
  57. package/lib/typescript/src/intent.d.ts.map +1 -1
  58. package/lib/typescript/src/noise.d.ts +62 -0
  59. package/lib/typescript/src/noise.d.ts.map +1 -1
  60. package/lib/typescript/src/reconciliation.d.ts +6 -0
  61. package/lib/typescript/src/reconciliation.d.ts.map +1 -1
  62. package/lib/typescript/src/services/authService.d.ts +26 -1
  63. package/lib/typescript/src/services/authService.d.ts.map +1 -1
  64. package/lib/typescript/src/storage/secureStorage.d.ts +16 -0
  65. package/lib/typescript/src/storage/secureStorage.d.ts.map +1 -1
  66. package/lib/typescript/src/sync.d.ts +6 -1
  67. package/lib/typescript/src/sync.d.ts.map +1 -1
  68. package/lib/typescript/src/types/nonceAccount.d.ts +59 -0
  69. package/lib/typescript/src/types/nonceAccount.d.ts.map +1 -0
  70. package/lib/typescript/src/types/tossUser.d.ts +16 -0
  71. package/lib/typescript/src/types/tossUser.d.ts.map +1 -1
  72. package/package.json +12 -1
  73. package/src/__tests__/reconciliation.test.tsx +7 -1
  74. package/src/__tests__/solana-program-simple.test.ts +256 -0
  75. package/src/ble.ts +105 -4
  76. package/src/client/BLETransactionHandler.ts +364 -0
  77. package/src/client/NonceAccountManager.ts +444 -0
  78. package/src/client/TossClient.ts +36 -49
  79. package/src/contexts/WalletContext.tsx +4 -4
  80. package/src/discovery.ts +46 -8
  81. package/src/examples/offlinePaymentFlow.ts +48 -2
  82. package/src/hooks/useOfflineBLETransactions.ts +438 -0
  83. package/src/index.tsx +49 -7
  84. package/src/intent.ts +254 -0
  85. package/src/nfc.ts +4 -4
  86. package/src/noise.ts +239 -1
  87. package/src/reconciliation.ts +184 -0
  88. package/src/services/authService.ts +188 -1
  89. package/src/storage/secureStorage.ts +142 -4
  90. package/src/storage.ts +4 -4
  91. package/src/sync.ts +40 -0
  92. package/src/types/nonceAccount.ts +75 -0
  93. package/src/types/tossUser.ts +35 -2
@@ -21,6 +21,13 @@ import {
21
21
  } from './storage/secureStorage';
22
22
  import { TossError, NetworkError } from './errors';
23
23
 
24
+ // Helper for logging during reconciliation
25
+ const msg = (message: string) => {
26
+ if (typeof console !== 'undefined') {
27
+ console.log(`[TOSS Reconciliation] ${message}`);
28
+ }
29
+ };
30
+
24
31
  /**
25
32
  * Result of intent settlement attempt
26
33
  */
@@ -70,6 +77,14 @@ export async function validateIntentOnchain(
70
77
  };
71
78
  }
72
79
 
80
+ // GAP #3 FIX: Check if sender is a program account (cannot be source of transfer)
81
+ if (senderAccountInfo.executable) {
82
+ return {
83
+ valid: false,
84
+ error: 'Sender is a program account and cannot send funds',
85
+ };
86
+ }
87
+
73
88
  // Validate sender has sufficient balance
74
89
  if (senderAccountInfo.lamports < intent.amount) {
75
90
  return {
@@ -78,6 +93,21 @@ export async function validateIntentOnchain(
78
93
  };
79
94
  }
80
95
 
96
+ // GAP #3 FIX: Check if sender is frozen (token account freezing)
97
+ if (senderAccountInfo.data && senderAccountInfo.data.length > 0) {
98
+ // If account has data, it might be a token account - check frozen status
99
+ // Token account structure: owner (32) + mint (32) + owner (32) + amount (8) + decimals (1) + isInitialized (1) + isFrozen (1)
100
+ if (senderAccountInfo.data.length >= 106) {
101
+ const isFrozen = senderAccountInfo.data[105] !== 0;
102
+ if (isFrozen) {
103
+ return {
104
+ valid: false,
105
+ error: 'Sender account is frozen and cannot send funds',
106
+ };
107
+ }
108
+ }
109
+ }
110
+
81
111
  // Validate recipient exists (if not a system account)
82
112
  const recipientPublicKey = new PublicKey(intent.to);
83
113
  const recipientAccountInfo =
@@ -88,6 +118,30 @@ export async function validateIntentOnchain(
88
118
  // But we should verify it's a valid public key format (already done above)
89
119
  }
90
120
 
121
+ // GAP #3 FIX: Validate nonce account if using durable nonce
122
+ if (intent.nonceAccountAddress && intent.nonceAuth) {
123
+ const nonceAddress = new PublicKey(intent.nonceAccountAddress);
124
+ const nonceAccountInfo = await connection.getAccountInfo(nonceAddress);
125
+
126
+ if (!nonceAccountInfo) {
127
+ return {
128
+ valid: false,
129
+ error: 'Nonce account does not exist',
130
+ };
131
+ }
132
+
133
+ // Check nonce account is owned by SystemProgram
134
+ const SYSTEM_PROGRAM_ID = new PublicKey(
135
+ '11111111111111111111111111111111'
136
+ );
137
+ if (!nonceAccountInfo.owner.equals(SYSTEM_PROGRAM_ID)) {
138
+ return {
139
+ valid: false,
140
+ error: 'Nonce account is not owned by SystemProgram',
141
+ };
142
+ }
143
+ }
144
+
91
145
  // Fetch recent transactions to check for double-spend
92
146
  const signatures = await connection.getSignaturesForAddress(
93
147
  senderPublicKey,
@@ -250,6 +304,136 @@ export async function submitTransactionToChain(
250
304
  );
251
305
  }
252
306
 
307
+ /**
308
+ * GAP #7 FIX: Submit transaction to Arcium MXE program for confidential execution
309
+ * Per TOSS Paper Section 7: "Arcium operates strictly before onchain execution"
310
+ */
311
+ export async function submitTransactionToArciumMXE(
312
+ intent: SolanaIntent,
313
+ connection: Connection,
314
+ mxeProgramId: PublicKey,
315
+ provider: any, // AnchorProvider
316
+ maxRetries: number = 3
317
+ ): Promise<string> {
318
+ if (!intent.encrypted) {
319
+ throw new Error(
320
+ 'Intent must be encrypted with Arcium data to submit to MXE'
321
+ );
322
+ }
323
+
324
+ try {
325
+ // GAP #7 FIX: Actual Arcium MXE Integration
326
+ // Per TOSS Paper Section 7: "Arcium operates strictly before onchain execution"
327
+
328
+ // Import Arcium helper for confidential computation
329
+ const { encryptForArciumInternal } =
330
+ await import('./internal/arciumHelper');
331
+
332
+ // Extract sensitive intent parameters for encryption
333
+ const plaintextValues = [
334
+ BigInt(intent.amount),
335
+ BigInt(intent.nonce),
336
+ BigInt(intent.expiry),
337
+ ];
338
+
339
+ // Encrypt parameters with Arcium
340
+ const encrypted = await encryptForArciumInternal(
341
+ mxeProgramId,
342
+ plaintextValues,
343
+ provider
344
+ );
345
+
346
+ msg?.('🔐 Intent parameters encrypted with Arcium MXE');
347
+
348
+ // PRODUCTION: Build MXE submission instruction
349
+ // Per TOSS Paper Section 7: "Arcium operates strictly before onchain execution"
350
+ // The MXE program will:
351
+ // 1. Receive encrypted intent data
352
+ // 2. Decrypt inside trusted execution environment
353
+ // 3. Validate constraints privately
354
+ // 4. Execute the transfer instruction confidentially
355
+ // 5. Return encrypted result only owner can decrypt
356
+
357
+ // Serialize encrypted data for MXE program instruction
358
+ const encryptedDataBuffer = Buffer.concat([
359
+ // Ephemeral public key (32 bytes)
360
+ Buffer.from(encrypted.publicKey),
361
+ // Nonce (16 bytes)
362
+ Buffer.from(encrypted.nonce),
363
+ // Ciphertext - serialize each field
364
+ Buffer.from(
365
+ JSON.stringify({
366
+ amount: encrypted.ciphertext[0],
367
+ nonce: encrypted.ciphertext[1],
368
+ expiry: encrypted.ciphertext[2],
369
+ })
370
+ ),
371
+ ]);
372
+
373
+ msg?.(
374
+ '🔐 Encrypted data prepared for MXE program (size: ' +
375
+ encryptedDataBuffer.length +
376
+ ' bytes)'
377
+ );
378
+
379
+ // PRODUCTION: Create MXE instruction with encrypted metadata
380
+ // This instruction invokes the MXE program to execute the transfer privately
381
+ const mxeInstruction: any = {
382
+ programId: mxeProgramId,
383
+ keys: [
384
+ { pubkey: intent.from, isSigner: true, isWritable: true }, // Payer
385
+ { pubkey: intent.to, isSigner: false, isWritable: true }, // Recipient
386
+ {
387
+ pubkey: provider.wallet.publicKey,
388
+ isSigner: true,
389
+ isWritable: false,
390
+ }, // Intent signer
391
+ ],
392
+ data: encryptedDataBuffer,
393
+ };
394
+
395
+ msg?.(
396
+ '📤 Submitting encrypted intent to MXE program for confidential execution'
397
+ );
398
+
399
+ // PRODUCTION: Build transaction with MXE instruction
400
+ // The MXE program receives encrypted intent, decrypts privately, and executes
401
+ const mxeTransaction = new (await import('@solana/web3.js')).Transaction();
402
+
403
+ // Add the encrypted MXE instruction
404
+ mxeTransaction.add({
405
+ programId: mxeInstruction.programId,
406
+ keys: mxeInstruction.keys,
407
+ data: mxeInstruction.data,
408
+ });
409
+
410
+ // Set transaction metadata
411
+ const latestBlockhash = await connection.getLatestBlockhash('confirmed');
412
+ mxeTransaction.recentBlockhash = latestBlockhash.blockhash;
413
+ mxeTransaction.lastValidBlockHeight = latestBlockhash.lastValidBlockHeight;
414
+ mxeTransaction.feePayer = provider.wallet.publicKey;
415
+
416
+ // PRODUCTION: Submit encrypted transaction to network
417
+ // Network validators verify signature but cannot see unencrypted intent details
418
+ const mxeSignature = await submitTransactionToChain(
419
+ mxeTransaction,
420
+ connection,
421
+ maxRetries
422
+ );
423
+
424
+ msg?.('✅ MXE transaction submitted - encrypted execution in progress');
425
+ msg?.(' Signature: ' + mxeSignature);
426
+ msg?.(' Intent details remain confidential until settlement');
427
+
428
+ return mxeSignature;
429
+ } catch (error) {
430
+ throw new TossError(
431
+ `Failed to submit transaction to Arcium MXE: ${error instanceof Error ? error.message : String(error)}`,
432
+ 'ARCIUM_SUBMISSION_FAILED'
433
+ );
434
+ }
435
+ }
436
+
253
437
  /**
254
438
  * Attempts to settle a single intent and returns the result
255
439
  */
@@ -1,12 +1,14 @@
1
1
  import * as SecureStore from 'expo-secure-store';
2
- import { Keypair, PublicKey } from '@solana/web3.js';
2
+ import { Keypair, PublicKey, Connection } from '@solana/web3.js';
3
3
  import * as LocalAuthentication from 'expo-local-authentication';
4
4
  import type { TossUser } from '../types/tossUser';
5
5
  import crypto from 'crypto';
6
+ import { NonceAccountManager } from '../client/NonceAccountManager';
6
7
 
7
8
  export const SESSION_KEY = 'toss_user_session';
8
9
  const WALLET_KEY = 'toss_encrypted_wallet';
9
10
  const BIOMETRIC_SALT_KEY = 'toss_biometric_salt';
11
+ const NONCE_ACCOUNT_KEY = 'toss_nonce_account';
10
12
 
11
13
  type UserSession = {
12
14
  id: string;
@@ -43,6 +45,10 @@ export class AuthService {
43
45
  lastActive: new Date().toISOString(),
44
46
  client: 'mobile',
45
47
  },
48
+ security: {
49
+ biometricEnabled: false,
50
+ nonceAccountRequiresBiometric: true,
51
+ },
46
52
  status: 'active',
47
53
  lastSeen: new Date().toISOString(),
48
54
  tossFeatures: {
@@ -50,6 +56,8 @@ export class AuthService {
50
56
  canReceive: true,
51
57
  isPrivateTxEnabled: true,
52
58
  maxTransactionAmount: 10000,
59
+ offlineTransactionsEnabled: false,
60
+ nonceAccountEnabled: false,
53
61
  },
54
62
  createdAt: new Date().toISOString(),
55
63
  updatedAt: new Date().toISOString(),
@@ -235,4 +243,183 @@ export class AuthService {
235
243
  await SecureStore.deleteItemAsync(BIOMETRIC_SALT_KEY);
236
244
  await SecureStore.deleteItemAsync(SESSION_KEY);
237
245
  }
246
+
247
+ /**
248
+ * Create a secure durable nonce account for offline transactions
249
+ * REQUIRES biometric authentication for maximum security
250
+ *
251
+ * This creates a nonce account that enables:
252
+ * - Offline transaction creation (with replay protection)
253
+ * - Biometric-protected signing
254
+ * - Encrypted storage with Noise Protocol support
255
+ */
256
+ static async createSecureNonceAccount(
257
+ user: TossUser,
258
+ connection: Connection,
259
+ userKeypair: Keypair
260
+ ): Promise<TossUser> {
261
+ // Verify biometric is available and enrolled
262
+ const hasHardware = await LocalAuthentication.hasHardwareAsync();
263
+ const isEnrolled = await LocalAuthentication.isEnrolledAsync();
264
+
265
+ if (!hasHardware || !isEnrolled) {
266
+ throw new Error(
267
+ '❌ Biometric authentication required but not configured on this device'
268
+ );
269
+ }
270
+
271
+ // Require biometric verification before creating nonce account
272
+ const result = await LocalAuthentication.authenticateAsync({
273
+ promptMessage: 'Biometric verification required to create nonce account',
274
+ fallbackLabel: 'Use PIN',
275
+ disableDeviceFallback: false,
276
+ });
277
+
278
+ if (!result.success) {
279
+ throw new Error(
280
+ 'Biometric verification failed - nonce account creation denied'
281
+ );
282
+ }
283
+
284
+ try {
285
+ // Initialize nonce account manager
286
+ const nonceManager = new NonceAccountManager(connection);
287
+
288
+ // Generate nonce authority keypair (separate from user wallet for security)
289
+ const nonceAuthorityKeypair = Keypair.generate();
290
+
291
+ // Create the nonce account
292
+ const nonceAccountInfo = await nonceManager.createNonceAccount(
293
+ user,
294
+ nonceAuthorityKeypair,
295
+ userKeypair.publicKey,
296
+ {
297
+ requireBiometric: true,
298
+ securityLevel: 'high',
299
+ persistToSecureStorage: true,
300
+ autoRenew: true,
301
+ }
302
+ );
303
+
304
+ // Update user with nonce account information
305
+ const updatedUser: TossUser = {
306
+ ...user,
307
+ nonceAccount: {
308
+ address: new PublicKey(nonceAccountInfo.address),
309
+ authorizedSigner: new PublicKey(nonceAccountInfo.authorizedSigner),
310
+ isBiometricProtected: true,
311
+ status: 'active',
312
+ },
313
+ security: {
314
+ ...user.security,
315
+ biometricEnabled: true,
316
+ nonceAccountRequiresBiometric: true,
317
+ lastBiometricVerification: Math.floor(Date.now() / 1000),
318
+ },
319
+ tossFeatures: {
320
+ ...user.tossFeatures,
321
+ offlineTransactionsEnabled: true,
322
+ nonceAccountEnabled: true,
323
+ },
324
+ updatedAt: new Date().toISOString(),
325
+ };
326
+
327
+ return updatedUser;
328
+ } catch (error) {
329
+ const errorMessage =
330
+ error instanceof Error ? error.message : String(error);
331
+ throw new Error(`Failed to create nonce account: ${errorMessage}`);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Enable offline transactions for a user with nonce account support
337
+ * Ensures all security measures are in place
338
+ */
339
+ static async enableOfflineTransactions(user: TossUser): Promise<TossUser> {
340
+ // Verify user has nonce account
341
+ if (!user.nonceAccount) {
342
+ throw new Error('User does not have a nonce account. Create one first.');
343
+ }
344
+
345
+ // Verify biometric is enabled
346
+ if (!user.security.biometricEnabled) {
347
+ throw new Error('Biometric authentication must be enabled first');
348
+ }
349
+
350
+ // Update user with offline transactions enabled
351
+ return {
352
+ ...user,
353
+ tossFeatures: {
354
+ ...user.tossFeatures,
355
+ offlineTransactionsEnabled: true,
356
+ nonceAccountEnabled: true,
357
+ },
358
+ updatedAt: new Date().toISOString(),
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Verify nonce account is accessible and valid
364
+ * Requires biometric authentication
365
+ */
366
+ static async verifyNonceAccountAccess(userId: string): Promise<boolean> {
367
+ try {
368
+ // Verify biometric
369
+ const result = await LocalAuthentication.authenticateAsync({
370
+ promptMessage: 'Verify nonce account access with biometric',
371
+ fallbackLabel: 'Use PIN',
372
+ disableDeviceFallback: false,
373
+ });
374
+
375
+ if (!result.success) {
376
+ return false;
377
+ }
378
+
379
+ // Check if nonce account exists in secure storage
380
+ const storageKey = `${NONCE_ACCOUNT_KEY}_${userId}`;
381
+ const stored = await SecureStore.getItemAsync(storageKey);
382
+
383
+ return stored !== null;
384
+ } catch (error) {
385
+ console.error('Failed to verify nonce account access:', error);
386
+ return false;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Revoke nonce account (security measure)
392
+ * Requires biometric verification
393
+ */
394
+ static async revokeNonceAccount(
395
+ userId: string,
396
+ user: TossUser
397
+ ): Promise<TossUser> {
398
+ // Require biometric verification
399
+ const result = await LocalAuthentication.authenticateAsync({
400
+ promptMessage: 'Biometric verification required to revoke nonce account',
401
+ fallbackLabel: 'Use PIN',
402
+ disableDeviceFallback: false,
403
+ });
404
+
405
+ if (!result.success) {
406
+ throw new Error('Biometric verification failed - revocation denied');
407
+ }
408
+
409
+ // Remove nonce account from storage
410
+ const storageKey = `${NONCE_ACCOUNT_KEY}_${userId}`;
411
+ await SecureStore.deleteItemAsync(storageKey);
412
+
413
+ // Update user
414
+ return {
415
+ ...user,
416
+ nonceAccount: undefined,
417
+ tossFeatures: {
418
+ ...user.tossFeatures,
419
+ offlineTransactionsEnabled: false,
420
+ nonceAccountEnabled: false,
421
+ },
422
+ updatedAt: new Date().toISOString(),
423
+ };
424
+ }
238
425
  }
@@ -24,7 +24,7 @@ export async function secureStoreIntent(intent: SolanaIntent): Promise<void> {
24
24
  try {
25
25
  const key = `${STORAGE_PREFIX}${intent.id}`;
26
26
  await SecureStore.setItemAsync(key, JSON.stringify(intent));
27
-
27
+
28
28
  // Update the keys list
29
29
  const keys = await getAllKeys();
30
30
  if (!keys.includes(key)) {
@@ -74,10 +74,10 @@ export async function removeSecureIntent(intentId: string): Promise<void> {
74
74
  try {
75
75
  const key = `${STORAGE_PREFIX}${intentId}`;
76
76
  await SecureStore.deleteItemAsync(key);
77
-
77
+
78
78
  // Update the keys list
79
79
  const keys = await getAllKeys();
80
- const updatedKeys = keys.filter(k => k !== key);
80
+ const updatedKeys = keys.filter((k) => k !== key);
81
81
  await saveKeys(updatedKeys);
82
82
  } catch (error) {
83
83
  throw new StorageError('Failed to remove intent', {
@@ -97,4 +97,142 @@ export async function clearAllSecureIntents(): Promise<void> {
97
97
  } catch (error) {
98
98
  throw new StorageError('Failed to clear all intents', { cause: error });
99
99
  }
100
- }
100
+ }
101
+
102
+ /**
103
+ * GAP #1: Cleanup expired intents from local storage
104
+ * Per TOSS Paper Section 8: Local state is append-only until settlement confirmation
105
+ */
106
+ export async function cleanupExpiredIntents(): Promise<number> {
107
+ try {
108
+ const intents = await getAllSecureIntents();
109
+ const now = Math.floor(Date.now() / 1000);
110
+ let cleanedCount = 0;
111
+
112
+ for (const intent of intents) {
113
+ if (intent.expiry < now) {
114
+ await removeSecureIntent(intent.id);
115
+ cleanedCount++;
116
+ }
117
+ }
118
+
119
+ return cleanedCount;
120
+ } catch (error) {
121
+ throw new StorageError('Failed to cleanup expired intents', {
122
+ cause: error,
123
+ });
124
+ }
125
+ }
126
+
127
+ /**
128
+ * GAP #2: Store and retrieve reconciliation state
129
+ * Per TOSS Paper Section 9: Track which intents have been synced/failed/conflicted
130
+ */
131
+ const RECONCILIATION_STATE_KEY = 'toss_reconciliation_state_';
132
+
133
+ export interface ReconciliationStateData {
134
+ userId: string;
135
+ lastSyncTime: number;
136
+ lastSyncSlot: number;
137
+ processedIntents: string[]; // Intent IDs successfully settled
138
+ failedIntents: string[]; // Intent IDs that failed/were rejected
139
+ conflictingIntents: string[]; // Intent IDs with detected conflicts
140
+ }
141
+
142
+ export async function saveReconciliationState(
143
+ userId: string,
144
+ state: Partial<ReconciliationStateData>
145
+ ): Promise<void> {
146
+ try {
147
+ const key = `${RECONCILIATION_STATE_KEY}${userId}`;
148
+ const existing = await SecureStore.getItemAsync(key);
149
+ const currentState: ReconciliationStateData = existing
150
+ ? JSON.parse(existing)
151
+ : {
152
+ userId,
153
+ lastSyncTime: 0,
154
+ lastSyncSlot: 0,
155
+ processedIntents: [],
156
+ failedIntents: [],
157
+ conflictingIntents: [],
158
+ };
159
+
160
+ const merged: ReconciliationStateData = {
161
+ ...currentState,
162
+ ...state,
163
+ userId, // Always preserve userId
164
+ };
165
+
166
+ await SecureStore.setItemAsync(key, JSON.stringify(merged));
167
+ } catch (error) {
168
+ throw new StorageError('Failed to save reconciliation state', {
169
+ cause: error,
170
+ userId,
171
+ });
172
+ }
173
+ }
174
+
175
+ export async function getReconciliationState(
176
+ userId: string
177
+ ): Promise<ReconciliationStateData> {
178
+ try {
179
+ const key = `${RECONCILIATION_STATE_KEY}${userId}`;
180
+ const value = await SecureStore.getItemAsync(key);
181
+
182
+ if (value) {
183
+ return JSON.parse(value);
184
+ }
185
+
186
+ return {
187
+ userId,
188
+ lastSyncTime: 0,
189
+ lastSyncSlot: 0,
190
+ processedIntents: [],
191
+ failedIntents: [],
192
+ conflictingIntents: [],
193
+ };
194
+ } catch (error) {
195
+ throw new StorageError('Failed to retrieve reconciliation state', {
196
+ cause: error,
197
+ userId,
198
+ });
199
+ }
200
+ }
201
+
202
+ export async function updateReconciliationState(
203
+ userId: string,
204
+ intentId: string,
205
+ status: 'processed' | 'failed' | 'conflicted'
206
+ ): Promise<void> {
207
+ try {
208
+ const current = await getReconciliationState(userId);
209
+
210
+ // Remove from all lists first
211
+ current.processedIntents = current.processedIntents.filter(
212
+ (id) => id !== intentId
213
+ );
214
+ current.failedIntents = current.failedIntents.filter(
215
+ (id) => id !== intentId
216
+ );
217
+ current.conflictingIntents = current.conflictingIntents.filter(
218
+ (id) => id !== intentId
219
+ );
220
+
221
+ // Add to appropriate list
222
+ if (status === 'processed') {
223
+ current.processedIntents.push(intentId);
224
+ } else if (status === 'failed') {
225
+ current.failedIntents.push(intentId);
226
+ } else if (status === 'conflicted') {
227
+ current.conflictingIntents.push(intentId);
228
+ }
229
+
230
+ await saveReconciliationState(userId, current);
231
+ } catch (error) {
232
+ throw new StorageError('Failed to update reconciliation state', {
233
+ cause: error,
234
+ userId,
235
+ intentId,
236
+ });
237
+ }
238
+ }
package/src/storage.ts CHANGED
@@ -1,15 +1,15 @@
1
- import AsyncStorage from "@react-native-async-storage/async-storage";
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
2
 
3
- const INTENTS_KEY = "TOSS_PENDING_INTENTS";
3
+ const INTENTS_KEY = 'TOSS_PENDING_INTENTS';
4
4
 
5
5
  export async function storePendingIntent(intent: any) {
6
- const current = JSON.parse((await AsyncStorage.getItem(INTENTS_KEY)) || "[]");
6
+ const current = JSON.parse((await AsyncStorage.getItem(INTENTS_KEY)) || '[]');
7
7
  current.push(intent);
8
8
  await AsyncStorage.setItem(INTENTS_KEY, JSON.stringify(current));
9
9
  }
10
10
 
11
11
  export async function getPendingIntents() {
12
- return JSON.parse((await AsyncStorage.getItem(INTENTS_KEY)) || "[]");
12
+ return JSON.parse((await AsyncStorage.getItem(INTENTS_KEY)) || '[]');
13
13
  }
14
14
 
15
15
  export async function clearPendingIntents() {
package/src/sync.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Implements Section 9 of the TOSS Technical Paper:
5
5
  * Upon regaining connectivity, devices initiate reconciliation with onchain state.
6
6
  * All offline artifacts are verified onchain and settled with deterministic outcomes.
7
+ *
8
+ * GAP #2 FIX: Track synchronization state persistently
7
9
  */
8
10
 
9
11
  import { Connection, PublicKey } from '@solana/web3.js';
@@ -14,6 +16,7 @@ import {
14
16
  type SettlementResult,
15
17
  type ReconciliationState,
16
18
  } from './reconciliation';
19
+ import { updateReconciliationState } from './storage/secureStorage';
17
20
  import { NetworkError } from './errors';
18
21
 
19
22
  export interface SyncResult {
@@ -40,12 +43,16 @@ export interface SyncResult {
40
43
  * 2. Settle all pending intents
41
44
  * 3. Update local state with results
42
45
  *
46
+ * GAP #2 FIX: Persist reconciliation state for future queries
47
+ *
43
48
  * @param connection Connection to Solana RPC
49
+ * @param userId User ID for state tracking (required for persistence)
44
50
  * @param feePayer Optional fee payer keypair public key
45
51
  * @returns Detailed sync results including conflicts and settlements
46
52
  */
47
53
  export async function syncToChain(
48
54
  connection: Connection,
55
+ userId?: string,
49
56
  feePayer?: PublicKey
50
57
  ): Promise<SyncResult> {
51
58
  const syncTimestamp = Math.floor(Date.now() / 1000);
@@ -71,6 +78,39 @@ export async function syncToChain(
71
78
  // Step 4: Get final reconciliation state
72
79
  const reconciliationState = await getReconciliationState(connection);
73
80
 
81
+ // GAP #2 FIX: Persist reconciliation state to storage
82
+ if (userId) {
83
+ try {
84
+ // Update individual intent statuses
85
+ for (const settlement of successfulSettlements) {
86
+ await updateReconciliationState(
87
+ userId,
88
+ settlement.intentId,
89
+ 'processed'
90
+ );
91
+ }
92
+ for (const settlement of failedSettlements) {
93
+ await updateReconciliationState(
94
+ userId,
95
+ settlement.intentId,
96
+ 'failed'
97
+ );
98
+ }
99
+ for (const conflict of detectedConflicts) {
100
+ await updateReconciliationState(
101
+ userId,
102
+ conflict.intentId,
103
+ 'conflicted'
104
+ );
105
+ }
106
+ } catch (storageError) {
107
+ console.warn(
108
+ 'Failed to update reconciliation state storage:',
109
+ storageError
110
+ );
111
+ }
112
+ }
113
+
74
114
  const isComplete =
75
115
  failedSettlements.length === 0 && detectedConflicts.length === 0;
76
116