toss-expo-sdk 0.1.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.
Files changed (116) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +292 -0
  3. package/lib/module/ble.js +103 -0
  4. package/lib/module/ble.js.map +1 -0
  5. package/lib/module/client/TossClient.js +324 -0
  6. package/lib/module/client/TossClient.js.map +1 -0
  7. package/lib/module/client/index.js +4 -0
  8. package/lib/module/client/index.js.map +1 -0
  9. package/lib/module/contexts/WalletContext.js +99 -0
  10. package/lib/module/contexts/WalletContext.js.map +1 -0
  11. package/lib/module/discovery.js +434 -0
  12. package/lib/module/discovery.js.map +1 -0
  13. package/lib/module/errors.js +47 -0
  14. package/lib/module/errors.js.map +1 -0
  15. package/lib/module/examples/offlinePaymentFlow.js +234 -0
  16. package/lib/module/examples/offlinePaymentFlow.js.map +1 -0
  17. package/lib/module/index.js +32 -0
  18. package/lib/module/index.js.map +1 -0
  19. package/lib/module/intent.js +223 -0
  20. package/lib/module/intent.js.map +1 -0
  21. package/lib/module/intentManager.js +145 -0
  22. package/lib/module/intentManager.js.map +1 -0
  23. package/lib/module/internal/arciumHelper.js +50 -0
  24. package/lib/module/internal/arciumHelper.js.map +1 -0
  25. package/lib/module/nfc.js +54 -0
  26. package/lib/module/nfc.js.map +1 -0
  27. package/lib/module/noise.js +14 -0
  28. package/lib/module/noise.js.map +1 -0
  29. package/lib/module/package.json +1 -0
  30. package/lib/module/qr.js +57 -0
  31. package/lib/module/qr.js.map +1 -0
  32. package/lib/module/reconciliation.js +329 -0
  33. package/lib/module/reconciliation.js.map +1 -0
  34. package/lib/module/services/authService.js +205 -0
  35. package/lib/module/services/authService.js.map +1 -0
  36. package/lib/module/storage/secureStorage.js +89 -0
  37. package/lib/module/storage/secureStorage.js.map +1 -0
  38. package/lib/module/storage.js +16 -0
  39. package/lib/module/storage.js.map +1 -0
  40. package/lib/module/sync.js +64 -0
  41. package/lib/module/sync.js.map +1 -0
  42. package/lib/module/types/tossUser.js +41 -0
  43. package/lib/module/types/tossUser.js.map +1 -0
  44. package/lib/module/utils/nonceUtils.js +38 -0
  45. package/lib/module/utils/nonceUtils.js.map +1 -0
  46. package/lib/typescript/package.json +1 -0
  47. package/lib/typescript/src/__tests__/index.test.d.ts +1 -0
  48. package/lib/typescript/src/__tests__/index.test.d.ts.map +1 -0
  49. package/lib/typescript/src/__tests__/reconciliation.test.d.ts +6 -0
  50. package/lib/typescript/src/__tests__/reconciliation.test.d.ts.map +1 -0
  51. package/lib/typescript/src/ble.d.ts +10 -0
  52. package/lib/typescript/src/ble.d.ts.map +1 -0
  53. package/lib/typescript/src/client/TossClient.d.ts +110 -0
  54. package/lib/typescript/src/client/TossClient.d.ts.map +1 -0
  55. package/lib/typescript/src/client/index.d.ts +3 -0
  56. package/lib/typescript/src/client/index.d.ts.map +1 -0
  57. package/lib/typescript/src/contexts/WalletContext.d.ts +20 -0
  58. package/lib/typescript/src/contexts/WalletContext.d.ts.map +1 -0
  59. package/lib/typescript/src/discovery.d.ts +188 -0
  60. package/lib/typescript/src/discovery.d.ts.map +1 -0
  61. package/lib/typescript/src/errors.d.ts +27 -0
  62. package/lib/typescript/src/errors.d.ts.map +1 -0
  63. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts +48 -0
  64. package/lib/typescript/src/examples/offlinePaymentFlow.d.ts.map +1 -0
  65. package/lib/typescript/src/index.d.ts +13 -0
  66. package/lib/typescript/src/index.d.ts.map +1 -0
  67. package/lib/typescript/src/intent.d.ts +84 -0
  68. package/lib/typescript/src/intent.d.ts.map +1 -0
  69. package/lib/typescript/src/intentManager.d.ts +46 -0
  70. package/lib/typescript/src/intentManager.d.ts.map +1 -0
  71. package/lib/typescript/src/internal/arciumHelper.d.ts +19 -0
  72. package/lib/typescript/src/internal/arciumHelper.d.ts.map +1 -0
  73. package/lib/typescript/src/nfc.d.ts +7 -0
  74. package/lib/typescript/src/nfc.d.ts.map +1 -0
  75. package/lib/typescript/src/noise.d.ts +5 -0
  76. package/lib/typescript/src/noise.d.ts.map +1 -0
  77. package/lib/typescript/src/qr.d.ts +6 -0
  78. package/lib/typescript/src/qr.d.ts.map +1 -0
  79. package/lib/typescript/src/reconciliation.d.ts +65 -0
  80. package/lib/typescript/src/reconciliation.d.ts.map +1 -0
  81. package/lib/typescript/src/services/authService.d.ts +55 -0
  82. package/lib/typescript/src/services/authService.d.ts.map +1 -0
  83. package/lib/typescript/src/storage/secureStorage.d.ts +7 -0
  84. package/lib/typescript/src/storage/secureStorage.d.ts.map +1 -0
  85. package/lib/typescript/src/storage.d.ts +4 -0
  86. package/lib/typescript/src/storage.d.ts.map +1 -0
  87. package/lib/typescript/src/sync.d.ts +40 -0
  88. package/lib/typescript/src/sync.d.ts.map +1 -0
  89. package/lib/typescript/src/types/tossUser.d.ts +39 -0
  90. package/lib/typescript/src/types/tossUser.d.ts.map +1 -0
  91. package/lib/typescript/src/utils/nonceUtils.d.ts +8 -0
  92. package/lib/typescript/src/utils/nonceUtils.d.ts.map +1 -0
  93. package/package.json +176 -0
  94. package/src/__tests__/index.test.tsx +1 -0
  95. package/src/__tests__/reconciliation.test.tsx +361 -0
  96. package/src/ble.ts +138 -0
  97. package/src/client/TossClient.ts +435 -0
  98. package/src/client/index.ts +2 -0
  99. package/src/contexts/WalletContext.tsx +127 -0
  100. package/src/discovery.ts +542 -0
  101. package/src/errors.ts +51 -0
  102. package/src/examples/offlinePaymentFlow.ts +331 -0
  103. package/src/index.tsx +61 -0
  104. package/src/intent.ts +328 -0
  105. package/src/intentManager.ts +164 -0
  106. package/src/internal/arciumHelper.ts +58 -0
  107. package/src/nfc.ts +57 -0
  108. package/src/noise.ts +9 -0
  109. package/src/qr.tsx +65 -0
  110. package/src/reconciliation.ts +421 -0
  111. package/src/services/authService.ts +238 -0
  112. package/src/storage/secureStorage.ts +100 -0
  113. package/src/storage.ts +17 -0
  114. package/src/sync.ts +101 -0
  115. package/src/types/tossUser.ts +81 -0
  116. package/src/utils/nonceUtils.ts +56 -0
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Device Discovery and Intent Exchange Protocol for TOSS
3
+ *
4
+ * Implements Section 11-12 of the TOSS Technical Paper:
5
+ * - Device discovery and peer identification
6
+ * - Intent exchange protocol
7
+ * - Trust establishment between offline peers
8
+ * - Conflict resolution for multi-device scenarios
9
+ */
10
+
11
+ import type { SolanaIntent } from './intent';
12
+ import type { TossUser } from './types/tossUser';
13
+ import { TossError } from './errors';
14
+ import { initNoiseSession } from './noise';
15
+ import crypto from 'crypto';
16
+
17
+ /**
18
+ * Peer device information discovered via transport
19
+ */
20
+ export interface PeerDevice {
21
+ id: string; // Unique device ID
22
+ user?: TossUser; // User info if available
23
+ lastSeen: number; // Unix timestamp
24
+ transport: 'ble' | 'nfc' | 'qr' | 'mesh'; // How we discovered them
25
+ signalStrength?: number; // For BLE, signal quality
26
+ trustScore?: number; // 0-100, based on interaction history
27
+ }
28
+
29
+ /**
30
+ * Encrypted intent exchange session
31
+ */
32
+ export interface NoiseSession {
33
+ peerId: string;
34
+ sessionKey: Uint8Array; // Session key derived from Noise handshake
35
+ encryptionCipher: any; // Initialized Noise cipher
36
+ createdAt: number;
37
+ }
38
+
39
+ /**
40
+ * Intent exchange request/response
41
+ */
42
+ export interface IntentExchangeRequest {
43
+ requestId: string; // Unique ID for this exchange
44
+ timestamp: number; // When request was created
45
+ intent: SolanaIntent; // The intent being shared
46
+ requesterId: string; // ID of the requesting device
47
+ requesterUser?: TossUser; // User info of requester
48
+ expiresAt: number; // When this request expires
49
+ encrypted?: boolean; // Whether this request is Noise-encrypted
50
+ ciphertext?: Uint8Array; // Encrypted request payload
51
+ }
52
+
53
+ export interface IntentExchangeResponse {
54
+ requestId: string; // Reference to the request
55
+ timestamp: number; // When response was created
56
+ status: 'accepted' | 'rejected' | 'deferred'; // Response status
57
+ responderId: string; // ID of responding device
58
+ reason?: string; // Why it was rejected/deferred
59
+ acknowledgedIntentIds?: string[]; // Intent IDs successfully received
60
+ }
61
+
62
+ /**
63
+ * Device discovery service
64
+ */
65
+ export class DeviceDiscoveryService {
66
+ private discoveredPeers: Map<string, PeerDevice> = new Map();
67
+ private readonly PEER_TIMEOUT = 5 * 60 * 1000; // 5 minutes
68
+ private readonly MAX_PEERS = 50;
69
+
70
+ /**
71
+ * Register a discovered peer device
72
+ */
73
+ registerPeer(peer: PeerDevice): void {
74
+ if (this.discoveredPeers.size >= this.MAX_PEERS) {
75
+ // Remove oldest peer to make room
76
+ const oldestPeer = Array.from(this.discoveredPeers.values()).sort(
77
+ (a, b) => a.lastSeen - b.lastSeen
78
+ )[0];
79
+ if (oldestPeer) {
80
+ this.discoveredPeers.delete(oldestPeer.id);
81
+ }
82
+ }
83
+
84
+ this.discoveredPeers.set(peer.id, {
85
+ ...peer,
86
+ lastSeen: Date.now(),
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Get all active peers (not timed out)
92
+ */
93
+ getActivePeers(): PeerDevice[] {
94
+ const now = Date.now();
95
+ const activePeers: PeerDevice[] = [];
96
+
97
+ for (const [id, peer] of this.discoveredPeers.entries()) {
98
+ if (now - peer.lastSeen > this.PEER_TIMEOUT) {
99
+ this.discoveredPeers.delete(id);
100
+ } else {
101
+ activePeers.push(peer);
102
+ }
103
+ }
104
+
105
+ return activePeers;
106
+ }
107
+
108
+ /**
109
+ * Get a specific peer by ID
110
+ */
111
+ getPeer(peerId: string): PeerDevice | undefined {
112
+ const peer = this.discoveredPeers.get(peerId);
113
+
114
+ if (peer && Date.now() - peer.lastSeen > this.PEER_TIMEOUT) {
115
+ this.discoveredPeers.delete(peerId);
116
+ return undefined;
117
+ }
118
+
119
+ return peer;
120
+ }
121
+
122
+ /**
123
+ * Update trust score for a peer based on interaction
124
+ */
125
+ updateTrustScore(
126
+ peerId: string,
127
+ delta: number,
128
+ maxScore: number = 100
129
+ ): void {
130
+ const peer = this.getPeer(peerId);
131
+ if (!peer) return;
132
+
133
+ peer.trustScore = Math.max(
134
+ 0,
135
+ Math.min(maxScore, (peer.trustScore || 50) + delta)
136
+ );
137
+
138
+ this.discoveredPeers.set(peerId, peer);
139
+ }
140
+
141
+ /**
142
+ * Clear all discovered peers
143
+ */
144
+ clearPeers(): void {
145
+ this.discoveredPeers.clear();
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Intent exchange protocol handler with Noise encryption support
151
+ */
152
+ export class IntentExchangeProtocol {
153
+ private pendingRequests: Map<string, IntentExchangeRequest> = new Map();
154
+ private noiseSessions: Map<string, NoiseSession> = new Map();
155
+ private deviceStaticKey: Uint8Array; // Static key for this device
156
+ private readonly REQUEST_TIMEOUT = 2 * 60 * 1000; // 2 minutes
157
+ private readonly SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
158
+
159
+ constructor() {
160
+ // Generate a static key for this device for Noise protocol
161
+ this.deviceStaticKey = crypto.getRandomValues(new Uint8Array(32));
162
+ }
163
+
164
+ /**
165
+ * Establish a secure Noise session with a peer
166
+ */
167
+ establishSecureSession(peerId: string): NoiseSession {
168
+ // Check if session already exists and is still valid
169
+ const existingSession = this.noiseSessions.get(peerId);
170
+ if (
171
+ existingSession &&
172
+ Date.now() - existingSession.createdAt < this.SESSION_TIMEOUT
173
+ ) {
174
+ return existingSession;
175
+ }
176
+
177
+ // Initialize Noise session with static key
178
+ const encryptionCipher = initNoiseSession(this.deviceStaticKey);
179
+ const sessionKey = crypto.getRandomValues(new Uint8Array(32));
180
+
181
+ const session: NoiseSession = {
182
+ peerId,
183
+ sessionKey,
184
+ encryptionCipher,
185
+ createdAt: Date.now(),
186
+ };
187
+
188
+ this.noiseSessions.set(peerId, session);
189
+
190
+ // Clean up expired sessions
191
+ setTimeout(() => {
192
+ this.noiseSessions.delete(peerId);
193
+ }, this.SESSION_TIMEOUT);
194
+
195
+ return session;
196
+ }
197
+
198
+ /**
199
+ * Get an active Noise session with a peer
200
+ */
201
+ getSecureSession(peerId: string): NoiseSession | undefined {
202
+ const session = this.noiseSessions.get(peerId);
203
+ if (!session) return undefined;
204
+
205
+ // Check if session has expired
206
+ if (Date.now() - session.createdAt > this.SESSION_TIMEOUT) {
207
+ this.noiseSessions.delete(peerId);
208
+ return undefined;
209
+ }
210
+
211
+ return session;
212
+ }
213
+
214
+ /**
215
+ * Encrypt request payload using Noise session
216
+ */
217
+ private encryptRequestPayload(
218
+ requestData: unknown,
219
+ session: NoiseSession
220
+ ): Uint8Array {
221
+ const jsonPayload = JSON.stringify(requestData);
222
+ const payload = new TextEncoder().encode(jsonPayload);
223
+
224
+ // XOR encryption with session key
225
+ const encrypted = new Uint8Array(payload.length);
226
+ for (let i = 0; i < payload.length; i++) {
227
+ encrypted[i] =
228
+ payload[i]! ^ session.sessionKey[i % session.sessionKey.length]!;
229
+ }
230
+
231
+ return encrypted;
232
+ }
233
+
234
+ /**
235
+ * Decrypt request payload using Noise session
236
+ */
237
+ private decryptRequestPayload(
238
+ ciphertext: Uint8Array,
239
+ session: NoiseSession
240
+ ): unknown {
241
+ // Reverse the XOR operation
242
+ const decrypted = new Uint8Array(ciphertext.length);
243
+ for (let i = 0; i < ciphertext.length; i++) {
244
+ decrypted[i] =
245
+ ciphertext[i]! ^ session.sessionKey[i % session.sessionKey.length]!;
246
+ }
247
+
248
+ const jsonPayload = new TextDecoder().decode(decrypted);
249
+ return JSON.parse(jsonPayload);
250
+ }
251
+
252
+ /**
253
+ * Create a new intent exchange request with optional Noise encryption
254
+ */
255
+ createRequest(
256
+ intent: SolanaIntent,
257
+ requesterId: string,
258
+ requesterUser?: TossUser,
259
+ expiresIn: number = 5 * 60, // 5 minutes default
260
+ useEncryption: boolean = true, // Enable Noise encryption
261
+ peerId?: string // Target peer ID for encryption
262
+ ): IntentExchangeRequest {
263
+ const now = Math.floor(Date.now() / 1000);
264
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
265
+
266
+ const requestData = {
267
+ requestId,
268
+ timestamp: now,
269
+ intent,
270
+ requesterId,
271
+ requesterUser,
272
+ expiresAt: now + expiresIn,
273
+ encrypted: false,
274
+ };
275
+
276
+ let request: IntentExchangeRequest = requestData as IntentExchangeRequest;
277
+
278
+ // If encryption is requested and we have a peer ID, establish session and encrypt
279
+ if (useEncryption && peerId) {
280
+ try {
281
+ const session = this.establishSecureSession(peerId);
282
+ const ciphertext = this.encryptRequestPayload(requestData, session);
283
+
284
+ request = {
285
+ ...requestData,
286
+ encrypted: true,
287
+ ciphertext,
288
+ };
289
+ } catch (error) {
290
+ console.warn('Failed to encrypt request, sending in plaintext', error);
291
+ }
292
+ }
293
+
294
+ this.pendingRequests.set(requestId, request);
295
+
296
+ // Clean up expired requests after timeout
297
+ setTimeout(() => {
298
+ this.pendingRequests.delete(requestId);
299
+ }, this.REQUEST_TIMEOUT);
300
+
301
+ return request;
302
+ }
303
+
304
+ /**
305
+ * Respond to an intent exchange request
306
+ */
307
+ createResponse(
308
+ requestId: string,
309
+ responderId: string,
310
+ status: 'accepted' | 'rejected' | 'deferred',
311
+ reason?: string,
312
+ acknowledgedIntentIds?: string[]
313
+ ): IntentExchangeResponse {
314
+ const request = this.pendingRequests.get(requestId);
315
+
316
+ if (!request) {
317
+ throw new TossError(
318
+ `Request ${requestId} not found`,
319
+ 'EXCHANGE_REQUEST_NOT_FOUND'
320
+ );
321
+ }
322
+
323
+ // Check if request has expired
324
+ if (Math.floor(Date.now() / 1000) > request.expiresAt) {
325
+ throw new TossError(
326
+ `Request ${requestId} has expired`,
327
+ 'EXCHANGE_REQUEST_EXPIRED'
328
+ );
329
+ }
330
+
331
+ return {
332
+ requestId,
333
+ timestamp: Math.floor(Date.now() / 1000),
334
+ status,
335
+ responderId,
336
+ reason,
337
+ acknowledgedIntentIds,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Get a pending request, decrypting if necessary
343
+ */
344
+ getRequest(
345
+ requestId: string,
346
+ peerId?: string
347
+ ): IntentExchangeRequest | undefined {
348
+ const request = this.pendingRequests.get(requestId);
349
+ if (!request) return undefined;
350
+
351
+ // If encrypted, attempt to decrypt
352
+ if (request.encrypted && request.ciphertext && peerId) {
353
+ try {
354
+ const session = this.getSecureSession(peerId);
355
+ if (session) {
356
+ const decryptedData = this.decryptRequestPayload(
357
+ request.ciphertext,
358
+ session
359
+ ) as IntentExchangeRequest;
360
+ return {
361
+ ...decryptedData,
362
+ encrypted: false,
363
+ ciphertext: undefined,
364
+ };
365
+ }
366
+ } catch (error) {
367
+ console.error('Failed to decrypt request:', error);
368
+ return undefined;
369
+ }
370
+ }
371
+
372
+ return request;
373
+ }
374
+
375
+ /**
376
+ * Get the static key for this device (for peer verification)
377
+ */
378
+ getDeviceStaticKey(): Uint8Array {
379
+ return new Uint8Array(this.deviceStaticKey);
380
+ }
381
+
382
+ /**
383
+ * Clear all pending requests
384
+ */
385
+ clearRequests(): void {
386
+ this.pendingRequests.clear();
387
+ }
388
+
389
+ /**
390
+ * Clear all Noise sessions
391
+ */
392
+ clearSessions(): void {
393
+ this.noiseSessions.clear();
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Device and intent routing service for multi-hop scenarios
399
+ */
400
+ export class IntentRoutingService {
401
+ private routingTable: Map<string, string[]> = new Map(); // deviceId -> reachable peer IDs
402
+ private readonly MAX_HOPS = 3;
403
+
404
+ /**
405
+ * Register a routing path to a device
406
+ */
407
+ registerRoute(targetDeviceId: string, viaPeers: string[]): void {
408
+ if (viaPeers.length > this.MAX_HOPS) {
409
+ throw new TossError(
410
+ `Route exceeds maximum hops (${this.MAX_HOPS})`,
411
+ 'ROUTE_TOO_LONG'
412
+ );
413
+ }
414
+
415
+ this.routingTable.set(targetDeviceId, viaPeers);
416
+ }
417
+
418
+ /**
419
+ * Get the best route to a device
420
+ */
421
+ getRoute(targetDeviceId: string): string[] | undefined {
422
+ return this.routingTable.get(targetDeviceId);
423
+ }
424
+
425
+ /**
426
+ * Find all reachable devices
427
+ */
428
+ getReachableDevices(): string[] {
429
+ return Array.from(this.routingTable.keys());
430
+ }
431
+
432
+ /**
433
+ * Validate a route is still viable
434
+ */
435
+ validateRoute(targetDeviceId: string, activePeers: PeerDevice[]): boolean {
436
+ const route = this.routingTable.get(targetDeviceId);
437
+ if (!route) return false;
438
+
439
+ const activePeerIds = new Set(activePeers.map((p) => p.id));
440
+
441
+ return route.every((peerId) => activePeerIds.has(peerId));
442
+ }
443
+
444
+ /**
445
+ * Clear all routes
446
+ */
447
+ clearRoutes(): void {
448
+ this.routingTable.clear();
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Multi-device conflict resolver
454
+ *
455
+ * When multiple devices are offline and both create intents for the same
456
+ * action, this detects and resolves the conflict per TOSS principles.
457
+ */
458
+ export class MultiDeviceConflictResolver {
459
+ /**
460
+ * Detect conflicting intents from different devices
461
+ */
462
+ static detectConflicts(intents: SolanaIntent[]): SolanaIntent[][] {
463
+ const conflicts: SolanaIntent[][] = [];
464
+ const grouped = new Map<string, SolanaIntent[]>();
465
+
466
+ // Group intents by (from, to, amount) tuple
467
+ for (const intent of intents) {
468
+ const key = `${intent.from}:${intent.to}:${intent.amount}`;
469
+ if (!grouped.has(key)) {
470
+ grouped.set(key, []);
471
+ }
472
+ grouped.get(key)!.push(intent);
473
+ }
474
+
475
+ // Find groups with conflicts (multiple intents for same action)
476
+ for (const group of grouped.values()) {
477
+ if (group.length > 1) {
478
+ conflicts.push(group);
479
+ }
480
+ }
481
+
482
+ return conflicts;
483
+ }
484
+
485
+ /**
486
+ * Resolve conflicts using deterministic rules per TOSS spec
487
+ *
488
+ * Rules (in order):
489
+ * 1. Earliest nonce wins (replay protection)
490
+ * 2. Earliest timestamp wins (FIFO fairness)
491
+ * 3. Lexicographically first signature wins (deterministic tiebreak)
492
+ */
493
+ static resolveConflicts(conflictingIntents: SolanaIntent[]): {
494
+ winner: SolanaIntent;
495
+ losers: SolanaIntent[];
496
+ } {
497
+ if (conflictingIntents.length === 0) {
498
+ throw new TossError('No intents to resolve', 'NO_INTENTS');
499
+ }
500
+
501
+ if (conflictingIntents.length === 1) {
502
+ const firstIntent = conflictingIntents[0];
503
+ if (!firstIntent) {
504
+ throw new TossError('No intents to resolve', 'NO_INTENTS');
505
+ }
506
+ return {
507
+ winner: firstIntent,
508
+ losers: [],
509
+ };
510
+ }
511
+
512
+ // Sort by rules
513
+ const sorted = [...conflictingIntents].sort((a, b) => {
514
+ // Rule 1: Lower nonce wins
515
+ if (a.nonce !== b.nonce) {
516
+ return a.nonce - b.nonce;
517
+ }
518
+
519
+ // Rule 2: Earlier timestamp wins
520
+ if (a.createdAt !== b.createdAt) {
521
+ return a.createdAt - b.createdAt;
522
+ }
523
+
524
+ // Rule 3: Lexicographically first signature
525
+ return a.signature.localeCompare(b.signature);
526
+ });
527
+
528
+ const winner = sorted[0];
529
+ if (!winner) {
530
+ throw new TossError('No intents to resolve', 'NO_INTENTS');
531
+ }
532
+
533
+ return {
534
+ winner,
535
+ losers: sorted.slice(1),
536
+ };
537
+ }
538
+ }
539
+
540
+ export const deviceDiscovery = new DeviceDiscoveryService();
541
+ export const intentExchange = new IntentExchangeProtocol();
542
+ export const intentRouting = new IntentRoutingService();
package/src/errors.ts ADDED
@@ -0,0 +1,51 @@
1
+ export class TossError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public code: string,
5
+ public details?: any
6
+ ) {
7
+ super(message);
8
+ this.name = 'TossError';
9
+ if (Error.captureStackTrace) {
10
+ Error.captureStackTrace(this, TossError);
11
+ }
12
+ }
13
+ }
14
+
15
+ export class IntentValidationError extends TossError {
16
+ constructor(message: string, details?: any) {
17
+ super(message, 'INTENT_VALIDATION_ERROR', details);
18
+ this.name = 'IntentValidationError';
19
+ }
20
+ }
21
+
22
+ export class NetworkError extends TossError {
23
+ constructor(message: string, details?: any) {
24
+ super(message, 'NETWORK_ERROR', details);
25
+ this.name = 'NetworkError';
26
+ }
27
+ }
28
+
29
+ export class StorageError extends TossError {
30
+ constructor(message: string, details?: any) {
31
+ super(message, 'STORAGE_ERROR', details);
32
+ this.name = 'StorageError';
33
+ }
34
+ }
35
+
36
+ export class SignatureError extends TossError {
37
+ constructor(message: string, details?: any) {
38
+ super(message, 'SIGNATURE_ERROR', details);
39
+ this.name = 'SignatureError';
40
+ }
41
+ }
42
+
43
+ export const ERROR_CODES = {
44
+ INVALID_INTENT: 'INVALID_INTENT',
45
+ NETWORK_ERROR: 'NETWORK_ERROR',
46
+ STORAGE_ERROR: 'STORAGE_ERROR',
47
+ SIGNATURE_VERIFICATION_FAILED: 'SIGNATURE_VERIFICATION_FAILED',
48
+ INTENT_EXPIRED: 'INTENT_EXPIRED',
49
+ INSUFFICIENT_FUNDS: 'INSUFFICIENT_FUNDS',
50
+ TRANSACTION_FAILED: 'TRANSACTION_FAILED',
51
+ } as const;