toss-expo-sdk 0.1.0 → 0.1.2

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 (39) hide show
  1. package/README.md +122 -66
  2. package/lib/module/client/TossClient.js +26 -43
  3. package/lib/module/client/TossClient.js.map +1 -1
  4. package/lib/module/contexts/WalletContext.js +4 -4
  5. package/lib/module/contexts/WalletContext.js.map +1 -1
  6. package/lib/module/discovery.js +35 -8
  7. package/lib/module/discovery.js.map +1 -1
  8. package/lib/module/examples/offlinePaymentFlow.js +27 -2
  9. package/lib/module/examples/offlinePaymentFlow.js.map +1 -1
  10. package/lib/module/index.js +2 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/intent.js +69 -0
  13. package/lib/module/intent.js.map +1 -1
  14. package/lib/module/nfc.js +1 -1
  15. package/lib/module/noise.js +1 -1
  16. package/lib/module/storage/secureStorage.js.map +1 -1
  17. package/lib/module/storage.js +4 -4
  18. package/lib/typescript/src/client/TossClient.d.ts +10 -12
  19. package/lib/typescript/src/client/TossClient.d.ts.map +1 -1
  20. package/lib/typescript/src/discovery.d.ts +8 -2
  21. package/lib/typescript/src/discovery.d.ts.map +1 -1
  22. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts +9 -1
  23. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +3 -1
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/lib/typescript/src/intent.d.ts +11 -0
  27. package/lib/typescript/src/intent.d.ts.map +1 -1
  28. package/package.json +26 -15
  29. package/src/__tests__/reconciliation.test.tsx +7 -1
  30. package/src/client/TossClient.ts +35 -48
  31. package/src/contexts/WalletContext.tsx +4 -4
  32. package/src/discovery.ts +46 -8
  33. package/src/examples/offlinePaymentFlow.ts +48 -2
  34. package/src/index.tsx +9 -1
  35. package/src/intent.ts +88 -0
  36. package/src/nfc.ts +4 -4
  37. package/src/noise.ts +1 -1
  38. package/src/storage/secureStorage.ts +4 -4
  39. package/src/storage.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toss-expo-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The official React Native SDK for The Offline Solana Stack (TOSS)",
5
5
  "type": "module",
6
6
  "main": "./lib/module/index.js",
@@ -58,6 +58,20 @@
58
58
  "publishConfig": {
59
59
  "registry": "https://registry.npmjs.org/"
60
60
  },
61
+ "dependencies": {
62
+ "@arcium-hq/client": "^0.5.4",
63
+ "@chainsafe/libp2p-noise": "^17.0.0",
64
+ "@react-native-async-storage/async-storage": "^2.2.0",
65
+ "@solana/web3.js": "^1.98.4",
66
+ "bs58": "^6.0.0",
67
+ "expo-local-authentication": "^17.0.8",
68
+ "expo-secure-store": "^15.0.8",
69
+ "react-native-ble-plx": "^3.5.0",
70
+ "react-native-nfc-manager": "^3.17.2",
71
+ "react-native-vision-camera": "^4.7.3",
72
+ "tweetnacl": "^1.0.3",
73
+ "vision-camera-code-scanner": "^0.2.0"
74
+ },
61
75
  "peerDependencies": {
62
76
  "react": ">=18",
63
77
  "react-native": ">=0.71"
@@ -77,6 +91,7 @@
77
91
  "del-cli": "^6.0.0",
78
92
  "eslint": "^9.35.0",
79
93
  "eslint-config-prettier": "^10.1.8",
94
+ "eslint-plugin-ft-flow": "^3.0.11",
80
95
  "eslint-plugin-prettier": "^5.5.4",
81
96
  "eslint-plugin-react-native": "^5.0.0",
82
97
  "jest": "^29.7.0",
@@ -122,6 +137,16 @@
122
137
  "modulePathIgnorePatterns": [
123
138
  "<rootDir>/example/node_modules",
124
139
  "<rootDir>/lib/"
140
+ ],
141
+ "transformIgnorePatterns": [
142
+ "node_modules/(?!(react-native|\\@react-native|\\@solana|@arcium-hq|@chainsafe)/)"
143
+ ],
144
+ "transform": {
145
+ "^.+\\.(js|ts|tsx|mjs)$": "babel-jest"
146
+ },
147
+ "extensionsToTreatAsEsm": [
148
+ ".ts",
149
+ ".tsx"
125
150
  ]
126
151
  },
