@0xio/sdk 2.6.0 → 2.7.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/dist/index.esm.js CHANGED
@@ -1,25 +1,13 @@
1
- /**
2
- * 0xio Wallet SDK - Event System
3
- * Type-safe event emitter for wallet events
4
- */
5
1
  class EventEmitter {
6
- constructor(debug = false) {
2
+ constructor(_debug = false) {
7
3
  this.listeners = new Map();
8
- this.debug = debug;
9
4
  }
10
- /**
11
- * Add event listener
12
- */
13
5
  on(eventType, listener) {
14
6
  if (!this.listeners.has(eventType)) {
15
7
  this.listeners.set(eventType, new Set());
16
8
  }
17
9
  this.listeners.get(eventType).add(listener);
18
- if (this.debug) ;
19
10
  }
20
- /**
21
- * Remove event listener
22
- */
23
11
  off(eventType, listener) {
24
12
  const eventListeners = this.listeners.get(eventType);
25
13
  if (eventListeners) {
@@ -27,22 +15,16 @@ class EventEmitter {
27
15
  if (eventListeners.size === 0) {
28
16
  this.listeners.delete(eventType);
29
17
  }
30
- if (this.debug) ;
31
18
  }
32
19
  }
33
- /**
34
- * Add one-time event listener
35
- */
36
20
  once(eventType, listener) {
37
21
  const onceListener = (event) => {
38
- listener(event);
22
+ // Remove BEFORE calling so a throwing listener doesn't stay registered
39
23
  this.off(eventType, onceListener);
24
+ listener(event);
40
25
  };
41
26
  this.on(eventType, onceListener);
42
27
  }
43
- /**
44
- * Emit event to all listeners
45
- */
46
28
  emit(eventType, data) {
47
29
  const event = {
48
30
  type: eventType,
@@ -51,49 +33,32 @@ class EventEmitter {
51
33
  };
52
34
  const eventListeners = this.listeners.get(eventType);
53
35
  if (eventListeners && eventListeners.size > 0) {
54
- if (this.debug) ;
55
- // Create a copy to avoid issues if listeners modify the set during iteration
56
- const listenersArray = Array.from(eventListeners);
57
- for (const listener of listenersArray) {
36
+ // snapshot to avoid issues if listeners modify the set during iteration
37
+ for (const listener of Array.from(eventListeners)) {
58
38
  try {
59
39
  listener(event);
60
40
  }
61
- catch (error) {
62
- // console.error(`[0xio SDK] Error in event listener for '${eventType}':`, error);
41
+ catch {
42
+ // listener errors are swallowed to keep the event loop running
63
43
  }
64
44
  }
65
45
  }
66
- else if (this.debug) ;
67
46
  }
68
- /**
69
- * Remove all listeners for a specific event type
70
- */
71
47
  removeAllListeners(eventType) {
72
48
  if (eventType) {
73
49
  this.listeners.delete(eventType);
74
- if (this.debug) ;
75
50
  }
76
51
  else {
77
52
  this.listeners.clear();
78
- if (this.debug) ;
79
53
  }
80
54
  }
81
- /**
82
- * Get number of listeners for an event type
83
- */
84
55
  listenerCount(eventType) {
85
56
  const eventListeners = this.listeners.get(eventType);
86
57
  return eventListeners ? eventListeners.size : 0;
87
58
  }
88
- /**
89
- * Get all event types that have listeners
90
- */
91
59
  eventTypes() {
92
60
  return Array.from(this.listeners.keys());
93
61
  }
94
- /**
95
- * Check if there are any listeners for an event type
96
- */
97
62
  hasListeners(eventType) {
98
63
  return this.listenerCount(eventType) > 0;
99
64
  }
@@ -138,15 +103,134 @@ class ZeroXIOWalletError extends Error {
138
103
  }
139
104
 
140
105
  /**
141
- * 0xio Wallet SDK - Utilities
142
- * Helper functions for validation, formatting, and common operations
106
+ * 0xio Wallet transport adapter.
107
+ *
108
+ * Implements the postMessage protocol used by the 0xio browser extension (>= v2.4.0).
109
+ *
110
+ * Outbound wire format:
111
+ * window.postMessage({ source: '0xio-sdk-request', request: { id, method, params, timestamp } }, origin)
112
+ *
113
+ * Inbound wire format:
114
+ * window.postMessage({ source: '0xio-sdk-bridge', response: { id, success, data, error }, sessionNonce })
115
+ * window.postMessage({ source: '0xio-sdk-bridge', event: { type, data } })
116
+ *
117
+ * H-2: Session nonce validation — injected.ts broadcasts the nonce received from the
118
+ * isolated content script. Once set, any '0xio-sdk-bridge' response with a missing or
119
+ * mismatched nonce is rejected, preventing response injection by malicious page scripts.
143
120
  */
144
- // ===================
145
- // VALIDATION UTILITIES
146
- // ===================
147
121
  /**
148
- * Validate wallet address for Octra blockchain
122
+ * Creates a 0xio adapter. The factory accepts optional extra trusted parent origins
123
+ * so the communicator can forward its own trustedOrigins setting to origin validation.
149
124
  */
125
+ function createZeroXIOAdapter(extraTrustedOrigins = []) {
126
+ return {
127
+ name: '0xio',
128
+ displayName: '0xio Wallet',
129
+ detect() {
130
+ if (typeof window === 'undefined')
131
+ return false;
132
+ const win = window;
133
+ return !!(win.wallet0xio ||
134
+ win.ZeroXIOWallet ||
135
+ // 0xio extension also exposes window.octra with isOctra=true; match it here
136
+ // so this adapter (postMessage protocol) takes priority over OctraProviderAdapter
137
+ (win.octra?.isOctra && (win.wallet0xio || win.ZeroXIOWallet)) ||
138
+ win.chrome?.runtime?.id ||
139
+ document.querySelector('meta[name="0xio-dapp"]') ||
140
+ document.querySelector('[data-0xio-sdk-bridge]'));
141
+ },
142
+ postRequest(request) {
143
+ window.postMessage({ source: '0xio-sdk-request', request }, window.location.origin);
144
+ },
145
+ postRequestToParent(request, parentOrigin) {
146
+ try {
147
+ window.parent.postMessage({ source: '0xio-sdk-request', request }, parentOrigin);
148
+ }
149
+ catch {
150
+ // Do not fall back to '*' — silent failure is safer
151
+ }
152
+ },
153
+ listen(handler, options) {
154
+ const allowedOrigin = window.location.origin;
155
+ const trustedParentOrigins = new Set([
156
+ allowedOrigin,
157
+ 'tauri://localhost',
158
+ 'https://tauri.localhost',
159
+ 'http://localhost',
160
+ 'https://localhost',
161
+ ...(extraTrustedOrigins),
162
+ ...(options?.trustedParentOrigins ?? []),
163
+ ]);
164
+ let _sessionNonce = null;
165
+ // H-2: receive session nonce from injected.ts (MAIN world content script)
166
+ const nonceListener = (e) => {
167
+ if (e.origin !== allowedOrigin)
168
+ return;
169
+ if (e.data?.source === '0xio-sdk-nonce-init' && typeof e.data.nonce === 'string') {
170
+ _sessionNonce = e.data.nonce;
171
+ window.removeEventListener('message', nonceListener);
172
+ }
173
+ };
174
+ window.addEventListener('message', nonceListener);
175
+ const msgListener = (e) => {
176
+ const isFromSameOrigin = e.origin === allowedOrigin;
177
+ const isLocalhost = e.origin.startsWith('http://localhost:') ||
178
+ e.origin.startsWith('http://127.0.0.1:');
179
+ const isFromTrustedParent = e.source === window.parent &&
180
+ window.parent !== window &&
181
+ (trustedParentOrigins.has(e.origin) || isLocalhost);
182
+ if (!isFromSameOrigin && !isFromTrustedParent)
183
+ return;
184
+ if (e.source !== window && e.source !== window.parent)
185
+ return;
186
+ if (!e.data || e.data.source !== '0xio-sdk-bridge')
187
+ return;
188
+ // H-2: session nonce validation.
189
+ // Preferred path: nonce set via 0xio-sdk-nonce-init from injected.ts.
190
+ // Fallback path: if the init broadcast was missed (race between document_start
191
+ // content script and page script load), capture nonce from the first same-origin
192
+ // response so that all subsequent responses are validated.
193
+ if (!_sessionNonce && isFromSameOrigin && typeof e.data.sessionNonce === 'string') {
194
+ _sessionNonce = e.data.sessionNonce;
195
+ }
196
+ if (_sessionNonce && e.data.sessionNonce !== _sessionNonce)
197
+ return;
198
+ if (e.data.response) {
199
+ const r = e.data.response;
200
+ handler({
201
+ requestId: r.id,
202
+ success: r.success,
203
+ data: r.data,
204
+ error: r.error,
205
+ });
206
+ }
207
+ else if (e.data.event) {
208
+ handler({
209
+ eventType: e.data.event.type,
210
+ eventData: e.data.event.data ?? e.data.event,
211
+ });
212
+ }
213
+ };
214
+ window.addEventListener('message', msgListener);
215
+ return () => {
216
+ window.removeEventListener('message', nonceListener);
217
+ window.removeEventListener('message', msgListener);
218
+ };
219
+ },
220
+ listenForReady(onReady) {
221
+ const handler = () => onReady();
222
+ window.addEventListener('0xioWalletReady', handler);
223
+ window.addEventListener('wallet0xioReady', handler);
224
+ return () => {
225
+ window.removeEventListener('0xioWalletReady', handler);
226
+ window.removeEventListener('wallet0xioReady', handler);
227
+ };
228
+ },
229
+ };
230
+ }
231
+ /** Default 0xio adapter instance (no extra trusted origins). */
232
+ const ZeroXIOAdapter = createZeroXIOAdapter();
233
+
150
234
  function isValidAddress(address) {
151
235
  if (!address || typeof address !== 'string') {
152
236
  return false;
@@ -156,51 +240,89 @@ function isValidAddress(address) {
156
240
  return addressRegex.test(address);
157
241
  }
158
242
  /**
159
- * Validate transaction amount
243
+ * Validate transaction amount.
244
+ * Accepts both number and string representations.
245
+ * String amounts avoid JS number precision loss for very large values.
160
246
  */
161
247
  function isValidAmount(amount) {
248
+ if (typeof amount === 'string') {
249
+ const n = parseFloat(amount);
250
+ return !isNaN(n) && n > 0 && Number.isFinite(n);
251
+ }
162
252
  return typeof amount === 'number' &&
163
253
  amount > 0 &&
164
254
  Number.isFinite(amount) &&
165
255
  amount <= Number.MAX_SAFE_INTEGER;
166
256
  }
167
- /**
168
- * Validate transaction message
169
- */
170
257
  function isValidMessage(message) {
171
- if (!message) {
172
- return true; // Empty messages are valid
173
- }
258
+ // Type check first — falsy non-strings (0, false, null) are NOT valid messages
174
259
  if (typeof message !== 'string') {
175
- return false;
260
+ return message === undefined || message === null ? true : false;
261
+ }
262
+ // Empty string is valid (optional field)
263
+ if (message.length === 0) {
264
+ return true;
176
265
  }
177
266
  // 100KB limit — contract call params can be large (serialized JSON)
178
267
  return message.length <= 100000;
179
268
  }
180
- /**
181
- * Validate fee level
182
- */
183
269
  function isValidFeeLevel(feeLevel) {
184
270
  return feeLevel === 1 || feeLevel === 3;
185
271
  }
186
- // ===================
187
- // FORMATTING UTILITIES
188
- // ===================
272
+ const _B58_ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
273
+ function _base58Encode(buf) {
274
+ let zeros = 0;
275
+ for (let i = 0; i < buf.length && buf[i] === 0; i++)
276
+ zeros++;
277
+ const digits = [];
278
+ for (let i = zeros; i < buf.length; i++) {
279
+ let carry = buf[i];
280
+ for (let j = 0; j < digits.length; j++) {
281
+ carry += digits[j] << 8;
282
+ digits[j] = carry % 58;
283
+ carry = Math.floor(carry / 58);
284
+ }
285
+ while (carry > 0) {
286
+ digits.push(carry % 58);
287
+ carry = Math.floor(carry / 58);
288
+ }
289
+ }
290
+ let out = '';
291
+ for (let i = 0; i < zeros; i++)
292
+ out += _B58_ALPHA[0];
293
+ for (let i = digits.length - 1; i >= 0; i--)
294
+ out += _B58_ALPHA[digits[i]];
295
+ return out;
296
+ }
189
297
  /**
190
- * Format OCT amount for display
298
+ * Derive the canonical Octra address from a base64-encoded Ed25519 public key.
299
+ * Algorithm: SHA-256(pubkey_bytes) → base58 → prepend "oct"
300
+ * Source of truth: ocho-push-server/src/index.ts#deriveAddressFromPubkey
191
301
  */
302
+ async function deriveOctraAddress(publicKeyBase64) {
303
+ if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') {
304
+ throw new Error('publicKeyBase64 must be a non-empty string');
305
+ }
306
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
307
+ throw new Error('Web Crypto API is not available in this environment');
308
+ }
309
+ const binStr = atob(publicKeyBase64);
310
+ const bytes = new Uint8Array(binStr.length);
311
+ for (let i = 0; i < binStr.length; i++)
312
+ bytes[i] = binStr.charCodeAt(i);
313
+ const hashBuf = await crypto.subtle.digest('SHA-256', bytes);
314
+ return 'oct' + _base58Encode(new Uint8Array(hashBuf));
315
+ }
192
316
  function formatOCT(amount, decimals = 6) {
193
- if (!isValidAmount(amount)) {
317
+ const n = typeof amount === 'string' ? parseFloat(amount) : amount;
318
+ if (!isValidAmount(n)) {
194
319
  return '0';
195
320
  }
196
- return amount.toLocaleString(undefined, {
321
+ return n.toLocaleString(undefined, {
197
322
  minimumFractionDigits: 0,
198
323
  maximumFractionDigits: decimals
199
324
  });
200
325
  }
201
- /**
202
- * Format address for display (truncated)
203
- */
204
326
  function formatAddress(address, prefixLength = 6, suffixLength = 4) {
205
327
  if (!isValidAddress(address)) {
206
328
  return 'Invalid Address';
@@ -210,16 +332,10 @@ function formatAddress(address, prefixLength = 6, suffixLength = 4) {
210
332
  }
211
333
  return `${address.slice(0, prefixLength)}...${address.slice(-suffixLength)}`;
212
334
  }
213
- /**
214
- * Format timestamp for display
215
- */
216
335
  function formatTimestamp(timestamp) {
217
336
  const date = new Date(timestamp);
218
337
  return date.toLocaleString();
219
338
  }
220
- /**
221
- * Format transaction hash for display
222
- */
223
339
  function formatTxHash(hash, length = 12) {
224
340
  if (!hash || typeof hash !== 'string') {
225
341
  return 'Invalid Hash';
@@ -231,12 +347,6 @@ function formatTxHash(hash, length = 12) {
231
347
  const suffixLength = Math.floor(length / 2);
232
348
  return `${hash.slice(0, prefixLength)}...${hash.slice(-suffixLength)}`;
233
349
  }
234
- // ===================
235
- // CONVERSION UTILITIES
236
- // ===================
237
- /**
238
- * Convert OCT to micro OCT (for network transmission)
239
- */
240
350
  function toMicroOCT(amount) {
241
351
  if (!isValidAmount(amount)) {
242
352
  throw new ZeroXIOWalletError(ErrorCode.INVALID_AMOUNT, 'Invalid amount for conversion');
@@ -245,9 +355,6 @@ function toMicroOCT(amount) {
245
355
  const microOCT = Math.round(amount * 1000000);
246
356
  return microOCT.toString();
247
357
  }
248
- /**
249
- * Convert micro OCT to OCT (for display)
250
- */
251
358
  function fromMicroOCT(microAmount) {
252
359
  const amount = typeof microAmount === 'string' ? parseInt(microAmount, 10) : microAmount;
253
360
  if (!Number.isFinite(amount) || amount < 0) {
@@ -255,12 +362,6 @@ function fromMicroOCT(microAmount) {
255
362
  }
256
363
  return amount / 1000000;
257
364
  }
258
- // ===================
259
- // ERROR UTILITIES
260
- // ===================
261
- /**
262
- * Create standardized error messages
263
- */
264
365
  function createErrorMessage(code, context) {
265
366
  const baseMessages = {
266
367
  [ErrorCode.EXTENSION_NOT_FOUND]: '0xio Wallet extension is not installed or enabled',
@@ -287,24 +388,12 @@ function createErrorMessage(code, context) {
287
388
  const baseMessage = baseMessages[code] || 'Unknown error';
288
389
  return context ? `${baseMessage}: ${context}` : baseMessage;
289
390
  }
290
- /**
291
- * Check if error is a specific type
292
- */
293
391
  function isErrorType(error, code) {
294
392
  return error instanceof ZeroXIOWalletError && error.code === code;
295
393
  }
296
- // ===================
297
- // ASYNC UTILITIES
298
- // ===================
299
- /**
300
- * Create a promise that resolves after a delay
301
- */
302
394
  function delay(ms) {
303
395
  return new Promise(resolve => setTimeout(resolve, ms));
304
396
  }
305
- /**
306
- * Retry an async operation with exponential backoff
307
- */
308
397
  async function retry(operation, maxRetries = 3, baseDelay = 1000) {
309
398
  let lastError;
310
399
  // maxRetries = number of retries AFTER the first attempt
@@ -344,18 +433,9 @@ function withTimeout(promise, timeoutMs, timeoutMessage = 'Operation timed out')
344
433
  clearTimeout(timer);
345
434
  });
346
435
  }
347
- // ===================
348
- // BROWSER UTILITIES
349
- // ===================
350
- /**
351
- * Check if running in browser environment
352
- */
353
436
  function isBrowser() {
354
437
  return typeof window !== 'undefined' && typeof document !== 'undefined';
355
438
  }
356
- /**
357
- * Check if browser supports required features
358
- */
359
439
  function checkBrowserSupport() {
360
440
  const missingFeatures = [];
361
441
  if (!isBrowser()) {
@@ -377,12 +457,6 @@ function checkBrowserSupport() {
377
457
  missingFeatures
378
458
  };
379
459
  }
380
- // ===================
381
- // DEVELOPMENT UTILITIES
382
- // ===================
383
- /**
384
- * Generate mock data for development/testing
385
- */
386
460
  function generateMockData() {
387
461
  return {
388
462
  address: 'oct' + Math.random().toString(36).substring(2, 22) + Math.random().toString(36).substring(2, 26),
@@ -395,7 +469,7 @@ function generateMockData() {
395
469
  networkInfo: {
396
470
  id: 'mainnet',
397
471
  name: 'Octra Mainnet',
398
- rpcUrl: 'http://46.101.86.250:8080',
472
+ rpcUrl: 'https://octra.network',
399
473
  explorerUrl: 'https://lite.octrascan.io/tx.html?hash=',
400
474
  explorerAddressUrl: 'https://lite.octrascan.io/address.html?addr=',
401
475
  indexerUrl: 'https://lite.octrascan.io',
@@ -405,9 +479,6 @@ function generateMockData() {
405
479
  }
406
480
  };
407
481
  }
408
- /**
409
- * Create development logger
410
- */
411
482
  function createLogger(prefix, debug) {
412
483
  const isDevelopment = typeof window !== 'undefined' && ((typeof globalThis !== 'undefined' && globalThis.process?.env?.NODE_ENV === 'development') ||
413
484
  window.location.hostname === 'localhost' ||
@@ -460,116 +531,51 @@ function createLogger(prefix, debug) {
460
531
  };
461
532
  }
462
533
 
463
- /**
464
- * 0xio Wallet SDK - Extension Communication Module
465
- *
466
- * @fileoverview Manages secure communication between the SDK and browser extension.
467
- * Implements message passing, request/response handling, rate limiting, and origin validation
468
- * to ensure secure wallet interactions.
469
- *
470
- * @module communication
471
- * @version 2.6.0
472
- * @license MIT
473
- */
474
- /**
475
- * ExtensionCommunicator - Manages communication with the 0xio Wallet browser extension
476
- *
477
- * @class
478
- * @extends EventEmitter
479
- *
480
- * @description
481
- * Handles all communication between the SDK and wallet extension including:
482
- * - Request/response message passing with origin validation
483
- * - Rate limiting to prevent DoS attacks
484
- * - Automatic retry logic with exponential backoff
485
- * - Extension detection and availability monitoring
486
- * - Cryptographically secure request ID generation
487
- *
488
- * @example
489
- * ```typescript
490
- * const communicator = new ExtensionCommunicator(true); // debug mode
491
- * await communicator.initialize();
492
- *
493
- * const response = await communicator.sendRequest('get_balance', {});
494
- * console.log(response);
495
- * ```
496
- */
497
534
  class ExtensionCommunicator extends EventEmitter {
498
- /**
499
- * Creates a new ExtensionCommunicator instance
500
- *
501
- * @param {boolean} debug - Enable debug logging
502
- */
503
- constructor(debug = false, trustedOrigins = []) {
535
+ constructor(debug = false, trustedOrigins = [], adapter) {
504
536
  super(debug);
505
- /** Legacy request counter (deprecated, kept for fallback) */
506
- this.requestId = 0;
507
- /** Map of pending requests awaiting responses */
508
537
  this.pendingRequests = new Map();
509
- /** Initialization state flag */
510
538
  this.isInitialized = false;
511
- /** Interval handle for periodic extension detection */
512
539
  this.extensionDetectionInterval = null;
513
- /** Current extension availability state */
514
540
  this.isExtensionAvailableState = false;
515
- /** Message listener reference for cleanup */
516
- this.messageListener = null;
517
- /** Trusted parent origins for iframe communication */
518
541
  this.trustedOrigins = [];
519
- /** Parent origin learned from walletReady signal */
520
542
  this._parentOrigin = null;
521
- // Rate limiting configuration
522
- /** Maximum number of concurrent pending requests */
543
+ /** Teardown fn returned by adapter.listen() */
544
+ this._adapterTeardown = null;
545
+ /** Teardown fn returned by adapter.listenForReady() */
546
+ this._adapterReadyTeardown = null;
547
+ /**
548
+ * Set when a trusted walletReady has been received from window.parent.
549
+ * The polling fallback must NOT clear this flag.
550
+ */
551
+ this._parentTrusted = false;
552
+ /** walletReady postMessage listener stored for cleanup */
553
+ this._walletReadyMessageListener = null;
554
+ /**
555
+ * In-flight interactive request lock.
556
+ * Methods that open approval popups are serialized — only one at a time.
557
+ */
558
+ this._interactiveInFlight = false;
523
559
  this.MAX_CONCURRENT_REQUESTS = 50;
524
- /** Time window for rate limiting (milliseconds) */
525
560
  this.RATE_LIMIT_WINDOW = 1000;
526
- /** Maximum requests allowed per time window */
527
561
  this.MAX_REQUESTS_PER_WINDOW = 20;
528
- /** Timestamps of recent requests for rate limiting */
529
562
  this.requestTimestamps = [];
530
563
  this.logger = createLogger('ExtensionCommunicator', debug);
531
564
  this.trustedOrigins = trustedOrigins;
565
+ this.adapter = adapter ?? createZeroXIOAdapter(trustedOrigins);
532
566
  this.setupMessageListener();
533
567
  this.startExtensionDetection();
534
568
  }
535
- /**
536
- * Add trusted origins for iframe/bridge communication
537
- * Call this before connecting if your dApp runs inside a trusted frame
538
- */
539
569
  setTrustedOrigins(origins) {
540
570
  this.trustedOrigins = origins;
541
571
  }
542
- /**
543
- * Initialize communication with the wallet extension
544
- *
545
- * @description
546
- * Performs initial setup and verification:
547
- * 1. Waits for extension to become available
548
- * 2. Sends ping to verify communication
549
- * 3. Establishes message handlers
550
- *
551
- * Must be called before any other methods.
552
- *
553
- * @returns {Promise<boolean>} True if initialization succeeded, false otherwise
554
- * @throws {ZeroXIOWalletError} If extension is not available after timeout
555
- *
556
- * @example
557
- * ```typescript
558
- * const success = await communicator.initialize();
559
- * if (!success) {
560
- * console.error('Failed to initialize wallet connection');
561
- * }
562
- * ```
563
- */
564
572
  async initialize() {
565
573
  if (this.isInitialized) {
566
574
  return true;
567
575
  }
568
576
  try {
569
- // Wait for extension detection with timeout
570
577
  const available = await this.waitForExtensionAvailability(10000);
571
578
  if (available) {
572
- // Verify with ping
573
579
  await withTimeout(this.sendRequestWithRetry('ping', {}, 3, 2000), 8000, 'Extension ping timeout during initialization');
574
580
  this.isInitialized = true;
575
581
  this.logger.log('Extension communication initialized successfully');
@@ -584,149 +590,106 @@ class ExtensionCommunicator extends EventEmitter {
584
590
  return false;
585
591
  }
586
592
  }
587
- /**
588
- * Check if extension is available
589
- */
590
593
  isExtensionAvailable() {
591
594
  return this.isExtensionAvailableState && this.hasExtensionContext();
592
595
  }
593
- /**
594
- * Send request to extension
595
- */
596
596
  async sendRequest(method, params = {}, timeout = 30000) {
597
597
  const isInteractive = ExtensionCommunicator.NO_RETRY_METHODS.has(method);
598
598
  const maxRetries = isInteractive ? 0 : 1;
599
- // Give users 3 minutes for interactive approvals (review + sign + FHE proof generation)
600
599
  const effectiveTimeout = isInteractive ? Math.max(timeout, 180000) : timeout;
601
600
  return this.sendRequestWithRetry(method, params, maxRetries, effectiveTimeout);
602
601
  }
603
- /**
604
- * Send request to extension with automatic retry logic
605
- */
606
602
  async sendRequestWithRetry(method, params = {}, maxRetries = 3, timeout = 30000) {
607
603
  if (!this.hasExtensionContext()) {
608
- throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, '0xio Wallet extension is not installed or available', {
609
- method,
610
- params,
611
- browserContext: this.getBrowserDiagnostics()
612
- });
604
+ throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, '0xio Wallet extension is not installed or available', { method, browserContext: this.getBrowserDiagnostics() });
613
605
  }
614
606
  if (!this.isExtensionAvailableState) {
615
- // Wait a bit for extension to become available
616
607
  await this.waitForExtensionAvailability(5000);
617
608
  if (!this.isExtensionAvailableState) {
618
- throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, 'Extension not available for communication', {
619
- method,
620
- params,
621
- extensionState: this.getExtensionDiagnostics()
622
- });
609
+ throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, 'Extension not available for communication', { method, extensionState: this.getExtensionDiagnostics() });
623
610
  }
624
611
  }
625
- // SECURITY: Check rate limits before processing
612
+ // Enforce one-at-a-time for interactive popup methods
613
+ const isInteractive = ExtensionCommunicator.INTERACTIVE_METHODS.has(method);
614
+ if (isInteractive) {
615
+ if (this._interactiveInFlight) {
616
+ throw new ZeroXIOWalletError(ErrorCode.RATE_LIMIT_EXCEEDED, 'Another approval popup is already open. Please wait for it to complete.');
617
+ }
618
+ this._interactiveInFlight = true;
619
+ }
626
620
  this.checkRateLimit();
627
- return retry(async () => {
628
- const requestId = this.generateRequestId();
629
- const request = {
630
- id: requestId,
631
- method,
632
- params,
633
- timestamp: Date.now()
634
- };
635
- this.logger.log(`Sending request (${method}):`, { id: requestId, params });
636
- return new Promise((resolve, reject) => {
637
- // Set up timeout
638
- const timeoutHandle = setTimeout(() => {
639
- const pending = this.pendingRequests.get(requestId);
640
- if (pending) {
621
+ try {
622
+ return await retry(async () => {
623
+ const requestId = this.generateRequestId();
624
+ const request = {
625
+ id: requestId,
626
+ method,
627
+ params,
628
+ timestamp: Date.now()
629
+ };
630
+ this.logger.log(`Sending request (${method}):`, { id: requestId });
631
+ return new Promise((resolve, reject) => {
632
+ const timeoutHandle = setTimeout(() => {
633
+ const pending = this.pendingRequests.get(requestId);
634
+ if (pending) {
635
+ this.pendingRequests.delete(requestId);
636
+ reject(new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, `Request timeout after ${timeout}ms`, { method, requestId, retryCount: pending.retryCount }));
637
+ }
638
+ }, timeout);
639
+ this.pendingRequests.set(requestId, {
640
+ resolve,
641
+ reject,
642
+ timeout: timeoutHandle,
643
+ retryCount: 0
644
+ });
645
+ // Wrap postMessage so a DataCloneError cleans up the pending entry
646
+ try {
647
+ this.postMessageToExtension(request);
648
+ }
649
+ catch (cloneErr) {
650
+ clearTimeout(timeoutHandle);
641
651
  this.pendingRequests.delete(requestId);
642
- reject(new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, `Request timeout after ${timeout}ms`, {
643
- method,
644
- params,
645
- requestId,
646
- retryCount: pending.retryCount,
647
- extensionState: this.getExtensionDiagnostics()
648
- }));
652
+ reject(new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Request params are not serializable', { method, requestId }));
649
653
  }
650
- }, timeout);
651
- // Store request handlers
652
- this.pendingRequests.set(requestId, {
653
- resolve,
654
- reject,
655
- timeout: timeoutHandle,
656
- retryCount: 0
657
654
  });
658
- // Send message to extension via content script bridge
659
- this.postMessageToExtension(request);
660
- });
661
- }, maxRetries, 1000);
662
- }
663
- /**
664
- * Setup message listener for responses from extension
665
- */
666
- setupMessageListener() {
667
- if (typeof window === 'undefined') {
668
- return; // Not in browser environment
669
- }
670
- const allowedOrigin = window.location.origin;
671
- // Store allowed origins for parent frame communication
672
- // Desktop (Tauri): tauri://localhost or https://tauri.localhost
673
- // Mobile (Expo): about:blank or custom scheme
674
- const trustedParentOrigins = new Set([
675
- allowedOrigin,
676
- 'tauri://localhost',
677
- 'https://tauri.localhost',
678
- 'http://localhost',
679
- 'https://localhost',
680
- ]);
681
- // Allow dApps to register additional trusted origins
682
- if (this.trustedOrigins) {
683
- for (const origin of this.trustedOrigins) {
684
- trustedParentOrigins.add(origin);
685
- }
655
+ }, maxRetries, 1000);
686
656
  }
687
- this.messageListener = (event) => {
688
- // Strict origin validation
689
- const isFromSameOrigin = event.origin === allowedOrigin;
690
- const isLocalhost = event.origin.startsWith('http://localhost:') || event.origin.startsWith('http://127.0.0.1:');
691
- const isFromTrustedParent = event.source === window.parent
692
- && window.parent !== window
693
- && (trustedParentOrigins.has(event.origin) || isLocalhost);
694
- if (!isFromSameOrigin && !isFromTrustedParent) {
695
- return;
696
- }
697
- // Only accept from same window (extension content script) or trusted parent frame
698
- if (event.source !== window && event.source !== window.parent) {
699
- return;
657
+ finally {
658
+ if (isInteractive) {
659
+ this._interactiveInFlight = false;
700
660
  }
701
- // Verify message structure
702
- if (!event.data || event.data.source !== '0xio-sdk-bridge') {
703
- return;
704
- }
705
- // Validate response has a pending request (prevents forged responses)
706
- if (event.data.response) {
707
- const response = event.data.response;
708
- if (response && response.id && this.pendingRequests.has(response.id)) {
709
- this.handleExtensionResponse(response);
661
+ }
662
+ }
663
+ setupMessageListener() {
664
+ if (typeof window === 'undefined')
665
+ return;
666
+ this._adapterTeardown = this.adapter.listen((msg) => {
667
+ if (msg.requestId !== undefined) {
668
+ // response map AdapterIncomingMessage ExtensionResponse shape
669
+ if (this.pendingRequests.has(msg.requestId)) {
670
+ this.handleExtensionResponse({
671
+ id: msg.requestId,
672
+ success: msg.success ?? false,
673
+ data: msg.data,
674
+ error: msg.error,
675
+ timestamp: Date.now(),
676
+ });
710
677
  }
711
678
  }
712
- else if (event.data.event) {
713
- this.handleExtensionEvent(event.data.event);
679
+ else if (msg.eventType) {
680
+ this.handleExtensionEvent({ type: msg.eventType, data: msg.eventData });
714
681
  }
715
- };
716
- window.addEventListener('message', this.messageListener);
717
- this.logger.log('Message listener setup complete');
682
+ }, { trustedParentOrigins: this.trustedOrigins });
683
+ this.logger.log(`Message listener setup complete (adapter: ${this.adapter.name})`);
718
684
  }
719
- /**
720
- * Handle extension event
721
- */
722
685
  handleExtensionEvent(event) {
686
+ if (!event?.type || !ExtensionCommunicator.VALID_EVENT_TYPES.has(event.type)) {
687
+ this.logger.warn(`Received unknown event type from bridge: ${event?.type}`);
688
+ return;
689
+ }
723
690
  this.logger.log('Received extension event:', event.type);
724
- // Forward the event to listeners
725
691
  this.emit(event.type, event.data);
726
692
  }
727
- /**
728
- * Handle response from extension
729
- */
730
693
  handleExtensionResponse(response) {
731
694
  this.logger.log(`Received response:`, { id: response.id, success: response.success });
732
695
  const pending = this.pendingRequests.get(response.id);
@@ -734,192 +697,134 @@ class ExtensionCommunicator extends EventEmitter {
734
697
  this.logger.warn(`Received response for unknown request ID: ${response.id}`);
735
698
  return;
736
699
  }
737
- // Clear timeout and remove from pending
738
700
  clearTimeout(pending.timeout);
739
701
  this.pendingRequests.delete(response.id);
740
- // Handle response
741
- if (response.success) {
702
+ // Require strict boolean true — "false" string or other truthy values are failures
703
+ if (response.success === true) {
742
704
  pending.resolve(response.data);
743
705
  }
744
706
  else {
745
707
  const error = response.error;
746
708
  if (error) {
747
- const enhancedError = new ZeroXIOWalletError(error.code, error.message, {
748
- ...error.details,
749
- requestId: response.id,
750
- retryCount: pending.retryCount,
751
- timestamp: Date.now(),
752
- extensionState: this.getExtensionDiagnostics()
753
- });
709
+ // Handle both object {code, message} and legacy plain-string error formats
710
+ const isObj = error !== null && typeof error === 'object';
711
+ const code = isObj && error.code ? error.code : ErrorCode.UNKNOWN_ERROR;
712
+ const message = isObj
713
+ ? (error.message ?? 'Unknown error')
714
+ : (typeof error === 'string' ? error : 'Unknown error');
715
+ const enhancedError = new ZeroXIOWalletError(code, message,
716
+ // Redact bridge-supplied error details; only keep non-sensitive metadata
717
+ { requestId: response.id, retryCount: pending.retryCount, timestamp: Date.now() });
754
718
  pending.reject(enhancedError);
755
719
  }
756
720
  else {
757
- pending.reject(new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Unknown error occurred', {
758
- requestId: response.id,
759
- retryCount: pending.retryCount,
760
- extensionState: this.getExtensionDiagnostics()
761
- }));
721
+ pending.reject(new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Unknown error occurred', { requestId: response.id, retryCount: pending.retryCount }));
762
722
  }
763
723
  }
764
724
  }
765
- /**
766
- * Post message to extension via content script
767
- */
768
725
  postMessageToExtension(request) {
769
- const msg = { source: '0xio-sdk-request', request };
770
- // Post to same window (extension content script picks it up)
771
- window.postMessage(msg, window.location.origin);
772
- // Also post to parent frame if in an iframe (desktop/mobile bridge)
773
- if (window.parent !== window) {
774
- // Use the parent origin we learned from the walletReady signal, or wildcard
775
- // Response validation is done by request ID matching, not origin
776
- const parentOrigin = this._parentOrigin || '*';
777
- try {
778
- window.parent.postMessage(msg, parentOrigin);
779
- }
780
- catch {
781
- // Fallback to wildcard if specific origin fails
782
- try {
783
- window.parent.postMessage(msg, '*');
784
- }
785
- catch { }
726
+ this.adapter.postRequest(request);
727
+ // Parent bridge (iframe/desktop mode) only when a trusted origin is established.
728
+ // Sending with '*' would leak method + params to any intercepting frame.
729
+ if (window.parent !== window && this._parentOrigin) {
730
+ if (this.adapter.postRequestToParent) {
731
+ this.adapter.postRequestToParent(request, this._parentOrigin);
786
732
  }
787
733
  }
788
734
  }
789
- /**
790
- * Check if we're in a context that can communicate with extension
791
- */
792
735
  hasExtensionContext() {
793
736
  return typeof window !== 'undefined' &&
794
737
  typeof window.postMessage === 'function';
795
738
  }
796
- /**
797
- * Check and enforce rate limits to prevent denial-of-service attacks
798
- *
799
- * @private
800
- * @throws {ZeroXIOWalletError} RATE_LIMIT_EXCEEDED if limits are exceeded
801
- *
802
- * @description
803
- * Implements two-tier rate limiting:
804
- * 1. Concurrent requests: Maximum 50 pending requests at once
805
- * 2. Request frequency: Maximum 20 requests per second
806
- *
807
- * Rate limiting protects both the SDK and extension from:
808
- * - Accidental infinite loops in dApp code
809
- * - Malicious DoS attacks
810
- * - Resource exhaustion
811
- *
812
- * @security Critical security function - enforces resource limits
813
- */
814
739
  checkRateLimit() {
815
740
  const now = Date.now();
816
- // ✅ SECURITY: Check concurrent request limit
817
741
  if (this.pendingRequests.size >= this.MAX_CONCURRENT_REQUESTS) {
818
742
  throw new ZeroXIOWalletError(ErrorCode.RATE_LIMIT_EXCEEDED, `Too many concurrent requests (max: ${this.MAX_CONCURRENT_REQUESTS})`);
819
743
  }
820
- // SECURITY: Check requests per time window
744
+ // Trim expired timestamps cap array size to prevent unbounded growth in idle tabs
821
745
  this.requestTimestamps = this.requestTimestamps.filter(t => now - t < this.RATE_LIMIT_WINDOW);
746
+ if (this.requestTimestamps.length > this.MAX_REQUESTS_PER_WINDOW) {
747
+ this.requestTimestamps = this.requestTimestamps.slice(-this.MAX_REQUESTS_PER_WINDOW);
748
+ }
822
749
  if (this.requestTimestamps.length >= this.MAX_REQUESTS_PER_WINDOW) {
823
750
  throw new ZeroXIOWalletError(ErrorCode.RATE_LIMIT_EXCEEDED, `Too many requests per second (max: ${this.MAX_REQUESTS_PER_WINDOW} per ${this.RATE_LIMIT_WINDOW}ms)`);
824
751
  }
825
752
  this.requestTimestamps.push(now);
826
753
  }
827
- /**
828
- * Generate cryptographically secure unique request ID
829
- *
830
- * @private
831
- * @returns {string} A unique, unpredictable request identifier
832
- *
833
- * @description
834
- * Uses Web Crypto API for secure random ID generation:
835
- * 1. Primary: crypto.randomUUID() - UUID v4 format
836
- * 2. Fallback: crypto.getRandomValues() - 128-bit random hex
837
- * 3. Last resort: timestamp + counter (logs warning)
838
- *
839
- * Security importance:
840
- * - Prevents request ID prediction attacks
841
- * - Mitigates replay attacks
842
- * - Makes session hijacking more difficult
843
- *
844
- * @security Critical - IDs must be cryptographically unpredictable
845
- */
846
754
  generateRequestId() {
847
- // ✅ SECURITY: Use crypto.randomUUID() for secure, unpredictable IDs
848
755
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
849
756
  return `0xio-sdk-${crypto.randomUUID()}`;
850
757
  }
851
- // Fallback to crypto.getRandomValues for older browsers
852
758
  if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
853
759
  const array = new Uint8Array(16);
854
760
  crypto.getRandomValues(array);
855
761
  const hex = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
856
762
  return `0xio-sdk-${hex}`;
857
763
  }
858
- // Last resort fallback (not recommended for production)
859
- this.logger.warn('Crypto API not available, using less secure ID generation');
860
- return `0xio-sdk-${++this.requestId}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
764
+ // Crypto API unavailable throw rather than produce a guessable ID that
765
+ // could allow response spoofing via a known requestId.
766
+ throw new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Cryptographic random number generation is not available in this environment');
861
767
  }
862
- /**
863
- * Start continuous extension detection
864
- */
865
768
  startExtensionDetection() {
866
769
  if (typeof window === 'undefined')
867
770
  return;
868
- // Listen for extension ready event (instant detection)
869
- window.addEventListener('0xioWalletReady', () => {
870
- this.logger.log('Received 0xioWalletReady event');
871
- this.isExtensionAvailableState = true;
872
- });
873
- // Also listen for legacy event names
874
- window.addEventListener('wallet0xioReady', () => {
875
- this.logger.log('Received wallet0xioReady event');
876
- this.isExtensionAvailableState = true;
877
- });
878
- window.addEventListener('octraWalletReady', () => {
879
- this.logger.log('Received octraWalletReady event');
880
- this.isExtensionAvailableState = true;
881
- });
882
- // Listen for desktop/mobile bridge walletReady via postMessage (with origin validation)
883
- window.addEventListener('message', (event) => {
884
- if (event.data?.source === '0xio-sdk-bridge' && event.data?.event?.type === 'walletReady') {
885
- const isSameOrigin = event.origin === window.location.origin;
886
- const isLocalhost = event.origin.startsWith('http://localhost:') || event.origin.startsWith('http://127.0.0.1:');
887
- const isTauri = event.origin === 'tauri://localhost' || event.origin === 'https://tauri.localhost';
888
- const isTrusted = this.trustedOrigins.includes(event.origin) || isTauri || isLocalhost;
889
- if (isSameOrigin || isTrusted) {
890
- this.logger.log('Received walletReady via postMessage from trusted origin');
891
- this.isExtensionAvailableState = true;
892
- // Capture parent origin for cross-origin reply targeting
893
- if (event.data.parentOrigin) {
894
- this._parentOrigin = event.data.parentOrigin;
895
- }
896
- else if (event.origin && event.origin !== 'null') {
897
- this._parentOrigin = event.origin;
898
- }
771
+ // Adapter-provided wallet-ready events (e.g. '0xioWalletReady', 'exampleWalletReady')
772
+ if (this.adapter.listenForReady) {
773
+ this._adapterReadyTeardown = this.adapter.listenForReady(() => {
774
+ this.logger.log(`Received wallet-ready event (adapter: ${this.adapter.name})`);
775
+ this.isExtensionAvailableState = true;
776
+ });
777
+ }
778
+ // walletReady via postMessage (desktop/mobile iframe bridge) — store ref for cleanup
779
+ this._walletReadyMessageListener = (event) => {
780
+ if (event.data?.source !== '0xio-sdk-bridge' || event.data?.event?.type !== 'walletReady') {
781
+ return;
782
+ }
783
+ const isSameOrigin = event.origin === window.location.origin;
784
+ const isTauri = event.origin === 'tauri://localhost' || event.origin === 'https://tauri.localhost';
785
+ const hasExplicitList = this.trustedOrigins.length > 0;
786
+ // When trustedParentOrigins is explicitly set, implicit localhost trust is disabled
787
+ const isLocalhostAllowed = !hasExplicitList &&
788
+ (event.origin.startsWith('http://localhost:') || event.origin.startsWith('http://127.0.0.1:'));
789
+ const isTrustedOrigin = this.trustedOrigins.includes(event.origin) || isTauri || isLocalhostAllowed;
790
+ // In iframe mode, only trust the actual parent window
791
+ const inIframe = window.parent !== window;
792
+ if (inIframe && event.source !== window.parent) {
793
+ this.logger.warn(`Ignored walletReady from non-parent source in iframe mode`);
794
+ return;
795
+ }
796
+ if (isSameOrigin || isTrustedOrigin) {
797
+ this.logger.log('Received walletReady via postMessage from trusted origin');
798
+ this.isExtensionAvailableState = true;
799
+ this._parentTrusted = true; // Mark parent-bridge readiness separately
800
+ if (event.data.parentOrigin) {
801
+ this._parentOrigin = event.data.parentOrigin;
899
802
  }
900
- else {
901
- this.logger.warn(`Ignored walletReady from untrusted origin: ${event.origin}`);
803
+ else if (event.origin && event.origin !== 'null') {
804
+ this._parentOrigin = event.origin;
902
805
  }
903
806
  }
904
- });
905
- // If running inside a frame, do NOT auto-trust the parent.
906
- // Wait for a walletReady message from a trusted origin instead.
807
+ else {
808
+ this.logger.warn(`Ignored walletReady from untrusted origin: ${event.origin}`);
809
+ }
810
+ };
811
+ window.addEventListener('message', this._walletReadyMessageListener);
907
812
  if (window.parent !== window) {
908
813
  this.logger.log('Running inside a frame — waiting for trusted walletReady signal');
909
814
  }
910
- // Initial check (in case extension was already injected)
911
815
  this.checkExtensionAvailability();
912
- // Set up periodic checks as fallback
913
816
  this.extensionDetectionInterval = setInterval(() => {
914
817
  this.checkExtensionAvailability();
915
818
  }, 2000);
916
819
  }
917
- /**
918
- * Check if extension is currently available
919
- */
920
820
  checkExtensionAvailability() {
821
+ // If parent-bridge readiness was established via a trusted walletReady handshake,
822
+ // preserve that state — the polling fallback (detectExtensionSignals) does not
823
+ // consider the iframe parent signal and would incorrectly flip state back
824
+ if (this._parentTrusted) {
825
+ return;
826
+ }
921
827
  const wasAvailable = this.isExtensionAvailableState;
922
- // Basic checks for extension context
923
828
  this.isExtensionAvailableState = this.hasExtensionContext() && this.detectExtensionSignals();
924
829
  if (!wasAvailable && this.isExtensionAvailableState) {
925
830
  this.logger.log('Extension became available');
@@ -928,25 +833,9 @@ class ExtensionCommunicator extends EventEmitter {
928
833
  this.logger.warn('Extension became unavailable');
929
834
  }
930
835
  }
931
- /**
932
- * Detect extension signals/indicators
933
- */
934
836
  detectExtensionSignals() {
935
- if (typeof window === 'undefined')
936
- return false;
937
- // Check for extension-injected indicators
938
- const win = window;
939
- // Look for extension-injected globals (matches injected.ts)
940
- return !!(win.wallet0xio ||
941
- win.ZeroXIOWallet ||
942
- win.octraWallet ||
943
- (win.chrome?.runtime?.id) ||
944
- document.querySelector('meta[name="0xio-dapp"]') ||
945
- document.querySelector('[data-0xio-sdk-bridge]'));
837
+ return this.adapter.detect();
946
838
  }
947
- /**
948
- * Wait for extension to become available
949
- */
950
839
  async waitForExtensionAvailability(timeoutMs) {
951
840
  if (this.isExtensionAvailableState) {
952
841
  return true;
@@ -954,14 +843,11 @@ class ExtensionCommunicator extends EventEmitter {
954
843
  return new Promise((resolve) => {
955
844
  let resolved = false;
956
845
  const startTime = Date.now();
957
- // Cleanup function
846
+ let adapterReadyTeardown = null;
958
847
  const cleanup = () => {
959
848
  clearInterval(checkInterval);
960
- window.removeEventListener('0xioWalletReady', onReady);
961
- window.removeEventListener('wallet0xioReady', onReady);
962
- window.removeEventListener('octraWalletReady', onReady);
849
+ adapterReadyTeardown?.();
963
850
  };
964
- // Event handler for instant resolution
965
851
  const onReady = () => {
966
852
  if (resolved)
967
853
  return;
@@ -970,11 +856,10 @@ class ExtensionCommunicator extends EventEmitter {
970
856
  cleanup();
971
857
  resolve(true);
972
858
  };
973
- // Listen for extension ready events
974
- window.addEventListener('0xioWalletReady', onReady);
975
- window.addEventListener('wallet0xioReady', onReady);
976
- window.addEventListener('octraWalletReady', onReady);
977
- // Polling fallback with shorter interval
859
+ // Use adapter's listenForReady for fast resolution; polling is the fallback
860
+ if (this.adapter.listenForReady) {
861
+ adapterReadyTeardown = this.adapter.listenForReady(onReady);
862
+ }
978
863
  const checkInterval = setInterval(() => {
979
864
  if (resolved)
980
865
  return;
@@ -992,9 +877,6 @@ class ExtensionCommunicator extends EventEmitter {
992
877
  }, 100);
993
878
  });
994
879
  }
995
- /**
996
- * Get browser diagnostics for error reporting
997
- */
998
880
  getBrowserDiagnostics() {
999
881
  if (typeof window === 'undefined') {
1000
882
  return { environment: 'non-browser' };
@@ -1006,29 +888,21 @@ class ExtensionCommunicator extends EventEmitter {
1006
888
  hasChromeRuntime: !!(win.chrome?.runtime),
1007
889
  hasPostMessage: typeof window.postMessage === 'function',
1008
890
  origin: window.location?.origin,
1009
- extensionDetection: {
1010
- hasWallet0xio: !!win.wallet0xio,
1011
- hasZeroXIOWallet: !!win.ZeroXIOWallet,
1012
- hasOctraWallet: !!win.octraWallet,
1013
- hasChromeRuntimeId: !!(win.chrome?.runtime?.id),
1014
- hasSdkBridge: !!document.querySelector('[data-0xio-sdk-bridge]')
1015
- }
1016
891
  };
1017
892
  }
1018
- /**
1019
- * Get extension state diagnostics
1020
- */
1021
893
  getExtensionDiagnostics() {
1022
894
  return {
1023
895
  initialized: this.isInitialized,
1024
896
  available: this.isExtensionAvailableState,
897
+ parentTrusted: this._parentTrusted,
1025
898
  pendingRequests: this.pendingRequests.size,
1026
899
  hasExtensionContext: this.hasExtensionContext(),
1027
- browserDiagnostics: this.getBrowserDiagnostics()
1028
900
  };
1029
901
  }
1030
902
  /**
1031
- * Cleanup pending requests
903
+ * Clean up SDK resources.
904
+ * After cleanup() the instance is terminal — do not call initialize() again.
905
+ * Construct a new instance instead.
1032
906
  */
1033
907
  cleanup() {
1034
908
  if (this.extensionDetectionInterval) {
@@ -1042,18 +916,22 @@ class ExtensionCommunicator extends EventEmitter {
1042
916
  this.pendingRequests.clear();
1043
917
  this.isInitialized = false;
1044
918
  this.isExtensionAvailableState = false;
1045
- // Remove message listener to prevent accumulation
1046
- if (this.messageListener) {
1047
- window.removeEventListener('message', this.messageListener);
1048
- this.messageListener = null;
919
+ this._parentTrusted = false;
920
+ this._parentOrigin = null;
921
+ this._interactiveInFlight = false;
922
+ // Tear down adapter listeners (response/event + wallet-ready)
923
+ this._adapterTeardown?.();
924
+ this._adapterTeardown = null;
925
+ this._adapterReadyTeardown?.();
926
+ this._adapterReadyTeardown = null;
927
+ // Remove walletReady postMessage listener (parent iframe bridge)
928
+ if (typeof window !== 'undefined' && this._walletReadyMessageListener) {
929
+ window.removeEventListener('message', this._walletReadyMessageListener);
930
+ this._walletReadyMessageListener = null;
1049
931
  }
1050
- // Call parent cleanup
1051
932
  this.removeAllListeners();
1052
933
  this.logger.log('Communication cleanup complete');
1053
934
  }
1054
- /**
1055
- * Get debug information
1056
- */
1057
935
  getDebugInfo() {
1058
936
  return {
1059
937
  initialized: this.isInitialized,
@@ -1064,23 +942,31 @@ class ExtensionCommunicator extends EventEmitter {
1064
942
  };
1065
943
  }
1066
944
  }
1067
- // Methods that trigger user-facing popups — NEVER retry these
945
+ // Methods that trigger user-facing popups — NEVER retry these.
1068
946
  // Retrying sends a second request while the first popup is still open,
1069
- // causing double popups where the second tx fails (stale nonce/state)
947
+ // causing double popups where the second tx fails (stale nonce/state).
1070
948
  ExtensionCommunicator.NO_RETRY_METHODS = new Set([
1071
949
  'connect', 'send_transaction', 'call_contract', 'signMessage',
950
+ 'sign_transaction', 'broadcast_only',
1072
951
  'send_private_transfer', 'claim_private_transfer',
1073
952
  'encrypt_balance', 'decrypt_balance',
1074
953
  ]);
954
+ ExtensionCommunicator.INTERACTIVE_METHODS = ExtensionCommunicator.NO_RETRY_METHODS;
955
+ // only forward known event types
956
+ ExtensionCommunicator.VALID_EVENT_TYPES = new Set([
957
+ 'connect', 'disconnect', 'accountChanged', 'balanceChanged',
958
+ 'networkChanged', 'transactionConfirmed', 'permissionsChanged', 'message',
959
+ 'error', 'extensionLocked', 'extensionUnlocked'
960
+ ]);
1075
961
 
1076
962
  /**
1077
963
  * Network configuration for 0xio SDK
1078
964
  */
1079
- const NETWORKS = {
965
+ const _NETWORKS = {
1080
966
  'mainnet': {
1081
967
  id: 'mainnet',
1082
968
  name: 'Octra Mainnet',
1083
- rpcUrl: 'http://46.101.86.250:8080',
969
+ rpcUrl: 'https://octra.network',
1084
970
  explorerUrl: 'https://lite.octrascan.io/tx.html?hash=',
1085
971
  explorerAddressUrl: 'https://lite.octrascan.io/address.html?addr=',
1086
972
  indexerUrl: 'https://lite.octrascan.io',
@@ -1111,70 +997,125 @@ const NETWORKS = {
1111
997
  isTestnet: false
1112
998
  }
1113
999
  };
1000
+ /**
1001
+ * Immutable public copy of the built-in network table.
1002
+ * Modifications to returned objects do not affect SDK-internal state.
1003
+ */
1004
+ const NETWORKS = Object.freeze(Object.fromEntries(Object.entries(_NETWORKS).map(([k, v]) => [k, Object.freeze({ ...v })])));
1114
1005
  const DEFAULT_NETWORK_ID = 'mainnet';
1115
1006
  /**
1116
- * Get network configuration by ID
1007
+ * Get network configuration by ID.
1008
+ * Returns a frozen copy — callers cannot mutate SDK-internal state.
1117
1009
  */
1118
1010
  function getNetworkConfig(networkId = DEFAULT_NETWORK_ID) {
1119
- const network = NETWORKS[networkId];
1120
- if (!network) {
1011
+ if (!Object.prototype.hasOwnProperty.call(_NETWORKS, networkId)) {
1121
1012
  throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, `Unknown network ID: ${networkId}`);
1122
1013
  }
1123
- return network;
1014
+ return Object.freeze({ ..._NETWORKS[networkId] });
1124
1015
  }
1125
1016
  /**
1126
- * Get all available networks
1017
+ * Get all available networks.
1018
+ * Returns frozen copies — callers cannot mutate SDK-internal state.
1127
1019
  */
1128
1020
  function getAllNetworks() {
1129
- return Object.values(NETWORKS);
1021
+ return Object.values(_NETWORKS).map(n => Object.freeze({ ...n }));
1130
1022
  }
1131
1023
  /**
1132
- * Check if network ID is valid
1024
+ * Check if network ID is valid (own property check, prevents prototype pollution).
1133
1025
  */
1134
1026
  function isValidNetworkId(networkId) {
1135
- return networkId in NETWORKS;
1027
+ return typeof networkId === 'string' && Object.prototype.hasOwnProperty.call(_NETWORKS, networkId);
1136
1028
  }
1137
-
1138
1029
  /**
1139
- * SDK Configuration
1030
+ * Validate a NetworkInfo shape from an untrusted source (bridge response).
1031
+ * Returns a frozen copy if valid, null otherwise.
1140
1032
  */
1033
+ function validateNetworkInfo(raw) {
1034
+ if (!raw || typeof raw !== 'object')
1035
+ return null;
1036
+ if (typeof raw.id !== 'string' || !raw.id)
1037
+ return null;
1038
+ if (typeof raw.name !== 'string')
1039
+ return null;
1040
+ if (typeof raw.rpcUrl !== 'string' || (!raw.rpcUrl && raw.id !== 'custom'))
1041
+ return null;
1042
+ if (raw.rpcUrl) {
1043
+ const isHttps = raw.rpcUrl.startsWith('https://');
1044
+ const isLocal = raw.rpcUrl.startsWith('http://localhost') || raw.rpcUrl.startsWith('http://127.0.0.1');
1045
+ const isTestnet = raw.isTestnet === true;
1046
+ // Reject plain-http RPC URLs from untrusted sources unless testnet-flagged or local
1047
+ if (!isHttps && !isLocal && !isTestnet)
1048
+ return null;
1049
+ }
1050
+ if (typeof raw.supportsPrivacy !== 'boolean')
1051
+ return null;
1052
+ return Object.freeze({
1053
+ id: raw.id,
1054
+ name: raw.name,
1055
+ rpcUrl: raw.rpcUrl,
1056
+ explorerUrl: typeof raw.explorerUrl === 'string' ? raw.explorerUrl : undefined,
1057
+ explorerAddressUrl: typeof raw.explorerAddressUrl === 'string' ? raw.explorerAddressUrl : undefined,
1058
+ indexerUrl: typeof raw.indexerUrl === 'string' ? raw.indexerUrl : undefined,
1059
+ supportsPrivacy: raw.supportsPrivacy,
1060
+ color: typeof raw.color === 'string' ? raw.color : '#64748b',
1061
+ isTestnet: typeof raw.isTestnet === 'boolean' ? raw.isTestnet : false,
1062
+ });
1063
+ }
1064
+
1141
1065
  /**
1142
- * Default balance structure
1066
+ * Default balance structure.
1067
+ * Accepts a numeric total or undefined — never pass a Balance object here.
1143
1068
  */
1144
1069
  function createDefaultBalance(total = 0) {
1070
+ const safeTotal = typeof total === 'number' && Number.isFinite(total) && total >= 0 ? total : 0;
1145
1071
  return {
1146
- total,
1147
- public: total,
1072
+ total: safeTotal,
1073
+ public: safeTotal,
1148
1074
  private: 0,
1149
1075
  currency: 'OCT'
1150
1076
  };
1151
1077
  }
1152
1078
  /**
1153
- * SDK Configuration constants
1079
+ * Validate and normalise a Balance from an untrusted source (bridge response).
1080
+ * Returns null if the payload cannot be coerced into a valid Balance.
1154
1081
  */
1082
+ function validateBalance(raw) {
1083
+ if (raw === null || raw === undefined)
1084
+ return null;
1085
+ // If it's already a Balance-shaped object, extract numeric fields
1086
+ // Use Number() not parseFloat() — parseFloat('10abc') silently returns 10
1087
+ const pub = typeof raw === 'object' ? Number(raw.public ?? raw.total ?? 0) : Number(raw);
1088
+ const priv = typeof raw === 'object' ? Number(raw.private ?? 0) : 0;
1089
+ if (!Number.isFinite(pub) || pub < 0)
1090
+ return null;
1091
+ if (!Number.isFinite(priv) || priv < 0)
1092
+ return null;
1093
+ return {
1094
+ public: pub,
1095
+ private: priv,
1096
+ total: pub + priv,
1097
+ currency: 'OCT'
1098
+ };
1099
+ }
1155
1100
  const SDK_CONFIG = {
1156
- version: '2.6.0',
1101
+ version: '2.7.1',
1157
1102
  defaultNetworkId: DEFAULT_NETWORK_ID,
1158
1103
  communicationTimeout: 30000, // 30 seconds
1159
1104
  retryAttempts: 3,
1160
1105
  retryDelay: 1000, // 1 second
1161
1106
  };
1162
- /**
1163
- * Get default network configuration
1164
- */
1165
1107
  function getDefaultNetwork() {
1166
1108
  return getNetworkConfig(SDK_CONFIG.defaultNetworkId);
1167
1109
  }
1168
1110
 
1169
- /**
1170
- * 0xio Wallet SDK - Main Wallet Class
1171
- * Primary interface for DApp developers to interact with 0xio Wallet
1172
- */
1173
1111
  class ZeroXIOWallet extends EventEmitter {
1174
1112
  constructor(config) {
1175
1113
  super(config.debug);
1176
1114
  this.connectionInfo = { isConnected: false };
1177
1115
  this.isInitialized = false;
1116
+ this._initPromise = null;
1117
+ // session version — stale write detection
1118
+ this._sessionVersion = 0;
1178
1119
  this.config = {
1179
1120
  ...config,
1180
1121
  appVersion: config.appVersion || '1.0.0',
@@ -1182,91 +1123,107 @@ class ZeroXIOWallet extends EventEmitter {
1182
1123
  debug: config.debug || false
1183
1124
  };
1184
1125
  this.logger = createLogger('ZeroXIOWallet', this.config.debug || false);
1185
- this.communicator = new ExtensionCommunicator(this.config.debug);
1126
+ this.communicator = new ExtensionCommunicator(this.config.debug, this.config.trustedParentOrigins ?? [], this.config.adapter);
1186
1127
  this.logger.log('Wallet instance created with config:', this.config);
1187
1128
  }
1188
- // ===================
1189
- // INITIALIZATION
1190
- // ===================
1191
- /**
1192
- * Initialize the SDK
1193
- * Must be called before using any other methods
1194
- */
1195
1129
  async initialize() {
1196
1130
  if (this.isInitialized) {
1197
1131
  return true;
1198
1132
  }
1199
- try {
1200
- // Initialize extension communication
1201
- const communicationReady = await this.communicator.initialize();
1202
- if (!communicationReady) {
1203
- throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, 'Failed to establish communication with 0xio Wallet extension');
1204
- }
1205
- // Register this DApp with the extension
1206
- await this.communicator.sendRequest('register_dapp', {
1207
- appName: this.config.appName,
1208
- appDescription: this.config.appDescription,
1209
- appVersion: this.config.appVersion,
1210
- appUrl: typeof window !== 'undefined' ? window.location.origin : undefined,
1211
- appIcon: this.config.appIcon,
1212
- requiredPermissions: this.config.requiredPermissions,
1213
- networkId: this.config.networkId
1214
- });
1215
- // Setup event forwarding from extension
1216
- this.setupExtensionEventListeners();
1217
- this.isInitialized = true;
1218
- this.logger.log('SDK initialized successfully');
1219
- return true;
1133
+ if (this._initPromise) {
1134
+ return this._initPromise;
1220
1135
  }
1221
- catch (error) {
1222
- this.logger.error('Failed to initialize:', error);
1223
- if (error instanceof ZeroXIOWalletError) {
1224
- throw error;
1136
+ this._initPromise = (async () => {
1137
+ try {
1138
+ const communicationReady = await this.communicator.initialize();
1139
+ if (!communicationReady) {
1140
+ throw new ZeroXIOWalletError(ErrorCode.EXTENSION_NOT_FOUND, 'Failed to establish communication with 0xio Wallet extension');
1141
+ }
1142
+ await this.communicator.sendRequest('register_dapp', {
1143
+ appName: this.config.appName,
1144
+ appDescription: this.config.appDescription,
1145
+ appVersion: this.config.appVersion,
1146
+ appUrl: typeof window !== 'undefined' ? window.location.origin : undefined,
1147
+ appIcon: this.config.appIcon,
1148
+ requiredPermissions: this.config.requiredPermissions,
1149
+ networkId: this.config.networkId
1150
+ });
1151
+ this.setupExtensionEventListeners();
1152
+ this.isInitialized = true;
1153
+ this.logger.log('SDK initialized successfully');
1154
+ return true;
1225
1155
  }
1226
- throw new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Failed to initialize SDK', error);
1227
- }
1156
+ catch (error) {
1157
+ this.logger.error('Failed to initialize:', error);
1158
+ if (error instanceof ZeroXIOWalletError) {
1159
+ throw error;
1160
+ }
1161
+ throw new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Failed to initialize SDK', error);
1162
+ }
1163
+ finally {
1164
+ this._initPromise = null;
1165
+ }
1166
+ })();
1167
+ return this._initPromise;
1228
1168
  }
1229
- /**
1230
- * Check if SDK is initialized
1231
- */
1232
1169
  isReady() {
1233
1170
  return this.isInitialized && this.communicator.isExtensionAvailable();
1234
1171
  }
1235
- // ===================
1236
- // CONNECTION MANAGEMENT
1237
- // ===================
1238
- /**
1239
- * Connect to wallet
1240
- */
1241
1172
  async connect(options = {}) {
1242
1173
  this.ensureInitialized();
1243
1174
  try {
1244
1175
  this.logger.log('Attempting to connect with options:', options);
1176
+ // filter to declared perms only — accept both RFC 'permissions' and legacy 'requestPermissions'
1177
+ const declaredPermissions = this.config.requiredPermissions || [];
1178
+ const requestedPerms = options.permissions ?? options.requestPermissions;
1179
+ const requestedPermissions = requestedPerms
1180
+ ? requestedPerms.filter(p => declaredPermissions.includes(p))
1181
+ : declaredPermissions;
1245
1182
  const result = await this.communicator.sendRequest('connect', {
1246
- requestPermissions: options.requestPermissions || this.config.requiredPermissions,
1183
+ permissions: requestedPermissions,
1247
1184
  networkId: options.networkId || this.config.networkId
1248
1185
  });
1249
- // Use networkInfo from extension response, fallback to config-based
1250
- const networkInfo = result.networkInfo || getNetworkConfig(result.networkId || this.config.networkId);
1251
- // Update connection info
1186
+ // verify pubkey→addr binding
1187
+ if (result.publicKey && result.address) {
1188
+ try {
1189
+ const derived = await deriveOctraAddress(result.publicKey);
1190
+ if (derived !== result.address) {
1191
+ throw new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'Address-key binding verification failed — the reported public key does not derive to the reported address');
1192
+ }
1193
+ }
1194
+ catch (e) {
1195
+ if (e instanceof ZeroXIOWalletError)
1196
+ throw e;
1197
+ this.logger.warn('Address derivation check skipped (crypto unavailable):', e);
1198
+ }
1199
+ }
1200
+ // Use networkInfo from extension response — validate before caching.
1201
+ const networkInfo = validateNetworkInfo(result.networkInfo)
1202
+ ?? (result.networkId ? getNetworkConfig(result.networkId) : null);
1203
+ if (!networkInfo) {
1204
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Wallet did not return valid network metadata.');
1205
+ }
1206
+ const permissions = result.permissions || [];
1207
+ // Update connection info — including permissions
1252
1208
  this.connectionInfo = {
1253
1209
  isConnected: true,
1254
1210
  address: result.address,
1255
1211
  publicKey: result.publicKey,
1256
1212
  balance: result.balance,
1257
1213
  networkInfo,
1258
- connectedAt: Date.now()
1214
+ connectedAt: Date.now(),
1215
+ permissions
1259
1216
  };
1260
1217
  const connectEvent = {
1261
1218
  address: result.address,
1262
1219
  publicKey: result.publicKey,
1263
1220
  balance: result.balance,
1264
1221
  networkInfo,
1265
- permissions: result.permissions || []
1222
+ permissions
1266
1223
  };
1267
1224
  // Emit connect event
1268
1225
  this.emit('connect', connectEvent);
1269
- this.logger.log('Connected successfully:', connectEvent);
1226
+ this.logger.log('Connected successfully:', { address: connectEvent.address, network: networkInfo.id });
1270
1227
  return connectEvent;
1271
1228
  }
1272
1229
  catch (error) {
@@ -1284,6 +1241,7 @@ class ZeroXIOWallet extends EventEmitter {
1284
1241
  this.ensureInitialized();
1285
1242
  try {
1286
1243
  await this.communicator.sendRequest('disconnect');
1244
+ ++this._sessionVersion;
1287
1245
  this.connectionInfo = { isConnected: false };
1288
1246
  const disconnectEvent = {
1289
1247
  reason: 'user_action'
@@ -1314,30 +1272,59 @@ class ZeroXIOWallet extends EventEmitter {
1314
1272
  async getConnectionStatus() {
1315
1273
  this.ensureInitialized();
1316
1274
  try {
1275
+ const sv = this._sessionVersion;
1317
1276
  const result = await this.communicator.sendRequest('getConnectionStatus');
1277
+ // skip if session changed mid-flight
1278
+ if (this._sessionVersion !== sv)
1279
+ return { ...this.connectionInfo };
1318
1280
  if (result.isConnected && result.address) {
1319
- // Update internal state if we discover an existing connection
1320
- const balanceInfo = createDefaultBalance(result.balance);
1321
- // Use network from extension response (actual active network), fallback to config
1322
- const activeNetworkId = result.networkId || this.config.networkId;
1323
- const networkInfo = getNetworkConfig(activeNetworkId);
1281
+ // verify pubkey→addr binding
1282
+ if (result.publicKey) {
1283
+ try {
1284
+ const derived = await deriveOctraAddress(result.publicKey);
1285
+ if (derived !== result.address) {
1286
+ this.logger.warn('Address-key binding mismatch on session restore — ignoring stale session');
1287
+ this.connectionInfo = { isConnected: false };
1288
+ return { ...this.connectionInfo };
1289
+ }
1290
+ }
1291
+ catch (e) {
1292
+ this.logger.warn('Address derivation check skipped (crypto unavailable):', e);
1293
+ }
1294
+ }
1295
+ // validate untrusted balance/networkInfo before caching
1296
+ const balanceInfo = validateBalance(result.balance) ?? createDefaultBalance();
1297
+ const networkInfo = validateNetworkInfo(result.networkInfo)
1298
+ ?? (result.networkId ? getNetworkConfig(result.networkId) : null);
1299
+ if (!networkInfo) {
1300
+ this.logger.warn('getConnectionStatus: wallet returned no network metadata — returning cached state');
1301
+ return this.connectionInfo;
1302
+ }
1303
+ const wasConnected = this.connectionInfo.isConnected;
1304
+ const permissions = result.permissions || [];
1305
+ // preserve existing connectedAt
1306
+ const connectedAt = this.connectionInfo.connectedAt || result.connectedAt || Date.now();
1324
1307
  this.connectionInfo = {
1325
1308
  isConnected: true,
1326
1309
  address: result.address,
1327
1310
  publicKey: result.publicKey,
1328
1311
  balance: balanceInfo,
1329
1312
  networkInfo,
1330
- connectedAt: result.connectedAt || Date.now()
1331
- };
1332
- this.logger.log('Discovered existing connection:', this.connectionInfo);
1333
- // Emit connect event to notify the wrapper
1334
- const connectEvent = {
1335
- address: result.address,
1336
- balance: balanceInfo,
1337
- networkInfo,
1338
- permissions: result.permissions || []
1313
+ connectedAt,
1314
+ permissions
1339
1315
  };
1340
- this.emit('connect', connectEvent);
1316
+ this.logger.log('Discovered existing connection:', { address: result.address, network: networkInfo.id });
1317
+ // only emit on disconnected→connected transition
1318
+ if (!wasConnected) {
1319
+ const connectEvent = {
1320
+ address: result.address,
1321
+ publicKey: result.publicKey,
1322
+ balance: balanceInfo,
1323
+ networkInfo,
1324
+ permissions
1325
+ };
1326
+ this.emit('connect', connectEvent);
1327
+ }
1341
1328
  }
1342
1329
  else {
1343
1330
  // No existing connection
@@ -1357,10 +1344,14 @@ class ZeroXIOWallet extends EventEmitter {
1357
1344
  * The extension broadcasts 'networkChanged' event to all connected dApps.
1358
1345
  */
1359
1346
  async switchNetwork(networkId) {
1360
- this.ensureInitialized();
1347
+ this.ensureConnected();
1361
1348
  try {
1349
+ const sv = this._sessionVersion;
1362
1350
  const result = await this.communicator.sendRequest('switch_network', { networkId });
1363
1351
  this.logger.log(`Network switch result:`, result);
1352
+ // skip if session changed mid-flight
1353
+ if (this._sessionVersion !== sv)
1354
+ return { network: result.network || networkId, switched: result.switched ?? false };
1364
1355
  if (result.switched) {
1365
1356
  // Update internal state
1366
1357
  const networkInfo = getNetworkConfig(networkId);
@@ -1381,18 +1372,9 @@ class ZeroXIOWallet extends EventEmitter {
1381
1372
  getNetworkId() {
1382
1373
  return this.connectionInfo.networkInfo?.id || null;
1383
1374
  }
1384
- // ===================
1385
- // WALLET INFORMATION
1386
- // ===================
1387
- /**
1388
- * Get current wallet address
1389
- */
1390
1375
  getAddress() {
1391
1376
  return this.connectionInfo.address || null;
1392
1377
  }
1393
- /**
1394
- * Get current balance
1395
- */
1396
1378
  async getBalance(forceRefresh = false) {
1397
1379
  this.ensureConnected();
1398
1380
  try {
@@ -1403,22 +1385,27 @@ class ZeroXIOWallet extends EventEmitter {
1403
1385
  // Fetch balance from extension (bypasses CORS, has access to private balance)
1404
1386
  let publicBalance = 0;
1405
1387
  let privateBalance = 0;
1388
+ const sv = this._sessionVersion;
1406
1389
  const extResult = await this.communicator.sendRequest('getBalance', { forceRefresh });
1407
1390
  publicBalance = parseFloat(extResult.balance || '0');
1408
1391
  privateBalance = parseFloat(extResult.privateBalance || '0');
1409
- this.logger.log('Balance fetched from extension:', { public: publicBalance, private: privateBalance });
1392
+ this.logger.log('Balance fetched from extension:', { public: publicBalance });
1410
1393
  const result = {
1411
1394
  public: publicBalance,
1412
1395
  private: privateBalance,
1413
1396
  total: publicBalance + privateBalance,
1414
1397
  currency: 'OCT'
1415
1398
  };
1416
- // Update cached balance
1399
+ // skip if session changed mid-flight
1400
+ if (this._sessionVersion !== sv)
1401
+ return result;
1417
1402
  if (this.connectionInfo.balance) {
1418
1403
  const previousBalance = this.connectionInfo.balance;
1419
1404
  this.connectionInfo.balance = result;
1420
- // Emit balance changed event if different
1421
- if (previousBalance.total !== result.total) {
1405
+ // emit on total or pub/priv split change
1406
+ if (previousBalance.total !== result.total ||
1407
+ previousBalance.public !== result.public ||
1408
+ previousBalance.private !== result.private) {
1422
1409
  const balanceChangedEvent = {
1423
1410
  address: this.connectionInfo.address,
1424
1411
  previousBalance,
@@ -1433,52 +1420,63 @@ class ZeroXIOWallet extends EventEmitter {
1433
1420
  return result;
1434
1421
  }
1435
1422
  catch (error) {
1423
+ if (error instanceof ZeroXIOWalletError)
1424
+ throw error;
1436
1425
  throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Failed to get balance', error);
1437
1426
  }
1438
1427
  }
1439
- /**
1440
- * Get network information
1441
- */
1442
1428
  async getNetworkInfo() {
1443
1429
  this.ensureInitialized();
1444
1430
  try {
1431
+ const sv = this._sessionVersion;
1445
1432
  const result = await this.communicator.sendRequest('get_network_info');
1446
- // Update cached network info
1433
+ const networkInfo = validateNetworkInfo(result);
1434
+ if (!networkInfo) {
1435
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Extension returned invalid network info');
1436
+ }
1437
+ // skip if session changed mid-flight
1438
+ if (this._sessionVersion !== sv)
1439
+ return networkInfo;
1447
1440
  if (this.connectionInfo.networkInfo) {
1448
1441
  const previousNetwork = this.connectionInfo.networkInfo;
1449
- this.connectionInfo.networkInfo = result;
1450
- // Emit network changed event if different
1451
- if (previousNetwork.id !== result.id) {
1442
+ this.connectionInfo.networkInfo = networkInfo;
1443
+ if (previousNetwork.id !== networkInfo.id) {
1452
1444
  const networkChangedEvent = {
1453
1445
  previousNetwork,
1454
- newNetwork: result
1446
+ newNetwork: networkInfo
1455
1447
  };
1456
1448
  this.emit('networkChanged', networkChangedEvent);
1457
1449
  }
1458
1450
  }
1459
1451
  else {
1460
- this.connectionInfo.networkInfo = result;
1452
+ this.connectionInfo.networkInfo = networkInfo;
1461
1453
  }
1462
- return result;
1454
+ return networkInfo;
1463
1455
  }
1464
1456
  catch (error) {
1457
+ if (error instanceof ZeroXIOWalletError)
1458
+ throw error;
1465
1459
  throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Failed to get network info', error);
1466
1460
  }
1467
1461
  }
1468
- // ===================
1469
- // TRANSACTIONS
1470
- // ===================
1471
- /**
1472
- * Send transaction
1473
- */
1474
1462
  async sendTransaction(txData) {
1475
1463
  this.ensureConnected();
1464
+ if (!isValidAddress(txData.to)) {
1465
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid recipient address');
1466
+ }
1467
+ if (!isValidAmount(txData.amount)) {
1468
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid transaction amount');
1469
+ }
1470
+ if (txData.message && txData.message.length > 1000) {
1471
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Transaction message too long (max 1,000 characters)');
1472
+ }
1476
1473
  try {
1477
- this.logger.log('Sending transaction:', txData);
1474
+ // log non-sensitive only
1475
+ this.logger.log('Sending transaction:', { to: txData.to });
1478
1476
  const result = await this.communicator.sendRequest('send_transaction', txData);
1479
1477
  this.logger.log('Transaction result:', result);
1480
- // Refresh balance after successful transaction
1481
- if (result.success) {
1478
+ // Refresh balance after successful transaction (accept RFC 'accepted' or legacy 'success')
1479
+ if (result.accepted ?? result.success) {
1482
1480
  setTimeout(() => {
1483
1481
  this.getBalance(true).catch(error => {
1484
1482
  this.logger.warn('Failed to refresh balance after transaction:', error);
@@ -1495,14 +1493,83 @@ class ZeroXIOWallet extends EventEmitter {
1495
1493
  throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Failed to send transaction', error);
1496
1494
  }
1497
1495
  }
1496
+ /**
1497
+ * Sign a transaction without broadcasting it (RFC-O-1 octra_signTransaction).
1498
+ * Returns the signed transaction object for manual submission via submitTransaction().
1499
+ */
1500
+ async signTransaction(txData) {
1501
+ this.ensureConnected();
1502
+ if (!isValidAddress(txData.to)) {
1503
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid recipient address');
1504
+ }
1505
+ if (!isValidAmount(txData.amount)) {
1506
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid transaction amount');
1507
+ }
1508
+ if (txData.message && txData.message.length > 1000) {
1509
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Transaction message too long (max 1,000 characters)');
1510
+ }
1511
+ try {
1512
+ this.logger.log('Requesting transaction signature:', { to: txData.to });
1513
+ const result = await this.communicator.sendRequest('sign_transaction', txData);
1514
+ return result;
1515
+ }
1516
+ catch (error) {
1517
+ if (error instanceof ZeroXIOWalletError)
1518
+ throw error;
1519
+ throw new ZeroXIOWalletError(ErrorCode.SIGNATURE_FAILED, 'Failed to sign transaction', error);
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Broadcast a pre-signed transaction (RFC-O-1 octra_submitTransaction).
1524
+ * Use after signTransaction() to submit the signed tx to the network.
1525
+ */
1526
+ async submitTransaction(signedTx) {
1527
+ this.ensureConnected();
1528
+ if (!signedTx || typeof signedTx !== 'object') {
1529
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'signedTx must be an object');
1530
+ }
1531
+ try {
1532
+ this.logger.log('Submitting pre-signed transaction');
1533
+ const result = await this.communicator.sendRequest('broadcast_only', { signedTx });
1534
+ return result;
1535
+ }
1536
+ catch (error) {
1537
+ if (error instanceof ZeroXIOWalletError)
1538
+ throw error;
1539
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Failed to submit transaction', error);
1540
+ }
1541
+ }
1498
1542
  /**
1499
1543
  * Call a smart contract method (state-changing).
1500
1544
  * The extension builds, signs, and submits the transaction via octra_submit.
1501
1545
  */
1502
1546
  async callContract(callData) {
1503
1547
  this.ensureConnected();
1548
+ if (!isValidAddress(callData.contract)) {
1549
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid contract address');
1550
+ }
1551
+ if (!callData.method || typeof callData.method !== 'string') {
1552
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Contract method is required');
1553
+ }
1554
+ if (callData.method.length > 200) {
1555
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Contract method name too long (max 200 characters)');
1556
+ }
1557
+ if (callData.amount != null) {
1558
+ this.assertExactOCTAmount(callData.amount, 'Contract call amount');
1559
+ }
1560
+ try {
1561
+ if (JSON.stringify(callData.params).length > 65536) {
1562
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Contract params too large (max 64 KB)');
1563
+ }
1564
+ }
1565
+ catch (e) {
1566
+ if (e instanceof ZeroXIOWalletError)
1567
+ throw e;
1568
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Contract params are not serialisable');
1569
+ }
1504
1570
  try {
1505
- this.logger.log('Calling contract:', callData);
1571
+ // log non-sensitive only
1572
+ this.logger.log('Calling contract:', { contract: callData.contract, method: callData.method });
1506
1573
  const result = await this.communicator.sendRequest('call_contract', {
1507
1574
  contract: callData.contract,
1508
1575
  method: callData.method,
@@ -1527,13 +1594,34 @@ class ZeroXIOWallet extends EventEmitter {
1527
1594
  */
1528
1595
  async contractCallView(viewData) {
1529
1596
  this.ensureInitialized();
1597
+ if (!isValidAddress(viewData.contract)) {
1598
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid contract address');
1599
+ }
1600
+ if (!viewData.method || typeof viewData.method !== 'string') {
1601
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Contract method is required');
1602
+ }
1603
+ if (viewData.method.length > 200) {
1604
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Contract method name too long (max 200 characters)');
1605
+ }
1606
+ try {
1607
+ if (JSON.stringify(viewData.params).length > 65536) {
1608
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Contract params too large (max 64 KB)');
1609
+ }
1610
+ }
1611
+ catch (e) {
1612
+ if (e instanceof ZeroXIOWalletError)
1613
+ throw e;
1614
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Contract params are not serialisable');
1615
+ }
1530
1616
  try {
1531
- this.logger.log('Contract view call:', viewData);
1617
+ // log non-sensitive only
1618
+ this.logger.log('Contract view call:', { contract: viewData.contract, method: viewData.method });
1532
1619
  const result = await this.communicator.sendRequest('contract_call_view', {
1533
1620
  contract: viewData.contract,
1534
1621
  method: viewData.method,
1535
1622
  params: viewData.params,
1536
- caller: viewData.caller || this.getAddress() || '',
1623
+ // only include caller if explicit
1624
+ ...(viewData.caller != null ? { caller: viewData.caller } : {}),
1537
1625
  });
1538
1626
  this.logger.log('Contract view result:', result);
1539
1627
  return result;
@@ -1551,6 +1639,15 @@ class ZeroXIOWallet extends EventEmitter {
1551
1639
  */
1552
1640
  async getContractStorage(contract, key) {
1553
1641
  this.ensureInitialized();
1642
+ if (!isValidAddress(contract)) {
1643
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid contract address');
1644
+ }
1645
+ if (!key || typeof key !== 'string') {
1646
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Storage key is required');
1647
+ }
1648
+ if (key.length > 200) {
1649
+ throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Storage key too long (max 200 characters)');
1650
+ }
1554
1651
  try {
1555
1652
  this.logger.log('Getting contract storage:', { contract, key });
1556
1653
  const result = await this.communicator.sendRequest('get_contract_storage', {
@@ -1583,12 +1680,6 @@ class ZeroXIOWallet extends EventEmitter {
1583
1680
  throw new ZeroXIOWalletError(ErrorCode.NETWORK_ERROR, 'Failed to get transaction history', error);
1584
1681
  }
1585
1682
  }
1586
- // ===================
1587
- // PRIVATE FEATURES
1588
- // ===================
1589
- /**
1590
- * Get private balance information
1591
- */
1592
1683
  async getPrivateBalanceInfo() {
1593
1684
  this.ensureConnected();
1594
1685
  try {
@@ -1604,13 +1695,17 @@ class ZeroXIOWallet extends EventEmitter {
1604
1695
  */
1605
1696
  async encryptBalance(amount) {
1606
1697
  this.ensureConnected();
1698
+ this.assertExactOCTAmount(amount, 'Encrypt amount');
1699
+ if (!isValidAmount(amount)) {
1700
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid amount');
1701
+ }
1607
1702
  try {
1608
1703
  const result = await this.communicator.sendRequest('encrypt_balance', { amount });
1609
1704
  // Refresh balance after encryption
1610
1705
  setTimeout(() => {
1611
1706
  this.getBalance(true).catch(() => { });
1612
1707
  }, 1000);
1613
- return result.success;
1708
+ return result;
1614
1709
  }
1615
1710
  catch (error) {
1616
1711
  throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Failed to encrypt balance', error);
@@ -1621,13 +1716,17 @@ class ZeroXIOWallet extends EventEmitter {
1621
1716
  */
1622
1717
  async decryptBalance(amount) {
1623
1718
  this.ensureConnected();
1719
+ this.assertExactOCTAmount(amount, 'Decrypt amount');
1720
+ if (!isValidAmount(amount)) {
1721
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid amount');
1722
+ }
1624
1723
  try {
1625
1724
  const result = await this.communicator.sendRequest('decrypt_balance', { amount });
1626
1725
  // Refresh balance after decryption
1627
1726
  setTimeout(() => {
1628
1727
  this.getBalance(true).catch(() => { });
1629
1728
  }, 1000);
1630
- return result.success;
1729
+ return result;
1631
1730
  }
1632
1731
  catch (error) {
1633
1732
  throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Failed to decrypt balance', error);
@@ -1643,10 +1742,21 @@ class ZeroXIOWallet extends EventEmitter {
1643
1742
  */
1644
1743
  async sendPrivateTransfer(transferData) {
1645
1744
  this.ensureConnected();
1745
+ if (!isValidAddress(transferData.to)) {
1746
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_ADDRESS, 'Invalid recipient address');
1747
+ }
1748
+ if (!isValidAmount(transferData.amount)) {
1749
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid transfer amount');
1750
+ }
1751
+ this.assertExactOCTAmount(transferData.amount, 'Transfer amount');
1752
+ // bound msg size
1753
+ if (transferData.message && transferData.message.length > 1000) {
1754
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Transfer message too long (max 1,000 characters)');
1755
+ }
1646
1756
  try {
1647
1757
  const result = await this.communicator.sendRequest('send_private_transfer', transferData);
1648
- // Refresh balance after transfer
1649
- if (result.success) {
1758
+ // Refresh balance after transfer (accept RFC 'accepted' or legacy 'success')
1759
+ if (result.accepted ?? result.success) {
1650
1760
  setTimeout(() => {
1651
1761
  this.getBalance(true).catch(() => { });
1652
1762
  }, 1000);
@@ -1678,12 +1788,16 @@ class ZeroXIOWallet extends EventEmitter {
1678
1788
  */
1679
1789
  async claimPrivateTransfer(transferId) {
1680
1790
  this.ensureConnected();
1791
+ // validate transfer ID
1792
+ if (!transferId || typeof transferId !== 'string') {
1793
+ throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Invalid transfer ID');
1794
+ }
1681
1795
  try {
1682
1796
  const result = await this.communicator.sendRequest('claim_private_transfer', {
1683
1797
  transferId
1684
1798
  });
1685
- // Refresh balance after claiming
1686
- if (result.success) {
1799
+ // Refresh balance after claiming (accept RFC 'accepted' or legacy 'success')
1800
+ if (result.accepted ?? result.success) {
1687
1801
  setTimeout(() => {
1688
1802
  this.getBalance(true).catch(() => { });
1689
1803
  }, 1000);
@@ -1694,9 +1808,6 @@ class ZeroXIOWallet extends EventEmitter {
1694
1808
  throw new ZeroXIOWalletError(ErrorCode.TRANSACTION_FAILED, 'Failed to claim private transfer', error);
1695
1809
  }
1696
1810
  }
1697
- // ===================
1698
- // MESSAGE SIGNING
1699
- // ===================
1700
1811
  /**
1701
1812
  * Sign an arbitrary message with the wallet's private key
1702
1813
  * The user will be prompted to approve the signature request in the extension
@@ -1734,9 +1845,27 @@ class ZeroXIOWallet extends EventEmitter {
1734
1845
  throw new ZeroXIOWalletError(ErrorCode.SIGNATURE_FAILED, 'Failed to sign message', error);
1735
1846
  }
1736
1847
  }
1737
- // ===================
1738
- // PRIVATE METHODS
1739
- // ===================
1848
+ /**
1849
+ * Sign a domain-separated authentication message.
1850
+ * Unlike `signMessage()`, this prepends a standard header that binds the signature
1851
+ * to the calling service and a one-time nonce, preventing cross-service replay attacks.
1852
+ *
1853
+ * @param service - Identifies the relying service (e.g. 'MyDApp' or 'api.mydapp.com')
1854
+ * @param nonce - Unique one-time value — use a server-generated UUID or challenge
1855
+ * @returns Promise resolving to the base64-encoded Ed25519 signature
1856
+ */
1857
+ async signAuthMessage(service, nonce) {
1858
+ this.ensureConnected();
1859
+ if (!service || typeof service !== 'string') {
1860
+ throw new ZeroXIOWalletError(ErrorCode.SIGNATURE_FAILED, 'Service name is required');
1861
+ }
1862
+ if (!nonce || typeof nonce !== 'string') {
1863
+ throw new ZeroXIOWalletError(ErrorCode.SIGNATURE_FAILED, 'Nonce is required');
1864
+ }
1865
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'unknown';
1866
+ const domainSeparated = `0xio auth\nService: ${service}\nNonce: ${nonce}\nOrigin: ${origin}`;
1867
+ return this.signMessage(domainSeparated);
1868
+ }
1740
1869
  ensureInitialized() {
1741
1870
  if (!this.isInitialized) {
1742
1871
  throw new ZeroXIOWalletError(ErrorCode.UNKNOWN_ERROR, 'SDK not initialized. Call initialize() first.');
@@ -1749,7 +1878,6 @@ class ZeroXIOWallet extends EventEmitter {
1749
1878
  }
1750
1879
  }
1751
1880
  setupExtensionEventListeners() {
1752
- // Listen for extension events through the communicator
1753
1881
  this.communicator.on('accountChanged', (event) => {
1754
1882
  this.handleAccountChanged(event.data);
1755
1883
  });
@@ -1768,76 +1896,93 @@ class ZeroXIOWallet extends EventEmitter {
1768
1896
  this.communicator.on('transactionConfirmed', (event) => {
1769
1897
  this.handleTransactionConfirmed(event.data);
1770
1898
  });
1899
+ this.communicator.on('permissionsChanged', (event) => {
1900
+ const permissions = event.data ?? event;
1901
+ if (this.connectionInfo.isConnected) {
1902
+ this.connectionInfo.permissions = Array.isArray(permissions) ? permissions : [];
1903
+ }
1904
+ this.emit('permissionsChanged', permissions);
1905
+ });
1906
+ this.communicator.on('message', (event) => {
1907
+ this.emit('message', event.data ?? event);
1908
+ });
1771
1909
  this.logger.log('Extension event listeners setup complete');
1772
1910
  }
1773
- /**
1774
- * Handle account changed event from extension
1775
- */
1776
1911
  handleAccountChanged(data) {
1912
+ ++this._sessionVersion;
1777
1913
  const previousAddress = this.connectionInfo.address;
1778
1914
  this.connectionInfo.address = data.address;
1915
+ // clear stale pubkey on acct change
1916
+ this.connectionInfo.publicKey = data.publicKey;
1779
1917
  if (data.balance) {
1780
- this.connectionInfo.balance = data.balance;
1918
+ const validated = validateBalance(data.balance);
1919
+ if (validated) {
1920
+ this.connectionInfo.balance = validated;
1921
+ }
1922
+ else {
1923
+ this.connectionInfo.balance = undefined; // clear stale balance
1924
+ }
1781
1925
  }
1782
1926
  const accountChangedEvent = {
1783
1927
  previousAddress,
1784
1928
  newAddress: data.address,
1929
+ publicKey: data.publicKey,
1785
1930
  balance: data.balance ?? this.connectionInfo.balance
1786
1931
  };
1787
1932
  this.emit('accountChanged', accountChangedEvent);
1788
- this.logger.log('Account changed:', accountChangedEvent);
1933
+ this.logger.log('Account changed:', { newAddress: accountChangedEvent.newAddress });
1789
1934
  }
1790
- /**
1791
- * Handle network changed event from extension
1792
- */
1793
1935
  handleNetworkChanged(data) {
1794
1936
  const previousNetwork = this.connectionInfo.networkInfo;
1795
- this.connectionInfo.networkInfo = data.networkInfo;
1937
+ // validate networkInfo drop invalid
1938
+ const networkInfo = validateNetworkInfo(data.networkInfo);
1939
+ if (!networkInfo) {
1940
+ this.logger.warn('Received invalid networkInfo in networkChanged event, ignoring');
1941
+ return;
1942
+ }
1943
+ this.connectionInfo.networkInfo = networkInfo;
1944
+ // invalidate balance on network change
1945
+ this.connectionInfo.balance = undefined;
1796
1946
  const networkChangedEvent = {
1797
1947
  previousNetwork,
1798
- newNetwork: data.networkInfo
1948
+ newNetwork: networkInfo
1799
1949
  };
1800
1950
  this.emit('networkChanged', networkChangedEvent);
1801
1951
  this.logger.log('Network changed:', networkChangedEvent);
1802
1952
  }
1803
- /**
1804
- * Handle balance changed event from extension
1805
- */
1806
1953
  handleBalanceChanged(data) {
1954
+ const balance = validateBalance(data.balance);
1955
+ if (!balance) {
1956
+ this.logger.warn('Received invalid balance in balanceChanged event, ignoring');
1957
+ return;
1958
+ }
1807
1959
  const previousBalance = this.connectionInfo.balance;
1808
- this.connectionInfo.balance = data.balance;
1960
+ this.connectionInfo.balance = balance;
1809
1961
  const balanceChangedEvent = {
1810
1962
  address: this.connectionInfo.address,
1811
1963
  previousBalance,
1812
- newBalance: data.balance
1964
+ newBalance: balance
1813
1965
  };
1814
1966
  this.emit('balanceChanged', balanceChangedEvent);
1815
- this.logger.log('Balance changed:', balanceChangedEvent);
1967
+ this.logger.log('Balance changed:', { public: balance.public });
1816
1968
  }
1817
- /**
1818
- * Handle extension locked event
1819
- */
1820
1969
  handleExtensionLocked() {
1970
+ ++this._sessionVersion;
1821
1971
  this.connectionInfo = { isConnected: false };
1972
+ this.emit('extensionLocked', {});
1822
1973
  const disconnectEvent = {
1823
1974
  reason: 'extension_locked'
1824
1975
  };
1825
1976
  this.emit('disconnect', disconnectEvent);
1826
1977
  this.logger.log('Extension locked - disconnected');
1827
1978
  }
1828
- /**
1829
- * Handle extension unlocked event
1830
- */
1831
1979
  handleExtensionUnlocked() {
1832
- // Attempt to restore connection
1980
+ this.emit('extensionUnlocked', {});
1833
1981
  this.getConnectionStatus().catch(() => {
1834
1982
  this.logger.warn('Could not restore connection after unlock');
1835
1983
  });
1836
1984
  this.logger.log('Extension unlocked');
1837
1985
  }
1838
- /**
1839
- * Handle transaction confirmed event
1840
- */
1841
1986
  handleTransactionConfirmed(data) {
1842
1987
  this.emit('transactionConfirmed', {
1843
1988
  txHash: data.txHash,
@@ -1850,17 +1995,28 @@ class ZeroXIOWallet extends EventEmitter {
1850
1995
  }, 2000);
1851
1996
  this.logger.log('Transaction confirmed:', data.txHash);
1852
1997
  }
1853
- // ===================
1854
- // CLEANUP
1855
- // ===================
1856
1998
  /**
1857
- * Clean up SDK resources
1999
+ * Reject numeric amounts that cannot be represented exactly in micro-OCT.
2000
+ * e.g. 0.1 + 0.2 = 0.30000000000000004 — the extension would sign the wrong value.
2001
+ * String amounts bypass this check (caller is responsible for correctness).
1858
2002
  */
2003
+ assertExactOCTAmount(amount, label) {
2004
+ if (typeof amount === 'number') {
2005
+ const micro = Math.round(amount * 1000000);
2006
+ if (Math.abs(amount - micro / 1000000) > 1e-10) {
2007
+ const suggested = (micro / 1000000).toFixed(6);
2008
+ throw new ZeroXIOWalletError(ErrorCode.INVALID_AMOUNT, `${label} cannot be represented exactly in micro-OCT. ` +
2009
+ `Pass a string instead (e.g. "${suggested}").`);
2010
+ }
2011
+ }
2012
+ }
1859
2013
  cleanup() {
1860
2014
  this.communicator.cleanup();
1861
2015
  this.removeAllListeners();
1862
2016
  this.connectionInfo = { isConnected: false };
1863
2017
  this.isInitialized = false;
2018
+ this._initPromise = null;
2019
+ ++this._sessionVersion;
1864
2020
  this.logger.log('SDK cleanup complete');
1865
2021
  }
1866
2022
  }
@@ -1870,6 +2026,209 @@ var wallet = /*#__PURE__*/Object.freeze({
1870
2026
  ZeroXIOWallet: ZeroXIOWallet
1871
2027
  });
1872
2028
 
2029
+ /**
2030
+ * RFC-O-1 OctraProvider transport adapter.
2031
+ *
2032
+ * Targets any wallet that exposes `window.octra` per the RFC-O-1 specification:
2033
+ * window.octra.isOctra === true
2034
+ * window.octra.request({ method, params }) → Promise<unknown>
2035
+ * window.octra.on(event, listener) / removeListener(event, listener)
2036
+ *
2037
+ * This adapter translates the SDK's internal method names into RFC-O-1 method
2038
+ * names and maps events back to the SDK event vocabulary.
2039
+ *
2040
+ * Non-standard SDK methods (ping, register_dapp, getTransactionHistory, etc.)
2041
+ * are passed through as-is; the wallet's request() handles or rejects them.
2042
+ */
2043
+ /** SDK method → RFC-O-1 method name */
2044
+ const SDK_TO_RFC = {
2045
+ get_network_info: 'octra_networkInfo',
2046
+ switch_network: 'octra_switchNetwork',
2047
+ signMessage: 'octra_signMessage',
2048
+ send_transaction: 'octra_sendTransaction',
2049
+ sign_transaction: 'octra_signTransaction',
2050
+ broadcast_only: 'octra_submitTransaction',
2051
+ call_contract: 'octra_sendContractTransaction',
2052
+ contract_call_view: 'octra_callContract',
2053
+ get_private_balance_info: 'octra_getEncryptedBalance',
2054
+ encrypt_balance: 'octra_encryptBalance',
2055
+ decrypt_balance: 'octra_decryptBalance',
2056
+ send_private_transfer: 'octra_sendPrivateTransfer',
2057
+ claim_private_transfer: 'octra_claimStealth',
2058
+ };
2059
+ /** RFC-O-1 error code → SDK ErrorCode string */
2060
+ const RFC_TO_SDK_ERROR = {
2061
+ 4001: 'USER_REJECTED',
2062
+ 4100: 'PERMISSION_DENIED',
2063
+ 4200: 'UNKNOWN_ERROR',
2064
+ 4900: 'CONNECTION_REFUSED',
2065
+ 4901: 'NETWORK_ERROR',
2066
+ };
2067
+ function getProvider() {
2068
+ return typeof window !== 'undefined' ? window.octra : null;
2069
+ }
2070
+ function mapError(err) {
2071
+ const code = RFC_TO_SDK_ERROR[err?.code] ?? 'UNKNOWN_ERROR';
2072
+ return { code, message: err?.message ?? 'Request failed' };
2073
+ }
2074
+ /**
2075
+ * Build a connect response compatible with what the SDK expects
2076
+ * ({ address, networkInfo, permissions, balance }) by making
2077
+ * three RFC-O-1 calls: octra_requestAccounts, octra_networkInfo, octra_permissions.
2078
+ */
2079
+ async function rfcConnect(provider, params) {
2080
+ const requestPerms = params?.permissions ?? params?.requestPermissions ?? [];
2081
+ const accounts = (await provider.request({
2082
+ method: 'octra_requestAccounts',
2083
+ params: [{ permissions: requestPerms }],
2084
+ }));
2085
+ const address = accounts?.[0] ?? null;
2086
+ const [networkInfo, permissions] = await Promise.all([
2087
+ provider.request({ method: 'octra_networkInfo' }),
2088
+ provider.request({ method: 'octra_permissions' }),
2089
+ ]);
2090
+ return { address, networkInfo, permissions, balance: null };
2091
+ }
2092
+ /**
2093
+ * Build a getConnectionStatus response by checking octra_accounts.
2094
+ */
2095
+ async function rfcConnectionStatus(provider) {
2096
+ const accounts = (await provider.request({ method: 'octra_accounts' }));
2097
+ const address = accounts?.[0] ?? null;
2098
+ if (!address)
2099
+ return { isConnected: false };
2100
+ const [networkInfo, permissions] = await Promise.all([
2101
+ provider.request({ method: 'octra_networkInfo' }),
2102
+ provider.request({ method: 'octra_permissions' }),
2103
+ ]);
2104
+ return { isConnected: true, address, networkInfo, permissions };
2105
+ }
2106
+ function createOctraProviderAdapter() {
2107
+ let _handler = null;
2108
+ return {
2109
+ name: 'octra-provider',
2110
+ displayName: 'Octra Wallet (RFC-O-1)',
2111
+ detect() {
2112
+ const p = getProvider();
2113
+ return p?.isOctra === true;
2114
+ },
2115
+ postRequest(request) {
2116
+ const { id, method, params } = request;
2117
+ const provider = getProvider();
2118
+ if (!provider) {
2119
+ _handler?.({
2120
+ requestId: id,
2121
+ success: false,
2122
+ error: { code: 'EXTENSION_NOT_FOUND', message: 'No RFC-O-1 provider found on window.octra' },
2123
+ });
2124
+ return;
2125
+ }
2126
+ (async () => {
2127
+ try {
2128
+ let data;
2129
+ if (method === 'ping') {
2130
+ data = { available: true };
2131
+ }
2132
+ else if (method === 'register_dapp') {
2133
+ data = { success: true };
2134
+ }
2135
+ else if (method === 'connect') {
2136
+ data = await rfcConnect(provider, params);
2137
+ }
2138
+ else if (method === 'disconnect') {
2139
+ await provider.request({ method: 'disconnect' }).catch(() => { });
2140
+ data = { success: true };
2141
+ }
2142
+ else if (method === 'getConnectionStatus') {
2143
+ data = await rfcConnectionStatus(provider);
2144
+ }
2145
+ else {
2146
+ const rfcMethod = SDK_TO_RFC[method] ?? method;
2147
+ data = await provider.request({ method: rfcMethod, params });
2148
+ }
2149
+ _handler?.({ requestId: id, success: true, data });
2150
+ }
2151
+ catch (err) {
2152
+ _handler?.({ requestId: id, success: false, error: mapError(err) });
2153
+ }
2154
+ })();
2155
+ },
2156
+ listen(handler) {
2157
+ _handler = handler;
2158
+ const provider = getProvider();
2159
+ if (!provider)
2160
+ return () => { _handler = null; };
2161
+ // RFC-O-1 event → SDK event name + data shape
2162
+ const onConnect = (data) => handler({ eventType: 'connect', eventData: data });
2163
+ const onDisconnect = (data) => handler({ eventType: 'disconnect', eventData: { reason: 'network_error', ...data } });
2164
+ const onAccountsChanged = (accounts) => handler({ eventType: 'accountChanged', eventData: { address: accounts?.[0] ?? null } });
2165
+ const onNetworkChanged = (data) => handler({ eventType: 'networkChanged', eventData: { networkInfo: data } });
2166
+ const onBalanceChanged = (data) => handler({ eventType: 'balanceChanged', eventData: data });
2167
+ const onTransactionChanged = (data) => handler({ eventType: 'transactionConfirmed', eventData: data });
2168
+ const onPermissionsChanged = (data) => handler({ eventType: 'permissionsChanged', eventData: data });
2169
+ provider.on('connect', onConnect);
2170
+ provider.on('disconnect', onDisconnect);
2171
+ provider.on('accountsChanged', onAccountsChanged);
2172
+ provider.on('networkChanged', onNetworkChanged);
2173
+ provider.on('balanceChanged', onBalanceChanged);
2174
+ provider.on('transactionChanged', onTransactionChanged);
2175
+ provider.on('permissionsChanged', onPermissionsChanged);
2176
+ const cleanup = () => {
2177
+ provider.removeListener('connect', onConnect);
2178
+ provider.removeListener('disconnect', onDisconnect);
2179
+ provider.removeListener('accountsChanged', onAccountsChanged);
2180
+ provider.removeListener('networkChanged', onNetworkChanged);
2181
+ provider.removeListener('balanceChanged', onBalanceChanged);
2182
+ provider.removeListener('transactionChanged', onTransactionChanged);
2183
+ provider.removeListener('permissionsChanged', onPermissionsChanged);
2184
+ _handler = null;
2185
+ };
2186
+ return cleanup;
2187
+ },
2188
+ listenForReady(onReady) {
2189
+ const handler = () => onReady();
2190
+ window.addEventListener('octraWalletReady', handler);
2191
+ window.addEventListener('octra#initialized', handler);
2192
+ return () => {
2193
+ window.removeEventListener('octraWalletReady', handler);
2194
+ window.removeEventListener('octra#initialized', handler);
2195
+ };
2196
+ },
2197
+ };
2198
+ }
2199
+ /** Default RFC-O-1 adapter instance. */
2200
+ const OctraProviderAdapter = createOctraProviderAdapter();
2201
+
2202
+ /**
2203
+ * 0xio SDK — Wallet Adapter Registry
2204
+ *
2205
+ * Add new wallet adapters here. Detection order determines which wallet takes
2206
+ * priority when multiple wallets are installed at the same time.
2207
+ */
2208
+ const REGISTERED_ADAPTERS = [
2209
+ ZeroXIOAdapter, // 0xio extension (postMessage protocol) — highest priority
2210
+ OctraProviderAdapter, // any RFC-O-1 compliant wallet (window.octra)
2211
+ // Add new wallet adapters here — detection runs in order, first match wins
2212
+ ];
2213
+ /**
2214
+ * Auto-detects the first available wallet in the current page.
2215
+ * Returns null if no supported wallet is found.
2216
+ *
2217
+ * @example
2218
+ * const adapter = detectWalletAdapter();
2219
+ * if (!adapter) throw new Error('No supported wallet found');
2220
+ * const wallet = new ZeroXIOWallet({ appName: 'My DApp', adapter });
2221
+ */
2222
+ function detectWalletAdapter() {
2223
+ if (typeof window === 'undefined')
2224
+ return null;
2225
+ return REGISTERED_ADAPTERS.find((a) => a.detect()) ?? null;
2226
+ }
2227
+ /** Returns all registered adapter instances. */
2228
+ function getAllAdapters() {
2229
+ return [...REGISTERED_ADAPTERS];
2230
+ }
2231
+
1873
2232
  /**
1874
2233
  * 0xio Wallet SDK - Main Entry Point
1875
2234
  * Official SDK for integrating with 0xio Wallet Extension
@@ -1896,7 +2255,7 @@ var wallet = /*#__PURE__*/Object.freeze({
1896
2255
  */
1897
2256
  // Main exports
1898
2257
  // Version information
1899
- const SDK_VERSION = '2.6.0';
2258
+ const SDK_VERSION = '2.7.1';
1900
2259
  const MIN_EXTENSION_VERSION = '2.0.1'; // Mainnet Alpha
1901
2260
  const MIN_EXTENSION_VERSION_DEVNET = '2.2.1'; // Devnet (contract calls, privacy)
1902
2261
  const SUPPORTED_EXTENSION_VERSIONS = '^2.0.1'; // Supports all versions >= 2.0.1
@@ -1906,37 +2265,49 @@ async function createZeroXIOWallet(config) {
1906
2265
  const wallet$1 = new ZeroXIOWallet({
1907
2266
  appName: config.appName,
1908
2267
  appDescription: config.appDescription,
1909
- debug: config.debug || false
2268
+ debug: config.debug || false,
2269
+ ...(config.adapter ? { adapter: config.adapter } : {}),
1910
2270
  });
1911
2271
  await wallet$1.initialize();
1912
2272
  if (config.autoConnect) {
1913
2273
  try {
1914
2274
  await wallet$1.connect();
1915
2275
  }
1916
- catch (error) {
1917
- if (config.debug) ;
2276
+ catch {
1918
2277
  // Don't throw - let the app handle connection manually
1919
2278
  }
1920
2279
  }
1921
2280
  return wallet$1;
1922
2281
  }
1923
- // Legacy alias for backward compatibility
1924
- const createOctraWallet = createZeroXIOWallet;
1925
2282
  // Browser detection and compatibility check
1926
2283
  function checkSDKCompatibility() {
1927
2284
  const issues = [];
1928
2285
  const recommendations = [];
1929
- // Basic browser support check
2286
+ // Hard blockers SDK cannot function without these
1930
2287
  if (typeof window === 'undefined') {
1931
2288
  issues.push('Window object not available');
1932
2289
  recommendations.push('SDK must be used in a browser environment');
2290
+ return { compatible: false, issues, recommendations };
2291
+ }
2292
+ if (typeof window.postMessage !== 'function') {
2293
+ issues.push('postMessage API not available');
2294
+ recommendations.push('Your browser environment must support postMessage');
2295
+ }
2296
+ if (typeof window.addEventListener !== 'function') {
2297
+ issues.push('addEventListener not available');
2298
+ }
2299
+ if (typeof Promise === 'undefined') {
2300
+ issues.push('Promise not available');
1933
2301
  }
1934
- // Check for extension APIs
1935
- if (typeof window !== 'undefined') {
2302
+ // Informational: note which transport is likely active
2303
+ if (issues.length === 0) {
1936
2304
  const win = window;
1937
- if (!win.chrome || !win.chrome.runtime) {
1938
- issues.push('Chrome extension APIs not available');
1939
- recommendations.push('This SDK requires a Chromium-based browser (Chrome, Edge, Brave, etc.)');
2305
+ const hasExtension = !!(win.wallet0xio || win.ZeroXIOWallet || win.chrome?.runtime?.id ||
2306
+ document.querySelector('meta[name="0xio-dapp"]') || document.querySelector('[data-0xio-sdk-bridge]'));
2307
+ const hasParentBridge = window.parent !== window;
2308
+ if (!hasExtension && !hasParentBridge) {
2309
+ recommendations.push('No 0xio transport detected yet. Install the 0xio Wallet browser extension, ' +
2310
+ 'or run inside the 0xio Desktop/Mobile app iframe bridge.');
1940
2311
  }
1941
2312
  }
1942
2313
  return {
@@ -1992,5 +2363,5 @@ if (typeof window !== 'undefined') {
1992
2363
  }
1993
2364
  }
1994
2365
 
1995
- export { DEFAULT_NETWORK_ID, ErrorCode, EventEmitter, ExtensionCommunicator, MIN_EXTENSION_VERSION, MIN_EXTENSION_VERSION_DEVNET, NETWORKS, SDK_CONFIG, SDK_VERSION, SUPPORTED_EXTENSION_VERSIONS, ZeroXIOWallet, ZeroXIOWalletError, checkBrowserSupport, checkSDKCompatibility, createDefaultBalance, createErrorMessage, createLogger, createOctraWallet, createZeroXIOWallet, delay, formatAddress, formatOCT, formatTimestamp, formatTxHash, formatOCT as formatZeroXIO, fromMicroOCT, fromMicroOCT as fromMicroZeroXIO, generateMockData, getAllNetworks, getDefaultNetwork, getNetworkConfig, isBrowser, isErrorType, isValidAddress, isValidAmount, isValidFeeLevel, isValidMessage, isValidNetworkId, retry, toMicroOCT, toMicroOCT as toMicroZeroXIO, withTimeout };
2366
+ export { DEFAULT_NETWORK_ID, ErrorCode, EventEmitter, ExtensionCommunicator, MIN_EXTENSION_VERSION, MIN_EXTENSION_VERSION_DEVNET, NETWORKS, OctraProviderAdapter, SDK_CONFIG, SDK_VERSION, SUPPORTED_EXTENSION_VERSIONS, ZeroXIOAdapter, ZeroXIOWallet, ZeroXIOWalletError, checkBrowserSupport, checkSDKCompatibility, createDefaultBalance, createErrorMessage, createLogger, createOctraProviderAdapter, createZeroXIOAdapter, createZeroXIOWallet, delay, deriveOctraAddress, detectWalletAdapter, formatAddress, formatOCT, formatTimestamp, formatTxHash, formatOCT as formatZeroXIO, fromMicroOCT, fromMicroOCT as fromMicroZeroXIO, generateMockData, getAllAdapters, getAllNetworks, getDefaultNetwork, getNetworkConfig, isBrowser, isErrorType, isValidAddress, isValidAmount, isValidFeeLevel, isValidMessage, isValidNetworkId, toMicroOCT, toMicroOCT as toMicroZeroXIO };
1996
2367
  //# sourceMappingURL=index.esm.js.map