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