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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciliation and Settlement Module for TOSS
|
|
3
|
+
*
|
|
4
|
+
* Implements Section 9-10 of the TOSS Technical Paper:
|
|
5
|
+
* - Synchronisation and reconciliation with onchain state
|
|
6
|
+
* - Deterministic failure handling
|
|
7
|
+
* - Conflict resolution
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Connection,
|
|
12
|
+
PublicKey,
|
|
13
|
+
Transaction,
|
|
14
|
+
SystemProgram,
|
|
15
|
+
} from '@solana/web3.js';
|
|
16
|
+
import type { SolanaIntent, IntentStatus } from './intent';
|
|
17
|
+
import { isIntentExpired } from './intent';
|
|
18
|
+
import {
|
|
19
|
+
secureStoreIntent,
|
|
20
|
+
getAllSecureIntents,
|
|
21
|
+
} from './storage/secureStorage';
|
|
22
|
+
import { TossError, NetworkError } from './errors';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result of intent settlement attempt
|
|
26
|
+
*/
|
|
27
|
+
export interface SettlementResult {
|
|
28
|
+
intentId: string;
|
|
29
|
+
status: 'success' | 'failed' | 'rejected';
|
|
30
|
+
signature?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* State of a device's reconciliation with onchain
|
|
37
|
+
*/
|
|
38
|
+
export interface ReconciliationState {
|
|
39
|
+
lastSyncTime: number;
|
|
40
|
+
lastSyncSlot: number;
|
|
41
|
+
processedIntents: string[]; // Intent IDs that were successfully settled
|
|
42
|
+
failedIntents: string[]; // Intent IDs that failed or were rejected
|
|
43
|
+
conflictingIntents: string[]; // Intent IDs with detected conflicts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validates an intent can be settled with current onchain state
|
|
48
|
+
*/
|
|
49
|
+
export async function validateIntentOnchain(
|
|
50
|
+
intent: SolanaIntent,
|
|
51
|
+
connection: Connection
|
|
52
|
+
): Promise<{ valid: boolean; error?: string }> {
|
|
53
|
+
try {
|
|
54
|
+
// Check if intent has expired
|
|
55
|
+
if (isIntentExpired(intent)) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: 'Intent has expired',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch sender account info
|
|
63
|
+
const senderPublicKey = new PublicKey(intent.from);
|
|
64
|
+
const senderAccountInfo = await connection.getAccountInfo(senderPublicKey);
|
|
65
|
+
|
|
66
|
+
if (!senderAccountInfo) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
error: 'Sender account does not exist',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate sender has sufficient balance
|
|
74
|
+
if (senderAccountInfo.lamports < intent.amount) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
error: `Insufficient balance: have ${senderAccountInfo.lamports}, need ${intent.amount}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate recipient exists (if not a system account)
|
|
82
|
+
const recipientPublicKey = new PublicKey(intent.to);
|
|
83
|
+
const recipientAccountInfo =
|
|
84
|
+
await connection.getAccountInfo(recipientPublicKey);
|
|
85
|
+
|
|
86
|
+
if (!recipientAccountInfo && intent.amount > 0) {
|
|
87
|
+
// Recipient account doesn't exist - this is okay, will be created by transfer
|
|
88
|
+
// But we should verify it's a valid public key format (already done above)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fetch recent transactions to check for double-spend
|
|
92
|
+
const signatures = await connection.getSignaturesForAddress(
|
|
93
|
+
senderPublicKey,
|
|
94
|
+
{
|
|
95
|
+
limit: 100,
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Check if this nonce has been used recently
|
|
100
|
+
for (const sig of signatures) {
|
|
101
|
+
const tx = await connection.getParsedTransaction(sig.signature);
|
|
102
|
+
if (tx?.transaction.message) {
|
|
103
|
+
// Check if nonce matches and transaction was successful
|
|
104
|
+
const instructions = tx.transaction.message.instructions;
|
|
105
|
+
for (const instruction of instructions) {
|
|
106
|
+
// Look for SystemProgram transfers with same nonce
|
|
107
|
+
if (
|
|
108
|
+
'parsed' in instruction &&
|
|
109
|
+
instruction.parsed?.type === 'transfer'
|
|
110
|
+
) {
|
|
111
|
+
const parsedIx = instruction.parsed;
|
|
112
|
+
if (
|
|
113
|
+
parsedIx.info?.source === intent.from &&
|
|
114
|
+
parsedIx.info?.destination === intent.to
|
|
115
|
+
) {
|
|
116
|
+
// Check if this is a duplicate
|
|
117
|
+
if (tx.slot && tx.blockTime) {
|
|
118
|
+
const timeDiff = Math.floor(Date.now() / 1000) - tx.blockTime;
|
|
119
|
+
// If transaction was confirmed within the nonce window, it's a potential conflict
|
|
120
|
+
if (timeDiff < 5 * 60) {
|
|
121
|
+
return {
|
|
122
|
+
valid: false,
|
|
123
|
+
error: `Potential double-spend detected: similar transaction already confirmed`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { valid: true };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
valid: false,
|
|
137
|
+
error: `Onchain validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Builds a Solana transaction from an intent
|
|
144
|
+
*/
|
|
145
|
+
export async function buildTransactionFromIntent(
|
|
146
|
+
intent: SolanaIntent,
|
|
147
|
+
connection: Connection,
|
|
148
|
+
feePayer?: PublicKey
|
|
149
|
+
): Promise<Transaction> {
|
|
150
|
+
try {
|
|
151
|
+
const senderPublicKey = new PublicKey(intent.from);
|
|
152
|
+
const recipientPublicKey = new PublicKey(intent.to);
|
|
153
|
+
const feePayerPubkey = feePayer || senderPublicKey;
|
|
154
|
+
|
|
155
|
+
// Get latest blockhash
|
|
156
|
+
const { blockhash, lastValidBlockHeight } =
|
|
157
|
+
await connection.getLatestBlockhash('confirmed');
|
|
158
|
+
|
|
159
|
+
// Create transfer instruction
|
|
160
|
+
const transferInstruction = SystemProgram.transfer({
|
|
161
|
+
fromPubkey: senderPublicKey,
|
|
162
|
+
toPubkey: recipientPublicKey,
|
|
163
|
+
lamports: intent.amount,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Create transaction
|
|
167
|
+
const transaction = new Transaction();
|
|
168
|
+
transaction.add(transferInstruction);
|
|
169
|
+
|
|
170
|
+
// If using nonce account, add nonce advance instruction
|
|
171
|
+
if (intent.nonceAccount && intent.nonceAuth) {
|
|
172
|
+
const nonceAccountPubkey = new PublicKey(intent.nonceAccount);
|
|
173
|
+
const nonceAuthPubkey = new PublicKey(intent.nonceAuth);
|
|
174
|
+
|
|
175
|
+
const nonceAdvanceInstruction = SystemProgram.nonceAdvance({
|
|
176
|
+
noncePubkey: nonceAccountPubkey,
|
|
177
|
+
authorizedPubkey: nonceAuthPubkey,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
transaction.add(nonceAdvanceInstruction);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
transaction.feePayer = feePayerPubkey;
|
|
184
|
+
transaction.recentBlockhash = blockhash;
|
|
185
|
+
transaction.lastValidBlockHeight = lastValidBlockHeight;
|
|
186
|
+
|
|
187
|
+
return transaction;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new TossError(
|
|
190
|
+
`Failed to build transaction from intent: ${error instanceof Error ? error.message : String(error)}`,
|
|
191
|
+
'TRANSACTION_BUILD_FAILED'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Submits a transaction to the network with confirmation
|
|
198
|
+
*/
|
|
199
|
+
export async function submitTransactionToChain(
|
|
200
|
+
transaction: Transaction,
|
|
201
|
+
connection: Connection,
|
|
202
|
+
maxRetries: number = 3
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
let lastError: Error | null = null;
|
|
205
|
+
|
|
206
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
207
|
+
try {
|
|
208
|
+
// Serialize and send transaction
|
|
209
|
+
const rawTransaction = transaction.serialize();
|
|
210
|
+
const signature = await connection.sendRawTransaction(rawTransaction, {
|
|
211
|
+
skipPreflight: false,
|
|
212
|
+
preflightCommitment: 'confirmed',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Wait for confirmation
|
|
216
|
+
const confirmation = await connection.confirmTransaction(
|
|
217
|
+
signature,
|
|
218
|
+
'confirmed'
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (confirmation.value.err) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Transaction failed: ${JSON.stringify(confirmation.value.err)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return signature;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
lastError = error as Error;
|
|
230
|
+
|
|
231
|
+
// Don't retry if it's a signature error (transaction already processed)
|
|
232
|
+
if (lastError.message?.includes('Signature verification failed')) {
|
|
233
|
+
throw lastError;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Exponential backoff
|
|
237
|
+
if (attempt < maxRetries) {
|
|
238
|
+
const delay = 1000 * Math.pow(2, attempt - 1);
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new NetworkError(
|
|
245
|
+
`Failed to submit transaction after ${maxRetries} attempts: ${lastError?.message}`,
|
|
246
|
+
{
|
|
247
|
+
context: 'submitTransactionToChain',
|
|
248
|
+
cause: lastError,
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Attempts to settle a single intent and returns the result
|
|
255
|
+
*/
|
|
256
|
+
export async function settleIntent(
|
|
257
|
+
intent: SolanaIntent,
|
|
258
|
+
connection: Connection,
|
|
259
|
+
feePayer?: PublicKey
|
|
260
|
+
): Promise<SettlementResult> {
|
|
261
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// Validate intent against onchain state
|
|
265
|
+
const validation = await validateIntentOnchain(intent, connection);
|
|
266
|
+
|
|
267
|
+
if (!validation.valid) {
|
|
268
|
+
return {
|
|
269
|
+
intentId: intent.id,
|
|
270
|
+
status: 'rejected',
|
|
271
|
+
error: validation.error,
|
|
272
|
+
timestamp,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Build transaction from intent
|
|
277
|
+
const transaction = await buildTransactionFromIntent(
|
|
278
|
+
intent,
|
|
279
|
+
connection,
|
|
280
|
+
feePayer
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Submit transaction to chain
|
|
284
|
+
const signature = await submitTransactionToChain(transaction, connection);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
intentId: intent.id,
|
|
288
|
+
status: 'success',
|
|
289
|
+
signature,
|
|
290
|
+
timestamp,
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
intentId: intent.id,
|
|
295
|
+
status: 'failed',
|
|
296
|
+
error: error instanceof Error ? error.message : String(error),
|
|
297
|
+
timestamp,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Core reconciliation: process all pending intents for settlement
|
|
304
|
+
*/
|
|
305
|
+
export async function reconcilePendingIntents(
|
|
306
|
+
connection: Connection,
|
|
307
|
+
feePayer?: PublicKey
|
|
308
|
+
): Promise<SettlementResult[]> {
|
|
309
|
+
try {
|
|
310
|
+
// Fetch all pending intents from storage
|
|
311
|
+
const allIntents = await getAllSecureIntents();
|
|
312
|
+
const pendingIntents = allIntents.filter((i) => i.status === 'pending');
|
|
313
|
+
|
|
314
|
+
if (pendingIntents.length === 0) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Sort by creation time to maintain ordering
|
|
319
|
+
pendingIntents.sort((a, b) => a.createdAt - b.createdAt);
|
|
320
|
+
|
|
321
|
+
// Settle each intent and collect results
|
|
322
|
+
const settlementResults: SettlementResult[] = [];
|
|
323
|
+
|
|
324
|
+
for (const intent of pendingIntents) {
|
|
325
|
+
const result = await settleIntent(intent, connection, feePayer);
|
|
326
|
+
settlementResults.push(result);
|
|
327
|
+
|
|
328
|
+
// Update intent status based on settlement result
|
|
329
|
+
let newStatus: IntentStatus;
|
|
330
|
+
let error: string | undefined;
|
|
331
|
+
|
|
332
|
+
switch (result.status) {
|
|
333
|
+
case 'success':
|
|
334
|
+
newStatus = 'settled';
|
|
335
|
+
break;
|
|
336
|
+
case 'rejected':
|
|
337
|
+
newStatus = 'failed';
|
|
338
|
+
error = result.error;
|
|
339
|
+
break;
|
|
340
|
+
case 'failed':
|
|
341
|
+
newStatus = 'failed';
|
|
342
|
+
error = result.error;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Update the intent in storage
|
|
347
|
+
const updatedIntent: SolanaIntent = {
|
|
348
|
+
...intent,
|
|
349
|
+
status: newStatus,
|
|
350
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
351
|
+
error,
|
|
352
|
+
signatures: result.signature ? [result.signature] : undefined,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
await secureStoreIntent(updatedIntent);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return settlementResults;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
throw new NetworkError(
|
|
361
|
+
`Reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
+
{ cause: error }
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Detects conflicts between local intents and onchain state
|
|
369
|
+
*/
|
|
370
|
+
export async function detectConflicts(
|
|
371
|
+
connection: Connection
|
|
372
|
+
): Promise<{ intentId: string; conflict: string }[]> {
|
|
373
|
+
const conflicts: { intentId: string; conflict: string }[] = [];
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const allIntents = await getAllSecureIntents();
|
|
377
|
+
|
|
378
|
+
for (const intent of allIntents) {
|
|
379
|
+
// Skip already settled or failed intents
|
|
380
|
+
if (intent.status !== 'pending') continue;
|
|
381
|
+
|
|
382
|
+
// Check for conflicts
|
|
383
|
+
const validation = await validateIntentOnchain(intent, connection);
|
|
384
|
+
|
|
385
|
+
if (!validation.valid) {
|
|
386
|
+
conflicts.push({
|
|
387
|
+
intentId: intent.id,
|
|
388
|
+
conflict: validation.error || 'Unknown conflict',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return conflicts;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
throw new NetworkError('Conflict detection failed', { cause: error });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Gets reconciliation state summary
|
|
401
|
+
*/
|
|
402
|
+
export async function getReconciliationState(
|
|
403
|
+
connection: Connection
|
|
404
|
+
): Promise<ReconciliationState> {
|
|
405
|
+
const allIntents = await getAllSecureIntents();
|
|
406
|
+
const slot = await connection.getSlot('confirmed');
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
lastSyncTime: Math.floor(Date.now() / 1000),
|
|
410
|
+
lastSyncSlot: slot,
|
|
411
|
+
processedIntents: allIntents
|
|
412
|
+
.filter((i) => i.status === 'settled')
|
|
413
|
+
.map((i) => i.id),
|
|
414
|
+
failedIntents: allIntents
|
|
415
|
+
.filter((i) => i.status === 'failed')
|
|
416
|
+
.map((i) => i.id),
|
|
417
|
+
conflictingIntents: allIntents
|
|
418
|
+
.filter((i) => isIntentExpired(i) && i.status === 'pending')
|
|
419
|
+
.map((i) => i.id),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import * as SecureStore from 'expo-secure-store';
|
|
2
|
+
import { Keypair, PublicKey } from '@solana/web3.js';
|
|
3
|
+
import * as LocalAuthentication from 'expo-local-authentication';
|
|
4
|
+
import type { TossUser } from '../types/tossUser';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
export const SESSION_KEY = 'toss_user_session';
|
|
8
|
+
const WALLET_KEY = 'toss_encrypted_wallet';
|
|
9
|
+
const BIOMETRIC_SALT_KEY = 'toss_biometric_salt';
|
|
10
|
+
|
|
11
|
+
type UserSession = {
|
|
12
|
+
id: string;
|
|
13
|
+
token: string;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
walletAddress: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class AuthService {
|
|
19
|
+
static async signInWithWallet(
|
|
20
|
+
walletAddress: string,
|
|
21
|
+
isTemporary: boolean = false
|
|
22
|
+
): Promise<{ user: TossUser; session: UserSession }> {
|
|
23
|
+
// In a real implementation, this would call your backend
|
|
24
|
+
const session: UserSession = {
|
|
25
|
+
id: `sess_${Date.now()}`,
|
|
26
|
+
token: `token_${Math.random().toString(36).substr(2, 9)}`,
|
|
27
|
+
expiresAt: isTemporary
|
|
28
|
+
? Date.now() + 1000 * 60 * 60 * 24 // 24 hours for temporary
|
|
29
|
+
: Date.now() + 1000 * 60 * 60 * 24 * 30, // 30 days
|
|
30
|
+
walletAddress,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const user: TossUser = {
|
|
34
|
+
userId: `user_${walletAddress.slice(0, 8)}`,
|
|
35
|
+
username: `user_${walletAddress.slice(0, 6)}`,
|
|
36
|
+
wallet: {
|
|
37
|
+
publicKey: new PublicKey(walletAddress),
|
|
38
|
+
isVerified: false,
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
},
|
|
41
|
+
device: {
|
|
42
|
+
id: 'device_id_here', // You'd get this from the device
|
|
43
|
+
lastActive: new Date().toISOString(),
|
|
44
|
+
client: 'mobile',
|
|
45
|
+
},
|
|
46
|
+
status: 'active',
|
|
47
|
+
lastSeen: new Date().toISOString(),
|
|
48
|
+
tossFeatures: {
|
|
49
|
+
canSend: true,
|
|
50
|
+
canReceive: true,
|
|
51
|
+
isPrivateTxEnabled: true,
|
|
52
|
+
maxTransactionAmount: 10000,
|
|
53
|
+
},
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
await this.saveSession(session);
|
|
59
|
+
return { user, session };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static async saveSession(session: UserSession): Promise<void> {
|
|
63
|
+
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async getSession(): Promise<UserSession | null> {
|
|
67
|
+
const session = await SecureStore.getItemAsync(SESSION_KEY);
|
|
68
|
+
return session ? JSON.parse(session) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async signOut(): Promise<void> {
|
|
72
|
+
await SecureStore.deleteItemAsync(SESSION_KEY);
|
|
73
|
+
await SecureStore.deleteItemAsync(WALLET_KEY);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static async isWalletUnlocked(): Promise<boolean> {
|
|
77
|
+
const isAvailable = await SecureStore.isAvailableAsync();
|
|
78
|
+
if (!isAvailable) return false;
|
|
79
|
+
|
|
80
|
+
const item = await SecureStore.getItemAsync(WALLET_KEY);
|
|
81
|
+
return item !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static async unlockWalletWithBiometrics(): Promise<Keypair | null> {
|
|
85
|
+
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
|
86
|
+
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
|
87
|
+
|
|
88
|
+
if (!hasHardware || !isEnrolled) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'Biometric authentication required but not available on this device'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// REQUIRED: Biometric authentication before key access
|
|
95
|
+
const result = await LocalAuthentication.authenticateAsync({
|
|
96
|
+
promptMessage: 'Biometric authentication required to access wallet',
|
|
97
|
+
fallbackLabel: 'Enter PIN',
|
|
98
|
+
disableDeviceFallback: false,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
throw new Error('Biometric authentication failed - access denied');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Only after successful biometric: retrieve encrypted keypair
|
|
106
|
+
const encrypted = await SecureStore.getItemAsync(WALLET_KEY);
|
|
107
|
+
if (!encrypted) {
|
|
108
|
+
throw new Error('Wallet not found - ensure wallet is set up first');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const salt = await SecureStore.getItemAsync(BIOMETRIC_SALT_KEY);
|
|
112
|
+
if (!salt) {
|
|
113
|
+
throw new Error('Wallet configuration corrupted');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const decryptedData = JSON.parse(encrypted);
|
|
118
|
+
|
|
119
|
+
if (!decryptedData.publicKey || !decryptedData.secretKey) {
|
|
120
|
+
throw new Error('Invalid wallet data');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reconstruct keypair from encrypted storage
|
|
124
|
+
const secretKeyArray = new Uint8Array(decryptedData.secretKey);
|
|
125
|
+
const keypair = Keypair.fromSecretKey(secretKeyArray);
|
|
126
|
+
|
|
127
|
+
// Verify keypair integrity
|
|
128
|
+
if (keypair.publicKey.toString() !== decryptedData.publicKey) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'Keypair verification failed - wallet may be corrupted'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Keypair returned but never exported/stored externally
|
|
135
|
+
return keypair;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Failed to unlock wallet: ${error instanceof Error ? error.message : String(error)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Setup biometric-protected wallet (REQUIRED for security)
|
|
145
|
+
*
|
|
146
|
+
* SECURITY CRITICAL:
|
|
147
|
+
* - Private keypair is encrypted and stored in hardware-secure storage
|
|
148
|
+
* - Private key NEVER accessible without biometric authentication
|
|
149
|
+
* - User CANNOT export, backup, or access seed phrase
|
|
150
|
+
* - Keypair is device-specific and non-custodial
|
|
151
|
+
*
|
|
152
|
+
* @param keypair User's Solana keypair (never re-used or exported)
|
|
153
|
+
* @param useBiometrics Must be true (biometric is mandatory, not optional)
|
|
154
|
+
*/
|
|
155
|
+
static async setupWalletProtection(
|
|
156
|
+
keypair: Keypair,
|
|
157
|
+
useBiometrics: boolean = true
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (!useBiometrics) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'❌ SECURITY ERROR: Biometric protection is mandatory for wallet security'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Verify biometric is available on device
|
|
166
|
+
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
|
167
|
+
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
|
168
|
+
|
|
169
|
+
if (!hasHardware || !isEnrolled) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
'❌ Biometric authentication required but not configured on device'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Generate unique salt for this wallet
|
|
176
|
+
const salt = crypto.getRandomValues(new Uint8Array(16)).toString();
|
|
177
|
+
await SecureStore.setItemAsync(BIOMETRIC_SALT_KEY, salt);
|
|
178
|
+
|
|
179
|
+
// Encrypt and store keypair in hardware-backed secure storage
|
|
180
|
+
const walletData = {
|
|
181
|
+
publicKey: keypair.publicKey.toString(),
|
|
182
|
+
secretKey: Array.from(keypair.secretKey), // Serializable format only
|
|
183
|
+
createdAt: Date.now(),
|
|
184
|
+
biometricRequired: true,
|
|
185
|
+
nonCustodial: true,
|
|
186
|
+
deviceSpecific: true,
|
|
187
|
+
exportable: false, // Explicitly non-exportable
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Store in Secure Enclave (iOS) or Keymaster (Android)
|
|
191
|
+
await SecureStore.setItemAsync(WALLET_KEY, JSON.stringify(walletData));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Verify wallet is stored securely (requires biometric to access)
|
|
196
|
+
* @returns true if wallet exists and requires biometric
|
|
197
|
+
*/
|
|
198
|
+
static async isKeypairStoredSecurely(): Promise<boolean> {
|
|
199
|
+
const stored = await SecureStore.getItemAsync(WALLET_KEY);
|
|
200
|
+
return stored !== null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get public key only (NO AUTHENTICATION REQUIRED - public key is safe)
|
|
205
|
+
* Use this for displaying wallet address, sending funds to, etc.
|
|
206
|
+
*/
|
|
207
|
+
static async getPublicKeyWithoutAuth(): Promise<PublicKey | null> {
|
|
208
|
+
try {
|
|
209
|
+
const encrypted = await SecureStore.getItemAsync(WALLET_KEY);
|
|
210
|
+
if (!encrypted) return null;
|
|
211
|
+
|
|
212
|
+
const data = JSON.parse(encrypted);
|
|
213
|
+
return new PublicKey(data.publicKey);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error('Failed to get public key:', error);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Lock wallet from memory (does NOT delete stored keypair)
|
|
222
|
+
* Keypair remains encrypted in secure storage
|
|
223
|
+
*/
|
|
224
|
+
static async lockWalletFromMemory(): Promise<void> {
|
|
225
|
+
// This is handled by WalletContext clearing the keypair state
|
|
226
|
+
// The encrypted keypair stays in SecureStore
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Permanently delete wallet (IRREVERSIBLE)
|
|
231
|
+
* Only use for logout or account deletion
|
|
232
|
+
*/
|
|
233
|
+
static async deleteWalletPermanently(): Promise<void> {
|
|
234
|
+
await SecureStore.deleteItemAsync(WALLET_KEY);
|
|
235
|
+
await SecureStore.deleteItemAsync(BIOMETRIC_SALT_KEY);
|
|
236
|
+
await SecureStore.deleteItemAsync(SESSION_KEY);
|
|
237
|
+
}
|
|
238
|
+
}
|