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.
- package/LICENSE +20 -0
- package/README.md +292 -0
- package/lib/module/ble.js +103 -0
- package/lib/module/ble.js.map +1 -0
- package/lib/module/client/TossClient.js +324 -0
- package/lib/module/client/TossClient.js.map +1 -0
- package/lib/module/client/index.js +4 -0
- package/lib/module/client/index.js.map +1 -0
- package/lib/module/contexts/WalletContext.js +99 -0
- package/lib/module/contexts/WalletContext.js.map +1 -0
- package/lib/module/discovery.js +434 -0
- package/lib/module/discovery.js.map +1 -0
- package/lib/module/errors.js +47 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/examples/offlinePaymentFlow.js +234 -0
- package/lib/module/examples/offlinePaymentFlow.js.map +1 -0
- package/lib/module/index.js +32 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/intent.js +223 -0
- package/lib/module/intent.js.map +1 -0
- package/lib/module/intentManager.js +145 -0
- package/lib/module/intentManager.js.map +1 -0
- package/lib/module/internal/arciumHelper.js +50 -0
- package/lib/module/internal/arciumHelper.js.map +1 -0
- package/lib/module/nfc.js +54 -0
- package/lib/module/nfc.js.map +1 -0
- package/lib/module/noise.js +14 -0
- package/lib/module/noise.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/qr.js +57 -0
- package/lib/module/qr.js.map +1 -0
- package/lib/module/reconciliation.js +329 -0
- package/lib/module/reconciliation.js.map +1 -0
- package/lib/module/services/authService.js +205 -0
- package/lib/module/services/authService.js.map +1 -0
- package/lib/module/storage/secureStorage.js +89 -0
- package/lib/module/storage/secureStorage.js.map +1 -0
- package/lib/module/storage.js +16 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/sync.js +64 -0
- package/lib/module/sync.js.map +1 -0
- package/lib/module/types/tossUser.js +41 -0
- package/lib/module/types/tossUser.js.map +1 -0
- package/lib/module/utils/nonceUtils.js +38 -0
- package/lib/module/utils/nonceUtils.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/__tests__/index.test.d.ts +1 -0
- package/lib/typescript/src/__tests__/index.test.d.ts.map +1 -0
- package/lib/typescript/src/__tests__/reconciliation.test.d.ts +6 -0
- package/lib/typescript/src/__tests__/reconciliation.test.d.ts.map +1 -0
- package/lib/typescript/src/ble.d.ts +10 -0
- package/lib/typescript/src/ble.d.ts.map +1 -0
- package/lib/typescript/src/client/TossClient.d.ts +110 -0
- package/lib/typescript/src/client/TossClient.d.ts.map +1 -0
- package/lib/typescript/src/client/index.d.ts +3 -0
- package/lib/typescript/src/client/index.d.ts.map +1 -0
- package/lib/typescript/src/contexts/WalletContext.d.ts +20 -0
- package/lib/typescript/src/contexts/WalletContext.d.ts.map +1 -0
- package/lib/typescript/src/discovery.d.ts +188 -0
- package/lib/typescript/src/discovery.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +27 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/examples/offlinePaymentFlow.d.ts +48 -0
- package/lib/typescript/src/examples/offlinePaymentFlow.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +13 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/intent.d.ts +84 -0
- package/lib/typescript/src/intent.d.ts.map +1 -0
- package/lib/typescript/src/intentManager.d.ts +46 -0
- package/lib/typescript/src/intentManager.d.ts.map +1 -0
- package/lib/typescript/src/internal/arciumHelper.d.ts +19 -0
- package/lib/typescript/src/internal/arciumHelper.d.ts.map +1 -0
- package/lib/typescript/src/nfc.d.ts +7 -0
- package/lib/typescript/src/nfc.d.ts.map +1 -0
- package/lib/typescript/src/noise.d.ts +5 -0
- package/lib/typescript/src/noise.d.ts.map +1 -0
- package/lib/typescript/src/qr.d.ts +6 -0
- package/lib/typescript/src/qr.d.ts.map +1 -0
- package/lib/typescript/src/reconciliation.d.ts +65 -0
- package/lib/typescript/src/reconciliation.d.ts.map +1 -0
- package/lib/typescript/src/services/authService.d.ts +55 -0
- package/lib/typescript/src/services/authService.d.ts.map +1 -0
- package/lib/typescript/src/storage/secureStorage.d.ts +7 -0
- package/lib/typescript/src/storage/secureStorage.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +4 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/sync.d.ts +40 -0
- package/lib/typescript/src/sync.d.ts.map +1 -0
- package/lib/typescript/src/types/tossUser.d.ts +39 -0
- package/lib/typescript/src/types/tossUser.d.ts.map +1 -0
- package/lib/typescript/src/utils/nonceUtils.d.ts +8 -0
- package/lib/typescript/src/utils/nonceUtils.d.ts.map +1 -0
- package/package.json +176 -0
- package/src/__tests__/index.test.tsx +1 -0
- package/src/__tests__/reconciliation.test.tsx +361 -0
- package/src/ble.ts +138 -0
- package/src/client/TossClient.ts +435 -0
- package/src/client/index.ts +2 -0
- package/src/contexts/WalletContext.tsx +127 -0
- package/src/discovery.ts +542 -0
- package/src/errors.ts +51 -0
- package/src/examples/offlinePaymentFlow.ts +331 -0
- package/src/index.tsx +61 -0
- package/src/intent.ts +328 -0
- package/src/intentManager.ts +164 -0
- package/src/internal/arciumHelper.ts +58 -0
- package/src/nfc.ts +57 -0
- package/src/noise.ts +9 -0
- package/src/qr.tsx +65 -0
- package/src/reconciliation.ts +421 -0
- package/src/services/authService.ts +238 -0
- package/src/storage/secureStorage.ts +100 -0
- package/src/storage.ts +17 -0
- package/src/sync.ts +101 -0
- package/src/types/tossUser.ts +81 -0
- package/src/utils/nonceUtils.ts +56 -0
package/src/discovery.ts
ADDED
|
@@ -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;
|