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