@unicitylabs/sphere-sdk 0.12.0-dev.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -124,26 +124,6 @@ var logger = {
124
124
  }
125
125
  };
126
126
 
127
- // core/errors.ts
128
- var SphereError = class extends Error {
129
- code;
130
- cause;
131
- /**
132
- * #441 deferred-paid linkage: the transferId of the possibly-committed send,
133
- * stamped by `sendOnce` before the error leaves so a payment-request consumer
134
- * can durably journal request→transfer and resolve 'paid' only when that
135
- * transfer actually completes (never optimistically). Present ONLY on
136
- * possibly-committed outcomes ({@link isPossiblyCommittedSendOutcome}).
137
- */
138
- transferId;
139
- constructor(message, code, cause) {
140
- super(message);
141
- this.name = "SphereError";
142
- this.code = code;
143
- this.cause = cause;
144
- }
145
- };
146
-
147
127
  // connect/semver.ts
148
128
  function majorOf(v) {
149
129
  return parseInt(String(v).split(".")[0], 10);
@@ -342,7 +322,7 @@ var HOST_READY_TIMEOUT = 3e4;
342
322
 
343
323
  // connect/protocol.ts
344
324
  var SPHERE_CONNECT_NAMESPACE = "sphere-connect";
345
- var SPHERE_CONNECT_VERSION = "2.0";
325
+ var SPHERE_CONNECT_VERSION = "2.1";
346
326
  var RPC_METHODS = {
347
327
  GET_IDENTITY: "sphere_getIdentity",
348
328
  GET_BALANCE: "sphere_getBalance",
@@ -378,6 +358,78 @@ var INTENT_ACTIONS = {
378
358
  SET_AUTO_RETURN: "set_auto_return",
379
359
  MINT: "mint"
380
360
  };
361
+ var ERROR_CODES = {
362
+ // Standard JSON-RPC
363
+ PARSE_ERROR: -32700,
364
+ INVALID_REQUEST: -32600,
365
+ METHOD_NOT_FOUND: -32601,
366
+ INVALID_PARAMS: -32602,
367
+ INTERNAL_ERROR: -32603,
368
+ // Sphere Connect (4xxx)
369
+ NOT_CONNECTED: 4001,
370
+ PERMISSION_DENIED: 4002,
371
+ USER_REJECTED: 4003,
372
+ SESSION_EXPIRED: 4004,
373
+ ORIGIN_BLOCKED: 4005,
374
+ RATE_LIMITED: 4006,
375
+ UNSUPPORTED_PROTOCOL_VERSION: 4007,
376
+ // Connect MAJOR mismatch (incompatible era)
377
+ INCOMPATIBLE_NETWORK: 4008,
378
+ // dApp targets a different network than the wallet
379
+ // Wallet locked; THE SESSION IS STILL ALIVE. A QUERY may be retried after wallet:unlocked.
380
+ // An INTENT already delegated to the wallet is NEVER answered with this code — it gets
381
+ // INTENT_OUTCOME_UNKNOWN (4201) instead, because a retry could double-spend.
382
+ WALLET_LOCKED: 4009,
383
+ INSUFFICIENT_BALANCE: 4100,
384
+ INVALID_RECIPIENT: 4101,
385
+ TRANSFER_FAILED: 4102,
386
+ INTENT_CANCELLED: 4200,
387
+ /**
388
+ * The intent was DELEGATED to the wallet and the host lost track of the answer — a host
389
+ * deadline fired, or the wallet locked / logged out mid-flight. **The outcome is UNKNOWN:
390
+ * the money may or may not have moved.**
391
+ *
392
+ * A dApp MUST NOT retry on this code. Reconcile out of band (poll the recipient, the
393
+ * aggregator, or your own backend) and only then decide.
394
+ *
395
+ * This code exists because every other answer would be a lie. `INTENT_CANCELLED` (4200)
396
+ * asserts the user declined and nothing happened; `WALLET_LOCKED` (4009) invites a retry
397
+ * after the unlock. Sending either for an intent the wallet had already submitted is how a
398
+ * paid-but-not-credited order — and then a double spend on retry — happens.
399
+ */
400
+ INTENT_OUTCOME_UNKNOWN: 4201
401
+ };
402
+ var WALLET_EVENTS = {
403
+ /** Wallet is LOCKED — the session is STILL ALIVE. Requests are answered
404
+ * WALLET_LOCKED (4009) until `wallet:unlocked`. The dApp must NOT disconnect,
405
+ * must NOT clear its sessionId, and must NOT re-handshake.
406
+ * Payload: {@link WalletLockedPayload}. Pushed by ConnectHost.setLocked() and
407
+ * immediately after a handshake response carrying `locked: true`. */
408
+ LOCKED: "wallet:locked",
409
+ /** Wallet was unlocked — the SAME session continues: no re-handshake, no re-approval,
410
+ * no re-subscribe (the host re-arms the dApp's subscriptions before pushing this).
411
+ * Payload: {@link WalletUnlockedPayload} — carries the CURRENT identity, which may
412
+ * differ from the one the dApp connected with. Pushed by ConnectHost.updateSphere()
413
+ * on the locked -> live edge only. */
414
+ UNLOCKED: "wallet:unlocked",
415
+ /** The session is GONE (logout, wallet deleted, dApp sphere_disconnect, expiry seen at
416
+ * unlock, a different seed behind the lock screen, host destroy).
417
+ * The dApp must clear its session and re-handshake to continue. Unlocking does not cure it.
418
+ * Payload: {@link WalletDisconnectedPayload}. Pushed by ConnectHost.revokeSession(). */
419
+ DISCONNECTED: "wallet:disconnected",
420
+ /** Active wallet address changed. dApp should update displayed identity.
421
+ * Pushed automatically by ConnectHost — no sphere_subscribe needed. */
422
+ IDENTITY_CHANGED: "identity:changed"
423
+ };
424
+ var AUTO_PUSHED_EVENTS = [
425
+ WALLET_EVENTS.LOCKED,
426
+ WALLET_EVENTS.UNLOCKED,
427
+ WALLET_EVENTS.DISCONNECTED,
428
+ WALLET_EVENTS.IDENTITY_CHANGED
429
+ ];
430
+ function isAutoPushedEvent(event) {
431
+ return AUTO_PUSHED_EVENTS.includes(event);
432
+ }
381
433
  function isSphereConnectMessage(msg) {
382
434
  if (!msg || typeof msg !== "object") return false;
383
435
  const m = msg;
@@ -394,7 +446,7 @@ function createRequestId() {
394
446
  }
395
447
 
396
448
  // connect/version.ts
397
- var SDK_VERSION = "0.12.0-dev.1";
449
+ var SDK_VERSION = "0.13.0";
398
450
 
399
451
  // connect/permissions.ts
400
452
  var PERMISSION_SCOPES = {
@@ -453,6 +505,37 @@ var INTENT_PERMISSIONS = {
453
505
  [INTENT_ACTIONS.MINT]: PERMISSION_SCOPES.MINT_REQUEST
454
506
  };
455
507
 
508
+ // connect/host/host-state.ts
509
+ var WALLET_LOCKED_MESSAGE = "Wallet is locked";
510
+ var NOT_CONNECTED_MESSAGE = "Not connected";
511
+ var LOCKED_ALLOWLIST = /* @__PURE__ */ new Set([
512
+ RPC_METHODS.GET_IDENTITY,
513
+ RPC_METHODS.SUBSCRIBE,
514
+ RPC_METHODS.UNSUBSCRIBE,
515
+ RPC_METHODS.DISCONNECT
516
+ ]);
517
+ var REFUSE_NOT_CONNECTED = {
518
+ kind: "refuse",
519
+ error: { code: ERROR_CODES.NOT_CONNECTED, message: NOT_CONNECTED_MESSAGE }
520
+ };
521
+ var REFUSE_LOCKED = {
522
+ kind: "refuse",
523
+ error: {
524
+ code: ERROR_CODES.WALLET_LOCKED,
525
+ message: WALLET_LOCKED_MESSAGE,
526
+ data: { reason: "locked" }
527
+ }
528
+ };
529
+
530
+ // connect/host/WalletSnapshot.ts
531
+ var EMPTY_WALLET_SNAPSHOT = Object.freeze({ capturedAt: 0 });
532
+
533
+ // connect/host/ConnectHost.ts
534
+ var CHANNEL_ONLY_CODES = /* @__PURE__ */ new Set([
535
+ ERROR_CODES.WALLET_LOCKED,
536
+ ERROR_CODES.NOT_CONNECTED
537
+ ]);
538
+
456
539
  // connect/client/ConnectClient.ts
457
540
  var ConnectError = class extends Error {
458
541
  constructor(message, code, data) {
@@ -477,6 +560,8 @@ var ConnectClient = class {
477
560
  grantedPermissions = [];
478
561
  identity = null;
479
562
  walletNet = null;
563
+ walletProto = null;
564
+ locked = false;
480
565
  connected = false;
481
566
  pendingRequests = /* @__PURE__ */ new Map();
482
567
  eventHandlers = /* @__PURE__ */ new Map();
@@ -549,12 +634,37 @@ var ConnectClient = class {
549
634
  get walletNetwork() {
550
635
  return this.walletNet;
551
636
  }
637
+ /**
638
+ * The wallet's Connect protocol version, captured from the handshake response `v`.
639
+ * Null before the first handshake response and after a disconnect.
640
+ *
641
+ * Feature-detect with it: compare against '2.1' to decide whether the wallet can be
642
+ * trusted to send wallet:unlocked / wallet:disconnected. A Connect 2.0 wallet destroys
643
+ * the session on lock and never emits either, so a dApp waiting for them against one
644
+ * waits forever.
645
+ *
646
+ * CAVEAT: on an ERROR response the host echoes the dApp's own `v` back
647
+ * (ConnectHost.sendHandshakeResponse), so after a refused connection this may be the
648
+ * dApp's version rather than the wallet's. Only trust it after a successful handshake.
649
+ */
650
+ get walletProtocol() {
651
+ return this.walletProto;
652
+ }
653
+ /**
654
+ * Whether the wallet was locked at the last handshake or lifecycle event.
655
+ * A locked client is still CONNECTED: `isConnected` stays true and `session` stays valid.
656
+ * Requests answer WALLET_LOCKED (4009) until `wallet:unlocked` arrives on the SAME
657
+ * session — do not disconnect, do not clear the session, do not re-handshake.
658
+ */
659
+ get walletLocked() {
660
+ return this.locked;
661
+ }
552
662
  // ===========================================================================
553
663
  // Query (read data)
554
664
  // ===========================================================================
555
665
  /** Send a query request and return the result */
556
666
  async query(method, params) {
557
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
667
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
558
668
  const id = createRequestId();
559
669
  return new Promise((resolve, reject) => {
560
670
  const timer = setTimeout(() => {
@@ -564,7 +674,8 @@ var ConnectClient = class {
564
674
  this.pendingRequests.set(id, {
565
675
  resolve,
566
676
  reject,
567
- timer
677
+ timer,
678
+ kind: "query"
568
679
  });
569
680
  this.transport.send({
570
681
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -581,17 +692,23 @@ var ConnectClient = class {
581
692
  // ===========================================================================
582
693
  /** Send an intent request. The wallet will open its UI for user confirmation. */
583
694
  async intent(action, params) {
584
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
695
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
585
696
  const id = createRequestId();
586
697
  return new Promise((resolve, reject) => {
587
698
  const timer = setTimeout(() => {
588
699
  this.pendingRequests.delete(id);
589
- reject(new Error(`Intent timeout: ${action}`));
700
+ reject(
701
+ new ConnectError(
702
+ `Intent outcome unknown \u2014 do not retry; reconcile before acting: ${action}`,
703
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
704
+ )
705
+ );
590
706
  }, this.intentTimeout);
591
707
  this.pendingRequests.set(id, {
592
708
  resolve,
593
709
  reject,
594
- timer
710
+ timer,
711
+ kind: "intent"
595
712
  });
596
713
  this.transport.send({
597
714
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -610,7 +727,7 @@ var ConnectClient = class {
610
727
  on(event, handler) {
611
728
  if (!this.eventHandlers.has(event)) {
612
729
  this.eventHandlers.set(event, /* @__PURE__ */ new Set());
613
- if (this.connected) {
730
+ if (this.connected && !isAutoPushedEvent(event)) {
614
731
  this.query(RPC_METHODS.SUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event subscription failed", err));
615
732
  }
616
733
  }
@@ -621,7 +738,7 @@ var ConnectClient = class {
621
738
  handlers.delete(handler);
622
739
  if (handlers.size === 0) {
623
740
  this.eventHandlers.delete(event);
624
- if (this.connected) {
741
+ if (this.connected && !isAutoPushedEvent(event)) {
625
742
  this.query(RPC_METHODS.UNSUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event unsubscription failed", err));
626
743
  }
627
744
  }
@@ -645,15 +762,36 @@ var ConnectClient = class {
645
762
  return;
646
763
  }
647
764
  if (msg.type === "event") {
648
- const handlers = this.eventHandlers.get(msg.event);
649
- if (handlers) {
650
- for (const handler of handlers) {
651
- try {
652
- handler(msg.data);
653
- } catch (err) {
654
- logger.debug("Connect", "Event handler error", err);
655
- }
656
- }
765
+ if (!this.connected || !this.sessionId) {
766
+ logger.warn("Connect", `Ignoring wallet event before a session exists: ${msg.event}`);
767
+ return;
768
+ }
769
+ if (msg.event === WALLET_EVENTS.LOCKED) {
770
+ this.locked = true;
771
+ } else if (msg.event === WALLET_EVENTS.UNLOCKED) {
772
+ this.locked = false;
773
+ const identity = msg.data?.identity;
774
+ if (identity) this.identity = identity;
775
+ } else if (msg.event === WALLET_EVENTS.DISCONNECTED) {
776
+ this.connected = false;
777
+ } else if (msg.event === WALLET_EVENTS.IDENTITY_CHANGED) {
778
+ const data = msg.data;
779
+ if (data && typeof data.chainPubkey === "string") this.identity = data;
780
+ }
781
+ this.dispatchEvent(msg.event, msg.data);
782
+ if (msg.event === WALLET_EVENTS.DISCONNECTED) {
783
+ this.cleanup();
784
+ }
785
+ }
786
+ }
787
+ dispatchEvent(event, data) {
788
+ const handlers = this.eventHandlers.get(event);
789
+ if (!handlers) return;
790
+ for (const handler of handlers) {
791
+ try {
792
+ handler(data);
793
+ } catch (err) {
794
+ logger.debug("Connect", "Event handler error", err);
657
795
  }
658
796
  }
659
797
  }
@@ -661,6 +799,7 @@ var ConnectClient = class {
661
799
  if (!this.handshakeResolver) return;
662
800
  clearTimeout(this.handshakeResolver.timer);
663
801
  const m = msg;
802
+ this.walletProto = msg.v ?? null;
664
803
  if (m.error) {
665
804
  this.handshakeResolver.reject(new ConnectError(m.error.message, m.error.code, m.error.data));
666
805
  this.handshakeResolver = null;
@@ -671,12 +810,16 @@ var ConnectClient = class {
671
810
  this.grantedPermissions = msg.permissions;
672
811
  this.identity = msg.identity;
673
812
  this.walletNet = m.network ?? null;
813
+ this.locked = m.locked === true;
674
814
  this.connected = true;
675
815
  if (m.warning) logger.warn("Connect", "Wallet deprecation notice", m.warning.message);
676
816
  this.handshakeResolver.resolve({
677
817
  sessionId: msg.sessionId,
678
818
  permissions: this.grantedPermissions,
679
- identity: msg.identity
819
+ identity: msg.identity,
820
+ // A resume DURING a lock succeeds: the dApp is connected on the same session and
821
+ // must not re-handshake. It will get wallet:unlocked when the user unlocks.
822
+ ...this.locked ? { locked: true } : {}
680
823
  });
681
824
  } else {
682
825
  this.handshakeResolver.reject(new Error("Connection rejected by wallet"));
@@ -702,9 +845,19 @@ var ConnectClient = class {
702
845
  this.unsubscribeTransport();
703
846
  this.unsubscribeTransport = null;
704
847
  }
848
+ if (this.handshakeResolver) {
849
+ clearTimeout(this.handshakeResolver.timer);
850
+ this.handshakeResolver.reject(new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED));
851
+ this.handshakeResolver = null;
852
+ }
705
853
  for (const [, pending] of this.pendingRequests) {
706
854
  clearTimeout(pending.timer);
707
- pending.reject(new Error("Disconnected"));
855
+ pending.reject(
856
+ pending.kind === "intent" ? new ConnectError(
857
+ "Intent outcome unknown \u2014 do not retry; reconcile before acting",
858
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
859
+ ) : new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED)
860
+ );
708
861
  }
709
862
  this.pendingRequests.clear();
710
863
  this.eventHandlers.clear();
@@ -713,11 +866,21 @@ var ConnectClient = class {
713
866
  this.grantedPermissions = [];
714
867
  this.identity = null;
715
868
  this.walletNet = null;
869
+ this.walletProto = null;
870
+ this.locked = false;
716
871
  }
717
872
  };
718
873
 
719
874
  // impl/browser/connect/PostMessageTransport.ts
720
875
  var POPUP_CLOSE_CHECK_INTERVAL = 1e3;
876
+ function originOf(targetOrigin) {
877
+ if (targetOrigin === "*") return null;
878
+ try {
879
+ return [new URL(targetOrigin).origin];
880
+ } catch {
881
+ return null;
882
+ }
883
+ }
721
884
  var PostMessageTransport = class _PostMessageTransport {
722
885
  targetWindow;
723
886
  targetOrigin;
@@ -731,6 +894,9 @@ var PostMessageTransport = class _PostMessageTransport {
731
894
  this.targetOrigin = targetOrigin;
732
895
  this.allowedOrigins = allowedOrigins ? new Set(allowedOrigins) : null;
733
896
  this.listener = (event) => {
897
+ if (event.source && event.source !== this.targetWindow) {
898
+ return;
899
+ }
734
900
  if (this.allowedOrigins && !this.allowedOrigins.has("*") && !this.allowedOrigins.has(event.origin)) {
735
901
  return;
736
902
  }
@@ -769,7 +935,8 @@ var PostMessageTransport = class _PostMessageTransport {
769
935
  static forClient(options) {
770
936
  const target = options?.target ?? window.parent;
771
937
  const targetOrigin = options?.targetOrigin ?? "*";
772
- const transport = new _PostMessageTransport(target, targetOrigin, null);
938
+ const allowedOrigins = originOf(targetOrigin);
939
+ const transport = new _PostMessageTransport(target, targetOrigin, allowedOrigins);
773
940
  if (options?.target && options.target !== window.parent) {
774
941
  transport.startPopupCloseDetection(options.target);
775
942
  }