toss-expo-sdk 0.1.1 → 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.
- package/README.md +122 -66
- package/lib/module/client/TossClient.js +26 -43
- package/lib/module/client/TossClient.js.map +1 -1
- package/lib/module/contexts/WalletContext.js +4 -4
- package/lib/module/contexts/WalletContext.js.map +1 -1
- package/lib/module/discovery.js +35 -8
- package/lib/module/discovery.js.map +1 -1
- package/lib/module/examples/offlinePaymentFlow.js +27 -2
- package/lib/module/examples/offlinePaymentFlow.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/intent.js +69 -0
- package/lib/module/intent.js.map +1 -1
- package/lib/module/nfc.js +1 -1
- package/lib/module/noise.js +1 -1
- package/lib/module/storage/secureStorage.js.map +1 -1
- package/lib/module/storage.js +4 -4
- package/lib/typescript/src/client/TossClient.d.ts +10 -12
- package/lib/typescript/src/client/TossClient.d.ts.map +1 -1
- package/lib/typescript/src/discovery.d.ts +8 -2
- package/lib/typescript/src/discovery.d.ts.map +1 -1
- package/lib/typescript/src/examples/offlinePaymentFlow.d.ts +9 -1
- package/lib/typescript/src/examples/offlinePaymentFlow.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/intent.d.ts +11 -0
- package/lib/typescript/src/intent.d.ts.map +1 -1
- package/package.json +12 -1
- package/src/__tests__/reconciliation.test.tsx +7 -1
- package/src/client/TossClient.ts +35 -48
- package/src/contexts/WalletContext.tsx +4 -4
- package/src/discovery.ts +46 -8
- package/src/examples/offlinePaymentFlow.ts +48 -2
- package/src/index.tsx +9 -1
- package/src/intent.ts +88 -0
- package/src/nfc.ts +4 -4
- package/src/noise.ts +1 -1
- package/src/storage/secureStorage.ts +4 -4
- 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.
|
|
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",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"del-cli": "^6.0.0",
|
|
92
92
|
"eslint": "^9.35.0",
|
|
93
93
|
"eslint-config-prettier": "^10.1.8",
|
|
94
|
+
"eslint-plugin-ft-flow": "^3.0.11",
|
|
94
95
|
"eslint-plugin-prettier": "^5.5.4",
|
|
95
96
|
"eslint-plugin-react-native": "^5.0.0",
|
|
96
97
|
"jest": "^29.7.0",
|
|
@@ -136,6 +137,16 @@
|
|
|
136
137
|
"modulePathIgnorePatterns": [
|
|
137
138
|
"<rootDir>/example/node_modules",
|
|
138
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"
|
|
139
150
|
]
|
|
140
151
|
},
|
|
141
152
|
"commitlint": {
|
|
@@ -180,7 +180,8 @@ describe('Discovery Module', () => {
|
|
|
180
180
|
|
|
181
181
|
// Manually set old timestamp
|
|
182
182
|
discovery.registerPeer(peer);
|
|
183
|
-
|
|
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',
|
package/src/client/TossClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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 (
|
|
266
|
-
if (
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (err instanceof TossError) throw err;
|
|
267
268
|
throw new StorageError('Failed to update intent status', {
|
|
268
|
-
cause:
|
|
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
|
-
|
|
387
|
+
senderKeypair,
|
|
398
388
|
recipientPubkey,
|
|
399
389
|
amount,
|
|
400
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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]!
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
@@ -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
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
2
|
|
|
3
|
-
const INTENTS_KEY =
|
|
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() {
|