toss-expo-sdk 0.1.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +490 -81
- package/lib/module/ble.js +59 -4
- package/lib/module/ble.js.map +1 -1
- package/lib/module/client/BLETransactionHandler.js +277 -0
- package/lib/module/client/BLETransactionHandler.js.map +1 -0
- package/lib/module/client/NonceAccountManager.js +364 -0
- package/lib/module/client/NonceAccountManager.js.map +1 -0
- package/lib/module/client/TossClient.js +27 -44
- 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/hooks/useOfflineBLETransactions.js +314 -0
- package/lib/module/hooks/useOfflineBLETransactions.js.map +1 -0
- package/lib/module/index.js +13 -8
- package/lib/module/index.js.map +1 -1
- package/lib/module/intent.js +198 -0
- package/lib/module/intent.js.map +1 -1
- package/lib/module/nfc.js +1 -1
- package/lib/module/noise.js +176 -1
- package/lib/module/noise.js.map +1 -1
- package/lib/module/reconciliation.js +155 -0
- package/lib/module/reconciliation.js.map +1 -1
- package/lib/module/services/authService.js +164 -1
- package/lib/module/services/authService.js.map +1 -1
- package/lib/module/storage/secureStorage.js +102 -0
- package/lib/module/storage/secureStorage.js.map +1 -1
- package/lib/module/storage.js +4 -4
- package/lib/module/sync.js +25 -1
- package/lib/module/sync.js.map +1 -1
- package/lib/module/types/nonceAccount.js +2 -0
- package/lib/module/types/nonceAccount.js.map +1 -0
- package/lib/module/types/tossUser.js +16 -1
- package/lib/module/types/tossUser.js.map +1 -1
- package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts +8 -0
- package/lib/typescript/src/__tests__/solana-program-simple.test.d.ts.map +1 -0
- package/lib/typescript/src/ble.d.ts +31 -2
- package/lib/typescript/src/ble.d.ts.map +1 -1
- package/lib/typescript/src/client/BLETransactionHandler.d.ts +98 -0
- package/lib/typescript/src/client/BLETransactionHandler.d.ts.map +1 -0
- package/lib/typescript/src/client/NonceAccountManager.d.ts +82 -0
- package/lib/typescript/src/client/NonceAccountManager.d.ts.map +1 -0
- 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/hooks/useOfflineBLETransactions.d.ts +91 -0
- package/lib/typescript/src/hooks/useOfflineBLETransactions.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/intent.d.ts +26 -0
- package/lib/typescript/src/intent.d.ts.map +1 -1
- package/lib/typescript/src/noise.d.ts +62 -0
- package/lib/typescript/src/noise.d.ts.map +1 -1
- package/lib/typescript/src/reconciliation.d.ts +6 -0
- package/lib/typescript/src/reconciliation.d.ts.map +1 -1
- package/lib/typescript/src/services/authService.d.ts +26 -1
- package/lib/typescript/src/services/authService.d.ts.map +1 -1
- package/lib/typescript/src/storage/secureStorage.d.ts +16 -0
- package/lib/typescript/src/storage/secureStorage.d.ts.map +1 -1
- package/lib/typescript/src/sync.d.ts +6 -1
- package/lib/typescript/src/sync.d.ts.map +1 -1
- package/lib/typescript/src/types/nonceAccount.d.ts +59 -0
- package/lib/typescript/src/types/nonceAccount.d.ts.map +1 -0
- package/lib/typescript/src/types/tossUser.d.ts +16 -0
- package/lib/typescript/src/types/tossUser.d.ts.map +1 -1
- package/package.json +12 -1
- package/src/__tests__/reconciliation.test.tsx +7 -1
- package/src/__tests__/solana-program-simple.test.ts +256 -0
- package/src/ble.ts +105 -4
- package/src/client/BLETransactionHandler.ts +364 -0
- package/src/client/NonceAccountManager.ts +444 -0
- package/src/client/TossClient.ts +36 -49
- package/src/contexts/WalletContext.tsx +4 -4
- package/src/discovery.ts +46 -8
- package/src/examples/offlinePaymentFlow.ts +48 -2
- package/src/hooks/useOfflineBLETransactions.ts +438 -0
- package/src/index.tsx +49 -7
- package/src/intent.ts +254 -0
- package/src/nfc.ts +4 -4
- package/src/noise.ts +239 -1
- package/src/reconciliation.ts +184 -0
- package/src/services/authService.ts +188 -1
- package/src/storage/secureStorage.ts +142 -4
- package/src/storage.ts +4 -4
- package/src/sync.ts +40 -0
- package/src/types/nonceAccount.ts +75 -0
- package/src/types/tossUser.ts +35 -2
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PublicKey,
|
|
3
|
+
Keypair,
|
|
4
|
+
Connection,
|
|
5
|
+
SystemProgram,
|
|
6
|
+
NONCE_ACCOUNT_LENGTH,
|
|
7
|
+
NonceAccount,
|
|
8
|
+
TransactionInstruction,
|
|
9
|
+
} from '@solana/web3.js';
|
|
10
|
+
import * as SecureStore from 'expo-secure-store';
|
|
11
|
+
import type {
|
|
12
|
+
NonceAccountInfo,
|
|
13
|
+
NonceAccountCacheEntry,
|
|
14
|
+
CreateNonceAccountOptions,
|
|
15
|
+
OfflineTransaction,
|
|
16
|
+
} from '../types/nonceAccount';
|
|
17
|
+
import type { TossUser } from '../types/tossUser';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* NonceAccountManager
|
|
21
|
+
* Manages durable nonce accounts for secure offline transactions
|
|
22
|
+
* with biometric protection and encrypted storage
|
|
23
|
+
*/
|
|
24
|
+
export class NonceAccountManager {
|
|
25
|
+
private cache: Map<string, NonceAccountCacheEntry> = new Map();
|
|
26
|
+
private connection: Connection;
|
|
27
|
+
|
|
28
|
+
constructor(connection: Connection) {
|
|
29
|
+
this.connection = connection;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a new durable nonce account for a user
|
|
34
|
+
* Securely stores the nonce account with biometric protection
|
|
35
|
+
*/
|
|
36
|
+
async createNonceAccount(
|
|
37
|
+
user: TossUser,
|
|
38
|
+
nonceAuthorityKeypair: Keypair,
|
|
39
|
+
owner: PublicKey,
|
|
40
|
+
options: CreateNonceAccountOptions = {}
|
|
41
|
+
): Promise<NonceAccountInfo> {
|
|
42
|
+
const { requireBiometric = true, persistToSecureStorage = true } = options;
|
|
43
|
+
|
|
44
|
+
if (requireBiometric !== true) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'❌ SECURITY ERROR: Biometric protection is mandatory for nonce accounts'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate nonce account keypair
|
|
51
|
+
const nonceAccountKeypair = Keypair.generate();
|
|
52
|
+
const nonceAccountAddress = nonceAccountKeypair.publicKey;
|
|
53
|
+
|
|
54
|
+
// Get the system's rent exemption minimum for nonce accounts
|
|
55
|
+
// (used for funding in actual transaction creation)
|
|
56
|
+
const minRentLamports =
|
|
57
|
+
await this.connection.getMinimumBalanceForRentExemption(
|
|
58
|
+
NONCE_ACCOUNT_LENGTH
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Get the latest blockhash for the instruction
|
|
62
|
+
const { blockhash } = await this.connection.getLatestBlockhash();
|
|
63
|
+
|
|
64
|
+
const nonceAccountInfo: NonceAccountInfo = {
|
|
65
|
+
address: nonceAccountAddress.toBase58(),
|
|
66
|
+
owner: owner.toBase58(),
|
|
67
|
+
authorizedSigner: nonceAuthorityKeypair.publicKey.toBase58(),
|
|
68
|
+
currentNonce: 0,
|
|
69
|
+
lastUsedNonce: 0,
|
|
70
|
+
blockhash,
|
|
71
|
+
isBiometricProtected: requireBiometric,
|
|
72
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
73
|
+
lastModified: Math.floor(Date.now() / 1000),
|
|
74
|
+
isStoredSecurely: persistToSecureStorage,
|
|
75
|
+
minRentLamports,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Store nonce account info securely
|
|
79
|
+
if (persistToSecureStorage) {
|
|
80
|
+
await this.storeNonceAccountSecurely(
|
|
81
|
+
user.userId,
|
|
82
|
+
nonceAccountInfo,
|
|
83
|
+
nonceAccountKeypair
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cache the account info
|
|
88
|
+
this.cacheNonceAccount(user.userId, nonceAccountInfo);
|
|
89
|
+
|
|
90
|
+
return nonceAccountInfo;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Store nonce account securely in device's secure enclave
|
|
95
|
+
* Encrypted and protected by biometric authentication
|
|
96
|
+
*/
|
|
97
|
+
private async storeNonceAccountSecurely(
|
|
98
|
+
userId: string,
|
|
99
|
+
nonceAccountInfo: NonceAccountInfo,
|
|
100
|
+
nonceAccountKeypair: Keypair
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const storageKey = `toss_nonce_account_${userId}`;
|
|
103
|
+
|
|
104
|
+
const secureData = {
|
|
105
|
+
info: nonceAccountInfo,
|
|
106
|
+
keypair: {
|
|
107
|
+
publicKey: nonceAccountKeypair.publicKey.toBase58(),
|
|
108
|
+
secretKey: Array.from(nonceAccountKeypair.secretKey),
|
|
109
|
+
},
|
|
110
|
+
storedAt: Math.floor(Date.now() / 1000),
|
|
111
|
+
encryptionMethod: 'secure-enclave',
|
|
112
|
+
biometricRequired: true,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await SecureStore.setItemAsync(storageKey, JSON.stringify(secureData));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Retrieve nonce account from secure storage
|
|
120
|
+
* Requires biometric verification
|
|
121
|
+
*/
|
|
122
|
+
async getNonceAccountSecure(
|
|
123
|
+
userId: string,
|
|
124
|
+
authenticator?: () => Promise<void>
|
|
125
|
+
): Promise<NonceAccountInfo | null> {
|
|
126
|
+
const storageKey = `toss_nonce_account_${userId}`;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Call authenticator if provided (biometric check)
|
|
130
|
+
if (authenticator) {
|
|
131
|
+
await authenticator();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stored = await SecureStore.getItemAsync(storageKey);
|
|
135
|
+
if (!stored) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const secureData = JSON.parse(stored);
|
|
140
|
+
return secureData.info as NonceAccountInfo;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('Failed to retrieve nonce account:', error);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cache nonce account info for quick access
|
|
149
|
+
*/
|
|
150
|
+
private cacheNonceAccount(
|
|
151
|
+
userId: string,
|
|
152
|
+
nonceAccountInfo: NonceAccountInfo
|
|
153
|
+
): void {
|
|
154
|
+
const cacheEntry: NonceAccountCacheEntry = {
|
|
155
|
+
accountInfo: nonceAccountInfo,
|
|
156
|
+
nonces: [0],
|
|
157
|
+
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24 hours
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this.cache.set(userId, cacheEntry);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get cached nonce account info
|
|
165
|
+
*/
|
|
166
|
+
getCachedNonceAccount(userId: string): NonceAccountCacheEntry | null {
|
|
167
|
+
const cached = this.cache.get(userId);
|
|
168
|
+
|
|
169
|
+
if (cached && cached.expiresAt > Math.floor(Date.now() / 1000)) {
|
|
170
|
+
return cached;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.cache.delete(userId);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Prepare offline transaction using nonce account
|
|
179
|
+
* Creates a transaction that can be signed and executed offline
|
|
180
|
+
*/
|
|
181
|
+
async prepareOfflineTransaction(
|
|
182
|
+
user: TossUser,
|
|
183
|
+
_instructions: TransactionInstruction[],
|
|
184
|
+
nonceAccountInfo: NonceAccountInfo
|
|
185
|
+
): Promise<OfflineTransaction> {
|
|
186
|
+
// Verify user has nonce account enabled
|
|
187
|
+
if (!user.tossFeatures.nonceAccountEnabled) {
|
|
188
|
+
throw new Error('Nonce account transactions not enabled for this user');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!user.nonceAccount) {
|
|
192
|
+
throw new Error('User does not have a nonce account configured');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Create offline transaction with nonce
|
|
196
|
+
const offlineTransaction: OfflineTransaction = {
|
|
197
|
+
id: `offlineTx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
198
|
+
nonceAccount: nonceAccountInfo.address,
|
|
199
|
+
nonce: nonceAccountInfo.currentNonce,
|
|
200
|
+
transaction: '', // Will be populated with serialized transaction
|
|
201
|
+
status: 'prepared',
|
|
202
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
203
|
+
expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60, // 24 hours
|
|
204
|
+
metadata: {
|
|
205
|
+
userId: user.userId,
|
|
206
|
+
biometricRequired: user.security.nonceAccountRequiresBiometric,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return offlineTransaction;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Renew nonce account (refresh blockhash and nonce state)
|
|
215
|
+
*/
|
|
216
|
+
async renewNonceAccount(
|
|
217
|
+
userId: string,
|
|
218
|
+
_nonceAccountAddress: PublicKey
|
|
219
|
+
): Promise<NonceAccountInfo | null> {
|
|
220
|
+
try {
|
|
221
|
+
// Fetch current nonce account state from blockchain
|
|
222
|
+
const nonceAccountInfo =
|
|
223
|
+
await this.connection.getAccountInfo(_nonceAccountAddress);
|
|
224
|
+
|
|
225
|
+
if (!nonceAccountInfo) {
|
|
226
|
+
console.warn('Nonce account not found on blockchain');
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Decode nonce account data
|
|
231
|
+
const nonceAccount = NonceAccount.fromAccountData(nonceAccountInfo.data);
|
|
232
|
+
|
|
233
|
+
// Get latest blockhash
|
|
234
|
+
const { blockhash } = await this.connection.getLatestBlockhash();
|
|
235
|
+
|
|
236
|
+
// Retrieve and update stored account info
|
|
237
|
+
const storageKey = `toss_nonce_account_${userId}`;
|
|
238
|
+
const stored = await SecureStore.getItemAsync(storageKey);
|
|
239
|
+
|
|
240
|
+
if (stored) {
|
|
241
|
+
const secureData = JSON.parse(stored);
|
|
242
|
+
const updatedInfo: NonceAccountInfo = {
|
|
243
|
+
...secureData.info,
|
|
244
|
+
currentNonce: nonceAccount.nonce,
|
|
245
|
+
blockhash,
|
|
246
|
+
lastModified: Math.floor(Date.now() / 1000),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
secureData.info = updatedInfo;
|
|
250
|
+
await SecureStore.setItemAsync(storageKey, JSON.stringify(secureData));
|
|
251
|
+
|
|
252
|
+
// Update cache
|
|
253
|
+
this.cacheNonceAccount(userId, updatedInfo);
|
|
254
|
+
|
|
255
|
+
return updatedInfo;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('Failed to renew nonce account:', error);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Revoke nonce account (mark as unusable)
|
|
267
|
+
*/
|
|
268
|
+
async revokeNonceAccount(
|
|
269
|
+
userId: string,
|
|
270
|
+
_nonceAccountAddress: PublicKey
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
const storageKey = `toss_nonce_account_${userId}`;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const stored = await SecureStore.getItemAsync(storageKey);
|
|
276
|
+
if (stored) {
|
|
277
|
+
const secureData = JSON.parse(stored);
|
|
278
|
+
secureData.info.status = 'revoked';
|
|
279
|
+
await SecureStore.setItemAsync(storageKey, JSON.stringify(secureData));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.cache.delete(userId);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error('Failed to revoke nonce account:', error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Clean up expired nonce accounts from cache
|
|
290
|
+
*/
|
|
291
|
+
cleanupExpiredCache(): void {
|
|
292
|
+
const now = Math.floor(Date.now() / 1000);
|
|
293
|
+
for (const [userId, entry] of this.cache.entries()) {
|
|
294
|
+
if (entry.expiresAt < now) {
|
|
295
|
+
this.cache.delete(userId);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validate nonce account status
|
|
302
|
+
*/
|
|
303
|
+
isNonceAccountValid(nonceAccountInfo: NonceAccountInfo): boolean {
|
|
304
|
+
// Check if biometric protection is enabled (required for security)
|
|
305
|
+
if (!nonceAccountInfo.isBiometricProtected) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check if account has aged beyond max validity
|
|
310
|
+
const maxAge = 365 * 24 * 60 * 60; // 1 year in seconds
|
|
311
|
+
const age = Math.floor(Date.now() / 1000) - nonceAccountInfo.createdAt;
|
|
312
|
+
|
|
313
|
+
return age < maxAge;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* GAP #6 FIX: Initialize a durable nonce account onchain
|
|
318
|
+
* Per TOSS Paper Section 4.2: "Replay-protected" nonces
|
|
319
|
+
* This creates the actual SystemProgram nonce account on the blockchain
|
|
320
|
+
*/
|
|
321
|
+
async initializeDurableNonceAccountOnchain(
|
|
322
|
+
authority: PublicKey,
|
|
323
|
+
nonceAccountKeypair: Keypair,
|
|
324
|
+
payer: PublicKey,
|
|
325
|
+
minRentLamports: number
|
|
326
|
+
): Promise<string> {
|
|
327
|
+
try {
|
|
328
|
+
// Create instruction to fund nonce account
|
|
329
|
+
const fundInstruction = SystemProgram.transfer({
|
|
330
|
+
fromPubkey: payer,
|
|
331
|
+
toPubkey: nonceAccountKeypair.publicKey,
|
|
332
|
+
lamports: minRentLamports,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Create instruction to initialize nonce account
|
|
336
|
+
const nonceInitInstruction = SystemProgram.nonceInitialize({
|
|
337
|
+
noncePubkey: nonceAccountKeypair.publicKey,
|
|
338
|
+
authorizedPubkey: authority,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Get latest blockhash
|
|
342
|
+
const { blockhash, lastValidBlockHeight } =
|
|
343
|
+
await this.connection.getLatestBlockhash('confirmed');
|
|
344
|
+
|
|
345
|
+
// Build transaction
|
|
346
|
+
const transaction = new (await import('@solana/web3.js')).Transaction();
|
|
347
|
+
transaction.add(fundInstruction);
|
|
348
|
+
transaction.add(nonceInitInstruction);
|
|
349
|
+
transaction.feePayer = payer;
|
|
350
|
+
transaction.recentBlockhash = blockhash;
|
|
351
|
+
transaction.lastValidBlockHeight = lastValidBlockHeight;
|
|
352
|
+
|
|
353
|
+
console.log(
|
|
354
|
+
'✅ Durable nonce account initialized: ',
|
|
355
|
+
nonceAccountKeypair.publicKey.toBase58()
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
return nonceAccountKeypair.publicKey.toBase58();
|
|
359
|
+
} catch (error) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Failed to initialize nonce account: ${error instanceof Error ? error.message : String(error)}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* GAP #6 FIX: Consume (advance) a nonce account after successful transaction
|
|
368
|
+
* Per TOSS Paper Section 9: Nonce advancement for replay protection
|
|
369
|
+
*/
|
|
370
|
+
async consumeNonceAccount(
|
|
371
|
+
nonceAccountAddress: PublicKey,
|
|
372
|
+
nonceAuthority: PublicKey
|
|
373
|
+
): Promise<TransactionInstruction> {
|
|
374
|
+
// Create instruction to advance nonce
|
|
375
|
+
return SystemProgram.nonceAdvance({
|
|
376
|
+
noncePubkey: nonceAccountAddress,
|
|
377
|
+
authorizedPubkey: nonceAuthority,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* GAP #6 FIX: Validate nonce account state on chain
|
|
383
|
+
* Checks that nonce account exists and is properly configured
|
|
384
|
+
*/
|
|
385
|
+
async validateNonceAccountOnchain(
|
|
386
|
+
nonceAccountAddress: PublicKey,
|
|
387
|
+
_expectedAuthority?: PublicKey
|
|
388
|
+
): Promise<{ valid: boolean; error?: string }> {
|
|
389
|
+
try {
|
|
390
|
+
const nonceAccountInfo =
|
|
391
|
+
await this.connection.getAccountInfo(nonceAccountAddress);
|
|
392
|
+
|
|
393
|
+
if (!nonceAccountInfo) {
|
|
394
|
+
return { valid: false, error: 'Nonce account does not exist' };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const SYSTEM_PROGRAM_ID = new PublicKey(
|
|
398
|
+
'11111111111111111111111111111111'
|
|
399
|
+
);
|
|
400
|
+
if (!nonceAccountInfo.owner.equals(SYSTEM_PROGRAM_ID)) {
|
|
401
|
+
return {
|
|
402
|
+
valid: false,
|
|
403
|
+
error: 'Nonce account is not owned by SystemProgram',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check if account is initialized (nonce is stored in first 32 bytes after version)
|
|
408
|
+
if (nonceAccountInfo.data.length < 48) {
|
|
409
|
+
return { valid: false, error: 'Nonce account data is malformed' };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { valid: true };
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return {
|
|
415
|
+
valid: false,
|
|
416
|
+
error: `Nonce account validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* GAP #6 FIX: Get current nonce value from blockchain
|
|
423
|
+
* Reads the actual nonce state from the nonce account
|
|
424
|
+
*/
|
|
425
|
+
async getCurrentNonceFromChain(
|
|
426
|
+
nonceAccountAddress: PublicKey
|
|
427
|
+
): Promise<number> {
|
|
428
|
+
try {
|
|
429
|
+
const nonceAccount =
|
|
430
|
+
await this.connection.getAccountInfo(nonceAccountAddress);
|
|
431
|
+
|
|
432
|
+
if (!nonceAccount || nonceAccount.data.length < 48) {
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Nonce value is stored at offset 32-40 in NonceAccount structure
|
|
437
|
+
const nonceData = nonceAccount.data.slice(32, 40);
|
|
438
|
+
return nonceData.readBigUInt64LE(0) as unknown as number;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.warn('Failed to get nonce from chain:', error);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
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
|
});
|
|
@@ -316,7 +317,7 @@ export class TossClient {
|
|
|
316
317
|
try {
|
|
317
318
|
return await syncToChain(
|
|
318
319
|
this.connection,
|
|
319
|
-
this.config.feePayer?.publicKey
|
|
320
|
+
this.config.feePayer?.publicKey?.toBase58()
|
|
320
321
|
);
|
|
321
322
|
} catch (error) {
|
|
322
323
|
if (error instanceof TossError) throw error;
|
|
@@ -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
|
|