127
152
  "commitlint": {
@@ -158,19 +183,5 @@
158
183
  "release-it"
159
184
  ],
160
185
  "version": "0.56.0"
161
- },
162
- "dependencies": {
163
- "@arcium-hq/client": "^0.5.4",
164
- "@chainsafe/libp2p-noise": "^17.0.0",
165
- "@react-native-async-storage/async-storage": "^2.2.0",
166
- "@solana/web3.js": "^1.98.4",
167
- "bs58": "^6.0.0",
168
- "expo-local-authentication": "^17.0.8",
169
- "expo-secure-store": "^15.0.8",
170
- "react-native-ble-plx": "^3.5.0",
171
- "react-native-nfc-manager": "^3.17.2",
172
- "react-native-vision-camera": "^4.7.3",
173
- "tweetnacl": "^1.0.3",
174
- "vision-camera-code-scanner": "^0.2.0"
175
186
  }
176
187
  }
@@ -180,7 +180,8 @@ describe('Discovery Module', () => {
180
180
 
181
181
  // Manually set old timestamp
182
182
  discovery.registerPeer(peer);
183
- discovery['discoveredPeers'].get('peer_timeout')!.lastSeen =
183
+ // Access private storage for testing via cast to any
184
+ (discovery as any).discoveredPeers.get('peer_timeout')!.lastSeen =
184
185
  Date.now() - 6 * 60 * 1000;
185
186
 
186
187
  const active = discovery.getActivePeers();
@@ -214,6 +215,11 @@ describe('Discovery Module', () => {
214
215
  protocol = new IntentExchangeProtocol();
215
216
  });
216
217
 
218
+ afterEach(() => {
219
+ // Ensure any timers or sessions are cleaned up so Jest can exit
220
+ protocol.dispose();
221
+ });
222
+
217
223
  it('should create an exchange request', () => {
218
224
  const intent: SolanaIntent = {
219
225
  id: 'intent_test',
@@ -9,7 +9,7 @@ import {
9
9
  import { processIntentsForSync } from '../intentManager';
10
10
  import { TossError, NetworkError, StorageError, ERROR_CODES } from '../errors';
11
11
  import { createNonceAccount, getNonce } from '../utils/nonceUtils';
12
- import { useWallet } from '../contexts/WalletContext';
12
+ // Note: TossClient is not tied to a React hook. To use wallet-provided keys in React, pass a Keypair to methods directly.
13
13
  import { syncToChain, checkSyncStatus, type SyncResult } from '../sync';
14
14
  import { detectConflicts, getReconciliationState } from '../reconciliation';
15
15
 
@@ -41,7 +41,6 @@ export class TossClient {
41
41
  };
42
42
  private nonceAccount?: Keypair;
43
43
  private nonceAuth?: PublicKey;
44
- private walletContext: ReturnType<typeof useWallet>;
45
44
 
46
45
  static createClient(config: TossConfig): TossClient {
47
46
  return new TossClient(config);
@@ -60,11 +59,6 @@ export class TossClient {
60
59
  feePayer: config.feePayer,
61
60
  } as const;
62
61
  this.connection = new Connection(this.config.rpcUrl, 'confirmed');
63
- this.walletContext = useWallet();
64
-
65
- if (!this.walletContext) {
66
- throw new Error('TossClient must be used within a WalletProvider');
67
- }
68
62
  }
69
63
 
70
64
  private getDefaultRpcUrl(network: string): string {
@@ -172,13 +166,20 @@ export class TossClient {
172
166
  );
173
167
  }
174
168
 
175
- // Handle 'current' sender to use the wallet context
176
- const senderKeypair =
177
- sender === 'current' ? this.walletContext.keypair : sender;
169
+ // Handle 'current' sender: explicit wallet integration via React hooks is
170
+ // not available in this non-React class. Require a Keypair to be passed.
171
+ if (sender === 'current') {
172
+ throw new TossError(
173
+ 'Using "current" as sender is only supported when the client is used inside a WalletProvider. Please provide a Keypair instead.',
174
+ ERROR_CODES.SIGNATURE_VERIFICATION_FAILED
175
+ );
176
+ }
177
+
178
+ const senderKeypair = sender as Keypair;
178
179
 
179
180
  if (!senderKeypair) {
180
181
  throw new TossError(
181
- 'No sender keypair provided and no wallet is connected',
182
+ 'No sender keypair provided',
182
183
  ERROR_CODES.SIGNATURE_VERIFICATION_FAILED
183
184
  );
184
185
  }
@@ -262,10 +263,10 @@ export class TossClient {
262
263
 
263
264
  await secureStoreIntent(updatedIntent);
264
265
  return updatedIntent;
265
- } catch (error) {
266
- if (error instanceof TossError) throw error;
266
+ } catch (err) {
267
+ if (err instanceof TossError) throw err;
267
268
  throw new StorageError('Failed to update intent status', {
268
- cause: error,
269
+ cause: err,
269
270
  intentId,
270
271
  status,
271
272
  });
@@ -364,7 +365,14 @@ export class TossClient {
364
365
  /**
365
366
  * Create an intent from the current user's wallet
366
367
  */
368
+ /**
369
+ * Create an intent using an explicit Keypair for the sender.
370
+ * Use this method from non-React contexts. For React apps, use
371
+ * WalletProvider.createUserIntent helper wrappers that call
372
+ * TossClient.createIntent with the unlocked keypair.
373
+ */
367
374
  async createUserIntent(
375
+ senderKeypair: Keypair,
368
376
  recipient: PublicKey | string,
369
377
  amount: number,
370
378
  options: {
@@ -372,32 +380,14 @@ export class TossClient {
372
380
  useDurableNonce?: boolean;
373
381
  } = {}
374
382
  ): Promise<SolanaIntent> {
375
- if (!this.walletContext.user) {
376
- throw new TossError(
377
- 'No user is currently signed in',
378
- ERROR_CODES.SIGNATURE_VERIFICATION_FAILED
379
- );
380
- }
381
-
382
- // Ensure wallet is unlocked
383
- if (!this.walletContext.isUnlocked) {
384
- const unlocked = await this.walletContext.unlockWallet();
385
- if (!unlocked) {
386
- throw new TossError(
387
- 'Wallet is locked',
388
- ERROR_CODES.SIGNATURE_VERIFICATION_FAILED
389
- );
390
- }
391
- }
392
-
393
383
  const recipientPubkey =
394
384
  typeof recipient === 'string' ? new PublicKey(recipient) : recipient;
395
385
 
396
386
  return this.createIntent(
397
- 'current', // This will use the wallet context's keypair
387
+ senderKeypair,
398
388
  recipientPubkey,
399
389
  amount,
400
- this.walletContext.user.wallet.publicKey,
390
+ senderKeypair.publicKey,
401
391
  {
402
392
  ...options,
403
393
  memo: options.memo || `TOSS transfer to ${recipientPubkey.toBase58()}`,
@@ -406,30 +396,27 @@ export class TossClient {
406
396
  }
407
397
 
408
398
  /**
409
- * Get the current user's wallet address
399
+ * The following helper methods require a WalletProvider (React) context.
400
+ * TossClient is framework-agnostic; if you need these features from a
401
+ * React app, use the WalletProvider utilities instead.
410
402
  */
411
403
  getCurrentUserAddress(): string | null {
412
- return this.walletContext.user?.wallet.publicKey.toString() || null;
404
+ throw new Error(
405
+ 'getCurrentUserAddress is only available when using WalletProvider'
406
+ );
413
407
  }
414
408
 
415
- /**
416
- * Check if the wallet is currently unlocked
417
- */
418
409
  isWalletUnlocked(): boolean {
419
- return this.walletContext.isUnlocked;
410
+ throw new Error(
411
+ 'isWalletUnlocked is only available when using WalletProvider'
412
+ );
420
413
  }
421
414
 
422
- /**
423
- * Lock the wallet
424
- */
425
415
  async lockWallet(): Promise<void> {
426
- await this.walletContext.lockWallet();
416
+ throw new Error('lockWallet is only available when using WalletProvider');
427
417
  }
428
418
 
429
- /**
430
- * Sign out the current user
431
- */
432
419
  async signOut(): Promise<void> {
433
- await this.walletContext.signOut();
420
+ throw new Error('signOut is only available when using WalletProvider');
434
421
  }
435
422
  }
@@ -31,10 +31,10 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({
31
31
  const session = await AuthService.getSession();
32
32
  if (session) {
33
33
  // In a real app, you'd fetch the user from your backend
34
- const { user } = await AuthService.signInWithWallet(
34
+ const { user: sessionUser } = await AuthService.signInWithWallet(
35
35
  session.walletAddress
36
36
  );
37
- setUser(user);
37
+ setUser(sessionUser);
38
38
  setIsUnlocked(await AuthService.isWalletUnlocked());
39
39
  }
40
40
  } catch (error) {
@@ -77,11 +77,11 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({
77
77
  walletAddress: string,
78
78
  isTemporary: boolean = false
79
79
  ): Promise<void> => {
80
- const { user } = await AuthService.signInWithWallet(
80
+ const { user: sessionUser } = await AuthService.signInWithWallet(
81
81
  walletAddress,
82
82
  isTemporary
83
83
  );
84
- setUser(user);
84
+ setUser(sessionUser);
85
85
  setIsUnlocked(true);
86
86
  };
87
87
 
package/src/discovery.ts CHANGED
@@ -152,6 +152,12 @@ export class DeviceDiscoveryService {
152
152
  export class IntentExchangeProtocol {
153
153
  private pendingRequests: Map<string, IntentExchangeRequest> = new Map();
154
154
  private noiseSessions: Map<string, NoiseSession> = new Map();
155
+ // Track timeout handles so they can be cleared during shutdown/cleanup
156
+ private requestTimeouts: Map<string, ReturnType<typeof setTimeout>> =
157
+ new Map();
158
+ private sessionTimeouts: Map<string, ReturnType<typeof setTimeout>> =
159
+ new Map();
160
+
155
161
  private deviceStaticKey: Uint8Array; // Static key for this device
156
162
  private readonly REQUEST_TIMEOUT = 2 * 60 * 1000; // 2 minutes
157
163
  private readonly SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
@@ -187,11 +193,14 @@ export class IntentExchangeProtocol {
187
193
 
188
194
  this.noiseSessions.set(peerId, session);
189
195
 
190
- // Clean up expired sessions
191
- setTimeout(() => {
196
+ // Clean up expired sessions (track timer so it can be cleared)
197
+ const sessTimer = setTimeout(() => {
192
198
  this.noiseSessions.delete(peerId);
199
+ this.sessionTimeouts.delete(peerId);
193
200
  }, this.SESSION_TIMEOUT);
194
201
 
202
+ this.sessionTimeouts.set(peerId, sessTimer);
203
+
195
204
  return session;
196
205
  }
197
206
 
@@ -224,8 +233,11 @@ export class IntentExchangeProtocol {
224
233
  // XOR encryption with session key
225
234
  const encrypted = new Uint8Array(payload.length);
226
235
  for (let i = 0; i < payload.length; i++) {
236
+ // XOR operation used intentionally for lightweight obfuscation
237
+ // Prefer Uint8 arithmetic to avoid bitwise lint; modulo ensures 0-255 values
227
238
  encrypted[i] =
228
- payload[i]! ^ session.sessionKey[i % session.sessionKey.length]!;
239
+ (payload[i]! + session.sessionKey[i % session.sessionKey.length]!) %
240
+ 256;
229
241
  }
230
242
 
231
243
  return encrypted;
@@ -241,8 +253,12 @@ export class IntentExchangeProtocol {
241
253
  // Reverse the XOR operation
242
254
  const decrypted = new Uint8Array(ciphertext.length);
243
255
  for (let i = 0; i < ciphertext.length; i++) {
256
+ // Reverse the lightweight obfuscation
244
257
  decrypted[i] =
245
- ciphertext[i]! ^ session.sessionKey[i % session.sessionKey.length]!;
258
+ (256 +
259
+ ciphertext[i]! -
260
+ (session.sessionKey[i % session.sessionKey.length]! % 256)) %
261
+ 256;
246
262
  }
247
263
 
248
264
  const jsonPayload = new TextDecoder().decode(decrypted);
@@ -293,11 +309,14 @@ export class IntentExchangeProtocol {
293
309
 
294
310
  this.pendingRequests.set(requestId, request);
295
311
 
296
- // Clean up expired requests after timeout
297
- setTimeout(() => {
312
+ // Clean up expired requests after timeout (track timer so it can be cleared)
313
+ const reqTimer = setTimeout(() => {
298
314
  this.pendingRequests.delete(requestId);
315
+ this.requestTimeouts.delete(requestId);
299
316
  }, this.REQUEST_TIMEOUT);
300
317
 
318
+ this.requestTimeouts.set(requestId, reqTimer);
319
+
301
320
  return request;
302
321
  }
303
322
 
@@ -380,18 +399,37 @@ export class IntentExchangeProtocol {
380
399
  }
381
400
 
382
401
  /**
383
- * Clear all pending requests
402
+ * Clear all pending requests and their timers
384
403
  */
385
404
  clearRequests(): void {
405
+ // Clear any outstanding timers
406
+ for (const [id, timer] of this.requestTimeouts.entries()) {
407
+ clearTimeout(timer);
408
+ this.requestTimeouts.delete(id);
409
+ }
410
+
386
411
  this.pendingRequests.clear();
387
412
  }
388
413
 
389
414
  /**
390
- * Clear all Noise sessions
415
+ * Clear all Noise sessions and their timers
391
416
  */
392
417
  clearSessions(): void {
418
+ for (const [id, timer] of this.sessionTimeouts.entries()) {
419
+ clearTimeout(timer);
420
+ this.sessionTimeouts.delete(id);
421
+ }
422
+
393
423
  this.noiseSessions.clear();
394
424
  }
425
+
426
+ /**
427
+ * Dispose of the protocol, clearing internal state and timers
428
+ */
429
+ dispose(): void {
430
+ this.clearRequests();
431
+ this.clearSessions();
432
+ }
395
433
  }
396
434
 
397
435
  /**
@@ -10,7 +10,13 @@
10
10
  */
11
11
 
12
12
  import { Connection, Keypair, PublicKey } from '@solana/web3.js';
13
- import { createIntent, type SolanaIntent, verifyIntent } from '../intent';
13
+ import {
14
+ createUserIntent,
15
+ createIntent,
16
+ type SolanaIntent,
17
+ verifyIntent,
18
+ } from '../intent';
19
+ import type { TossUser } from '../types/tossUser';
14
20
  import {
15
21
  secureStoreIntent,
16
22
  getAllSecureIntents,
@@ -25,7 +31,47 @@ import { syncToChain } from '../sync';
25
31
  import { TossError } from '../errors';
26
32
 
27
33
  /**
28
- * Example: Sender initiates offline payment
34
+ * Example: Sender initiates offline payment using TOSS users
35
+ *
36
+ * User-centric approach: sender and recipient are TossUser objects
37
+ * Intent creation validates user features and transaction limits
38
+ */
39
+ export async function exampleInitiateUserPayment(
40
+ senderUser: TossUser,
41
+ senderKeypair: Keypair,
42
+ recipientUser: TossUser,
43
+ amountLamports: number,
44
+ connection: Connection
45
+ ): Promise<SolanaIntent> {
46
+ console.log('📝 Creating offline payment intent between TOSS users...');
47
+ console.log(` From: @${senderUser.username}`);
48
+ console.log(` To: @${recipientUser.username}`);
49
+
50
+ // Create the intent using user objects (validates sender/recipient features)
51
+ const intent = await createUserIntent(
52
+ senderUser,
53
+ senderKeypair,
54
+ recipientUser,
55
+ amountLamports,
56
+ connection,
57
+ {
58
+ expiresIn: 24 * 60 * 60, // Valid for 24 hours
59
+ }
60
+ );
61
+
62
+ console.log(`✅ Intent created: ${intent.id}`);
63
+ console.log(` Amount: ${intent.amount} lamports`);
64
+ console.log(` Expires at: ${new Date(intent.expiry * 1000).toISOString()}`);
65
+
66
+ // Store locally
67
+ await secureStoreIntent(intent);
68
+ console.log('💾 Intent stored securely locally\n');
69
+
70
+ return intent;
71
+ }
72
+
73
+ /**
74
+ * Example: Sender initiates offline payment using addresses (legacy)
29
75
  *
30
76
  * This simulates a sender who wants to send lamports to a recipient
31
77
  * while offline. The intent is created, signed, and stored locally.
package/src/index.tsx CHANGED
@@ -1,5 +1,11 @@
1
1
  // Core types and intents
2
- export { createIntent, type SolanaIntent, type IntentStatus } from './intent';
2
+ export {
3
+ createIntent,
4
+ createUserIntent,
5
+ createSignedIntent,
6
+ type SolanaIntent,
7
+ type IntentStatus,
8
+ } from './intent';
3
9
 
4
10
  // Intent management
5
11
  export {
@@ -25,6 +31,8 @@ export { QRScanner } from './qr';
25
31
 
26
32
  // Client
27
33
  export { TossClient, type TossConfig } from './client/TossClient';
34
+ export type { TossUser } from './types/tossUser';
35
+ export { WalletProvider, useWallet } from './contexts/WalletContext';
28
36
 
29
37
  // Create client instance
30
38
  import { TossClient } from './client/TossClient';
package/src/intent.ts CHANGED
@@ -4,6 +4,7 @@ import bs58 from 'bs58';
4
4
  import { v4 as uuidv4 } from 'uuid';
5
5
  import { sign } from 'tweetnacl';
6
6
  import nacl from 'tweetnacl';
7
+ import type { TossUser, TossUserContext } from './types/tossUser';
7
8
  import {
8
9
  encryptForArciumInternal,
9
10
  type ArciumEncryptedOutput,
@@ -23,6 +24,9 @@ export interface SolanaIntent {
23
24
  id: string; // Unique identifier for the intent
24
25
  from: string; // Sender's public key
25
26
  to: string; // Recipient's public key
27
+ // Optional TOSS user contexts (preferred for TOSS-to-TOSS communication)
28
+ fromUser?: TossUserContext; // Minimal sender user context
29
+ toUser?: TossUserContext; // Minimal recipient user context
26
30
  amount: number; // Amount in lamports
27
31
  nonce: number; // For replay protection
28
32
  expiry: number; // Unix timestamp in seconds
@@ -64,6 +68,9 @@ export interface CreateIntentOptions {
64
68
  nonceAuth?: PublicKey;
65
69
  /** Fee payer for the transaction (defaults to sender) */
66
70
  feePayer?: PublicKey | string;
71
+ /** Optional minimal user contexts for sender and recipient */
72
+ fromUser?: TossUserContext;
73
+ toUser?: TossUserContext;
67
74
  }
68
75
 
69
76
  /**
@@ -133,6 +140,84 @@ class NonceManager {
133
140
 
134
141
  export const nonceManager = new NonceManager();
135
142
 
143
+ /**
144
+ * Creates a signed intent between two TOSS users (User-centric API)
145
+ * Recommended for application developers - validates user wallets
146
+ */
147
+ export async function createUserIntent(
148
+ senderUser: TossUser,
149
+ senderKeypair: Keypair,
150
+ recipientUser: TossUser,
151
+ amount: number,
152
+ connection: Connection,
153
+ options: CreateIntentOptions = {}
154
+ ): Promise<SolanaIntent> {
155
+ // Verify sender's keypair matches their wallet
156
+ if (
157
+ senderKeypair.publicKey.toBase58() !==
158
+ senderUser.wallet.publicKey.toBase58()
159
+ ) {
160
+ throw new Error('Sender keypair does not match user wallet');
161
+ }
162
+
163
+ // Verify both users can transact
164
+ if (!senderUser.tossFeatures.canSend) {
165
+ throw new Error('Sender account is not enabled for sending');
166
+ }
167
+ if (!recipientUser.tossFeatures.canReceive) {
168
+ throw new Error('Recipient account is not enabled for receiving');
169
+ }
170
+
171
+ // Verify transaction amount is within limits
172
+ if (amount > senderUser.tossFeatures.maxTransactionAmount) {
173
+ throw new Error(
174
+ `Transaction amount exceeds limit of ${senderUser.tossFeatures.maxTransactionAmount} lamports`
175
+ );
176
+ }
177
+
178
+ // Prepare minimal user contexts for inclusion in the intent
179
+ const senderCtx: TossUserContext = {
180
+ userId: senderUser.userId,
181
+ username: senderUser.username,
182
+ wallet: {
183
+ publicKey: senderUser.wallet.publicKey,
184
+ isVerified: senderUser.wallet.isVerified,
185
+ createdAt: senderUser.wallet.createdAt,
186
+ },
187
+ status: senderUser.status,
188
+ deviceId: senderUser.device.id,
189
+ sessionId: uuidv4(),
190
+ };
191
+
192
+ const recipientCtx: TossUserContext = {
193
+ userId: recipientUser.userId,
194
+ username: recipientUser.username,
195
+ wallet: {
196
+ publicKey: recipientUser.wallet.publicKey,
197
+ isVerified: recipientUser.wallet.isVerified,
198
+ createdAt: recipientUser.wallet.createdAt,
199
+ },
200
+ status: recipientUser.status,
201
+ deviceId: recipientUser.device.id,
202
+ sessionId: uuidv4(),
203
+ };
204
+
205
+ // Create intent using keypair and recipient's public key, and include user contexts
206
+ const intent = await createSignedIntent(
207
+ senderKeypair,
208
+ recipientUser.wallet.publicKey,
209
+ amount,
210
+ connection,
211
+ { ...options, fromUser: senderCtx, toUser: recipientCtx }
212
+ );
213
+
214
+ // Ensure user contexts are present on return (for backward compatibility)
215
+ intent.fromUser = senderCtx;
216
+ intent.toUser = recipientCtx;
217
+
218
+ return intent;
219
+ }
220
+
136
221
  /**
137
222
  * Creates a signed intent that can be verified offline
138
223
  */
@@ -159,6 +244,9 @@ export async function createSignedIntent(
159
244
  id: uuidv4(),
160
245
  from: sender.publicKey.toBase58(),
161
246
  to: recipient.toBase58(),
247
+ // Include optional user contexts when provided
248
+ ...(options.fromUser ? { fromUser: options.fromUser } : {}),
249
+ ...(options.toUser ? { toUser: options.toUser } : {}),
162
250
  amount,
163
251
  nonce,
164
252
  expiry: now + (options.expiresIn || defaultExpiry),
package/src/nfc.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/nfc.ts
2
- import NfcManager, { NfcTech, Ndef } from "react-native-nfc-manager";
2
+ import NfcManager, { NfcTech, Ndef } from 'react-native-nfc-manager';
3
3
  import type { TossUser } from './types/tossUser';
4
4
  import type { SolanaIntent } from './intent';
5
5
 
@@ -14,11 +14,11 @@ export async function readNFCUser(): Promise<TossUser> {
14
14
  await NfcManager.requestTechnology(NfcTech.Ndef);
15
15
  const tag = await NfcManager.getTag();
16
16
  await NfcManager.cancelTechnologyRequest();
17
-
17
+
18
18
  if (!tag?.ndefMessage?.[0]?.payload) {
19
19
  throw new Error('No NDEF message found');
20
20
  }
21
-
21
+
22
22
  const message = Ndef.uri.decodePayload(tag.ndefMessage[0].payload as any);
23
23
  return JSON.parse(message) as TossUser;
24
24
  } catch (ex: unknown) {
@@ -54,4 +54,4 @@ export async function writeIntentToNFC(intent: SolanaIntent): Promise<boolean> {
54
54
  await NfcManager.cancelTechnologyRequest();
55
55
  throw new Error(`Failed to write intent to NFC: ${String(ex)}`);
56
56
  }
57
- }
57
+ }
package/src/noise.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { noise } from "@chainsafe/libp2p-noise";
1
+ import { noise } from '@chainsafe/libp2p-noise';
2
2
 
3
3
  /**
4
4
  * Initialize Noise secure session with a static key.
@@ -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,4 @@ 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
+ }
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() {