@unicitylabs/sphere-sdk 0.12.0 → 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.
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var connect_exports = {};
22
22
  __export(connect_exports, {
23
23
  ALL_PERMISSIONS: () => ALL_PERMISSIONS,
24
+ AUTO_PUSHED_EVENTS: () => AUTO_PUSHED_EVENTS,
24
25
  ConnectClient: () => ConnectClient,
25
26
  ConnectError: () => ConnectError,
26
27
  ConnectHost: () => ConnectHost,
@@ -40,6 +41,7 @@ __export(connect_exports, {
40
41
  createRequestId: () => createRequestId,
41
42
  hasIntentPermission: () => hasIntentPermission,
42
43
  hasMethodPermission: () => hasMethodPermission,
44
+ isAutoPushedEvent: () => isAutoPushedEvent,
43
45
  isSphereConnectMessage: () => isSphereConnectMessage,
44
46
  validatePermissions: () => validatePermissions
45
47
  });
@@ -387,7 +389,7 @@ var HOST_READY_TIMEOUT = 3e4;
387
389
 
388
390
  // connect/protocol.ts
389
391
  var SPHERE_CONNECT_NAMESPACE = "sphere-connect";
390
- var SPHERE_CONNECT_VERSION = "2.0";
392
+ var SPHERE_CONNECT_VERSION = "2.1";
391
393
  var RPC_METHODS = {
392
394
  GET_IDENTITY: "sphere_getIdentity",
393
395
  GET_BALANCE: "sphere_getBalance",
@@ -441,19 +443,60 @@ var ERROR_CODES = {
441
443
  // Connect MAJOR mismatch (incompatible era)
442
444
  INCOMPATIBLE_NETWORK: 4008,
443
445
  // dApp targets a different network than the wallet
446
+ // Wallet locked; THE SESSION IS STILL ALIVE. A QUERY may be retried after wallet:unlocked.
447
+ // An INTENT already delegated to the wallet is NEVER answered with this code — it gets
448
+ // INTENT_OUTCOME_UNKNOWN (4201) instead, because a retry could double-spend.
449
+ WALLET_LOCKED: 4009,
444
450
  INSUFFICIENT_BALANCE: 4100,
445
451
  INVALID_RECIPIENT: 4101,
446
452
  TRANSFER_FAILED: 4102,
447
- INTENT_CANCELLED: 4200
453
+ INTENT_CANCELLED: 4200,
454
+ /**
455
+ * The intent was DELEGATED to the wallet and the host lost track of the answer — a host
456
+ * deadline fired, or the wallet locked / logged out mid-flight. **The outcome is UNKNOWN:
457
+ * the money may or may not have moved.**
458
+ *
459
+ * A dApp MUST NOT retry on this code. Reconcile out of band (poll the recipient, the
460
+ * aggregator, or your own backend) and only then decide.
461
+ *
462
+ * This code exists because every other answer would be a lie. `INTENT_CANCELLED` (4200)
463
+ * asserts the user declined and nothing happened; `WALLET_LOCKED` (4009) invites a retry
464
+ * after the unlock. Sending either for an intent the wallet had already submitted is how a
465
+ * paid-but-not-credited order — and then a double spend on retry — happens.
466
+ */
467
+ INTENT_OUTCOME_UNKNOWN: 4201
448
468
  };
449
469
  var WALLET_EVENTS = {
450
- /** Wallet locked or user logged out. dApp shows locked state and waits for unlock.
451
- * Pushed automatically by ConnectHost — no sphere_subscribe needed. */
470
+ /** Wallet is LOCKED — the session is STILL ALIVE. Requests are answered
471
+ * WALLET_LOCKED (4009) until `wallet:unlocked`. The dApp must NOT disconnect,
472
+ * must NOT clear its sessionId, and must NOT re-handshake.
473
+ * Payload: {@link WalletLockedPayload}. Pushed by ConnectHost.setLocked() and
474
+ * immediately after a handshake response carrying `locked: true`. */
452
475
  LOCKED: "wallet:locked",
476
+ /** Wallet was unlocked — the SAME session continues: no re-handshake, no re-approval,
477
+ * no re-subscribe (the host re-arms the dApp's subscriptions before pushing this).
478
+ * Payload: {@link WalletUnlockedPayload} — carries the CURRENT identity, which may
479
+ * differ from the one the dApp connected with. Pushed by ConnectHost.updateSphere()
480
+ * on the locked -> live edge only. */
481
+ UNLOCKED: "wallet:unlocked",
482
+ /** The session is GONE (logout, wallet deleted, dApp sphere_disconnect, expiry seen at
483
+ * unlock, a different seed behind the lock screen, host destroy).
484
+ * The dApp must clear its session and re-handshake to continue. Unlocking does not cure it.
485
+ * Payload: {@link WalletDisconnectedPayload}. Pushed by ConnectHost.revokeSession(). */
486
+ DISCONNECTED: "wallet:disconnected",
453
487
  /** Active wallet address changed. dApp should update displayed identity.
454
488
  * Pushed automatically by ConnectHost — no sphere_subscribe needed. */
455
489
  IDENTITY_CHANGED: "identity:changed"
456
490
  };
491
+ var AUTO_PUSHED_EVENTS = [
492
+ WALLET_EVENTS.LOCKED,
493
+ WALLET_EVENTS.UNLOCKED,
494
+ WALLET_EVENTS.DISCONNECTED,
495
+ WALLET_EVENTS.IDENTITY_CHANGED
496
+ ];
497
+ function isAutoPushedEvent(event) {
498
+ return AUTO_PUSHED_EVENTS.includes(event);
499
+ }
457
500
  function isSphereConnectMessage(msg) {
458
501
  if (!msg || typeof msg !== "object") return false;
459
502
  const m = msg;
@@ -512,7 +555,7 @@ function checkCompatibility(input) {
512
555
  }
513
556
 
514
557
  // connect/version.ts
515
- var SDK_VERSION = "0.12.0";
558
+ var SDK_VERSION = "0.13.0";
516
559
 
517
560
  // connect/permissions.ts
518
561
  var PERMISSION_SCOPES = {
@@ -585,11 +628,188 @@ function validatePermissions(permissions) {
585
628
  return permissions.every((p) => validScopes.has(p));
586
629
  }
587
630
 
631
+ // connect/host/host-state.ts
632
+ var WALLET_LOCKED_MESSAGE = "Wallet is locked";
633
+ var INTERNAL_ERROR_MESSAGE = "Internal wallet error";
634
+ var INTENT_UNKNOWN_MESSAGE = "Intent outcome unknown \u2014 do not retry; reconcile before acting";
635
+ var NOT_CONNECTED_MESSAGE = "Not connected";
636
+ var VALID_WALLET_TRANSITIONS = {
637
+ live: ["locked", "unavailable"],
638
+ locked: ["live", "unavailable"],
639
+ unavailable: ["live", "locked"]
640
+ };
641
+ function isValidWalletTransition(from, to) {
642
+ return VALID_WALLET_TRANSITIONS[from].includes(to);
643
+ }
644
+ function assertWalletTransition(from, to) {
645
+ if (!isValidWalletTransition(from, to)) {
646
+ throw new SphereError(`Invalid wallet state transition: ${from} -> ${to}`, "VALIDATION_ERROR");
647
+ }
648
+ }
649
+ var LOCKED_ALLOWLIST = /* @__PURE__ */ new Set([
650
+ RPC_METHODS.GET_IDENTITY,
651
+ RPC_METHODS.SUBSCRIBE,
652
+ RPC_METHODS.UNSUBSCRIBE,
653
+ RPC_METHODS.DISCONNECT
654
+ ]);
655
+ var REFUSE_NOT_CONNECTED = {
656
+ kind: "refuse",
657
+ error: { code: ERROR_CODES.NOT_CONNECTED, message: NOT_CONNECTED_MESSAGE }
658
+ };
659
+ var REFUSE_LOCKED = {
660
+ kind: "refuse",
661
+ error: {
662
+ code: ERROR_CODES.WALLET_LOCKED,
663
+ message: WALLET_LOCKED_MESSAGE,
664
+ data: { reason: "locked" }
665
+ }
666
+ };
667
+ function gate(walletState, hasActiveSession, requestKind, name) {
668
+ if (walletState === "unavailable") return REFUSE_NOT_CONNECTED;
669
+ if (requestKind === "handshake") {
670
+ return walletState === "locked" ? { kind: "serve-from-snapshot" } : { kind: "serve" };
671
+ }
672
+ if (!hasActiveSession) return REFUSE_NOT_CONNECTED;
673
+ if (walletState === "live") return { kind: "serve" };
674
+ if (requestKind === "query" && LOCKED_ALLOWLIST.has(name)) {
675
+ return name === RPC_METHODS.GET_IDENTITY ? { kind: "serve-from-snapshot" } : { kind: "serve" };
676
+ }
677
+ return REFUSE_LOCKED;
678
+ }
679
+
680
+ // connect/host/WalletSnapshot.ts
681
+ var EMPTY_WALLET_SNAPSHOT = Object.freeze({ capturedAt: 0 });
682
+ function buildWalletSnapshot(sphere) {
683
+ if (!sphere) return EMPTY_WALLET_SNAPSHOT;
684
+ const id = sphere.identity;
685
+ return Object.freeze({
686
+ ...typeof sphere.networkId === "number" ? { networkId: sphere.networkId } : {},
687
+ ...id ? {
688
+ identity: Object.freeze({
689
+ chainPubkey: id.chainPubkey,
690
+ directAddress: id.directAddress,
691
+ nametag: id.nametag
692
+ })
693
+ } : {},
694
+ capturedAt: Date.now()
695
+ });
696
+ }
697
+
698
+ // connect/host/InFlightRegistry.ts
699
+ var InFlightRegistry = class {
700
+ slots = /* @__PURE__ */ new Map();
701
+ onExpire;
702
+ constructor(options) {
703
+ this.onExpire = options.onExpire;
704
+ }
705
+ get size() {
706
+ return this.slots.size;
707
+ }
708
+ has(id) {
709
+ return this.slots.has(id);
710
+ }
711
+ /** Register BEFORE the first await and arm the timer AT INSERTION TIME.
712
+ * A duplicate id is a protocol violation: warn and reuse the existing entry, so a
713
+ * hostile or buggy client cannot arm unbounded timers by replaying one id. */
714
+ add(id, kind, deadlineMs) {
715
+ const existing = this.slots.get(id);
716
+ if (existing) {
717
+ logger.warn("InFlightRegistry", `Duplicate request id, reusing entry: ${id}`);
718
+ return existing.entry;
719
+ }
720
+ const entry = {
721
+ id,
722
+ kind,
723
+ deadline: Date.now() + deadlineMs,
724
+ controller: new AbortController()
725
+ };
726
+ const timer = setTimeout(() => {
727
+ this.slots.delete(id);
728
+ entry.controller.abort();
729
+ this.onExpire(entry);
730
+ }, deadlineMs);
731
+ this.slots.set(id, { entry, timer });
732
+ return entry;
733
+ }
734
+ /** Remove + abort BEFORE the caller sends. Returns the entry, or null when it was
735
+ * already settled — in which case the caller MUST send nothing. */
736
+ settle(id) {
737
+ const slot = this.slots.get(id);
738
+ if (!slot) {
739
+ logger.warn("InFlightRegistry", `Already settled, dropping second answer: ${id}`);
740
+ return null;
741
+ }
742
+ this.slots.delete(id);
743
+ clearTimeout(slot.timer);
744
+ slot.entry.controller.abort();
745
+ return slot.entry;
746
+ }
747
+ /** Settle every entry, in insertion order, aborting each. The caller sends one frame per
748
+ * returned entry. Used by setLocked (4009), revokeSession (4001), destroy (4001). */
749
+ settleAll() {
750
+ const out = [];
751
+ for (const slot of this.slots.values()) {
752
+ clearTimeout(slot.timer);
753
+ slot.entry.controller.abort();
754
+ out.push(slot.entry);
755
+ }
756
+ this.slots.clear();
757
+ return out;
758
+ }
759
+ /** Clear all timers WITHOUT invoking onExpire. Host teardown only. */
760
+ destroy() {
761
+ for (const slot of this.slots.values()) clearTimeout(slot.timer);
762
+ this.slots.clear();
763
+ }
764
+ };
765
+
588
766
  // connect/host/ConnectHost.ts
589
767
  var DEFAULT_SESSION_TTL_MS = 864e5;
590
768
  var DEFAULT_MAX_RPS = 20;
769
+ var CHANNEL_ONLY_CODES = /* @__PURE__ */ new Set([
770
+ ERROR_CODES.WALLET_LOCKED,
771
+ ERROR_CODES.NOT_CONNECTED
772
+ ]);
773
+ var DEFAULT_REQUEST_DEADLINE_MS = 25e3;
774
+ var DEFAULT_INTENT_DEADLINE_MS = 18e4;
775
+ var DEFAULT_HANDSHAKE_DEADLINE_MS = 12e4;
776
+ function withDeadline(promise, ms, fallback) {
777
+ return new Promise((resolve, reject) => {
778
+ const timer = setTimeout(() => resolve(fallback()), ms);
779
+ promise.then(
780
+ (value) => {
781
+ clearTimeout(timer);
782
+ resolve(value);
783
+ },
784
+ (error) => {
785
+ clearTimeout(timer);
786
+ reject(error);
787
+ }
788
+ );
789
+ });
790
+ }
591
791
  var ConnectHost = class {
792
+ /** Null whenever _walletState is 'locked' or 'unavailable' (invariant B). */
592
793
  sphere;
794
+ /** The wallet-binding axis. Underscored because `walletState` is the public getter.
795
+ * ORTHOGONAL to `session` — a locked wallet keeps its session, a live wallet may have
796
+ * none. Written only by the WALLET (setLocked / setUnavailable / updateSphere / destroy);
797
+ * `session` is written by the dApp handshake, sphere_disconnect and expiry. */
798
+ _walletState;
799
+ /** Immutable public facts about the current binding. Refreshed on every bind
800
+ * (constructor, updateSphere); FROZEN by setLocked(); EMPTY after setUnavailable() and
801
+ * destroy(). Never read from Sphere while locked — that is a property of the types
802
+ * here, not of code review. */
803
+ snapshot;
804
+ /** Subscription KEYS captured by setLocked() BEFORE the unsub closures are detached.
805
+ * Sphere.destroy() kills those closures, so the keys are the only recoverable
806
+ * information. Excludes 'identity:changed' (autoSubscribeIdentityChanged re-arms it).
807
+ * A Set, not an array: handleSubscribe may be called twice for the same key while
808
+ * locked. */
809
+ suspendedSubscriptions = /* @__PURE__ */ new Set();
810
+ /** Every accepted id, with its own host-side timer. The single convergence point for
811
+ * lock / revoke / unavailable / destroy / deadline. */
812
+ inFlight;
593
813
  transport;
594
814
  config;
595
815
  session = null;
@@ -604,11 +824,33 @@ var ConnectHost = class {
604
824
  rateLimitResetAt = 0;
605
825
  unsubscribeTransport = null;
606
826
  constructor(config) {
607
- this.sphere = config.sphere;
608
827
  this.transport = config.transport;
609
828
  this.config = config;
829
+ this._walletState = config.initialWalletState ?? "live";
830
+ this.sphere = config.sphere ?? null;
831
+ if (this._walletState === "live" && !this.sphere) {
832
+ logger.warn(
833
+ "ConnectHost",
834
+ 'Constructed live with sphere === null; coercing to unavailable. Pass initialWalletState: "locked" when the wallet is locked at construction time.'
835
+ );
836
+ this._walletState = "unavailable";
837
+ }
838
+ if (this._walletState !== "live") this.sphere = null;
839
+ this.snapshot = buildWalletSnapshot(this.sphere);
840
+ this.inFlight = new InFlightRegistry({ onExpire: (e) => this.settleExpired(e) });
610
841
  this.unsubscribeTransport = this.transport.onMessage(this.handleMessage.bind(this));
611
842
  }
843
+ /** The wallet-binding axis. Orthogonal to {@link getSession}. Read-only —
844
+ * transitions go through setLocked() / setUnavailable() / updateSphere(). */
845
+ get walletState() {
846
+ return this._walletState;
847
+ }
848
+ /** Both axes in one read, for UI that must render "connected AND locked".
849
+ * Required by the wallet's ConnectPage, which today renders a green pulsing
850
+ * "Connected to {dapp}" with no regard for lock state. */
851
+ getState() {
852
+ return { walletState: this._walletState, session: this.session };
853
+ }
612
854
  /** Get current active session */
613
855
  getSession() {
614
856
  return this.session;
@@ -622,52 +864,212 @@ var ConnectHost = class {
622
864
  this.autoApprovedIntents.delete(action);
623
865
  }
624
866
  /**
625
- * Update the Sphere instance (e.g. user switched address — new Sphere created).
626
- * Re-subscribes auto-push events and notifies connected dApp of the new identity.
867
+ * Bind a (new) Sphere instance. This is BOTH the re-arm path after setLocked() /
868
+ * setUnavailable() AND the existing address-switch path in a live wallet.
869
+ *
870
+ * From 'live' (address switch): today's behaviour, unchanged — re-arm identity:changed,
871
+ * push identity:changed. NO identity comparison: an address switch is legal.
872
+ *
873
+ * On the 'locked' -> 'live' edge, in this order:
874
+ * 1. compare snapshot.identity?.chainPubkey with the new Sphere's chainPubkey.
875
+ * MISMATCH => revokeSession() (which pushes wallet:disconnected) and RETURN.
876
+ * Never wallet:unlocked. This is the "Forgot password -> restore recovery phrase
877
+ * installed a different seed behind an origin-keyed approval" guard.
878
+ * 2. session.expiresAt passed => revokeSession() and RETURN. A wallet:unlocked into a
879
+ * dead session would make the dApp's next request answer SESSION_EXPIRED 4004.
880
+ * 3. rebind, refresh the snapshot, go live, re-arm identity:changed, replay every
881
+ * suspended sphere_subscribe key, and ONLY THEN push wallet:unlocked with the
882
+ * CURRENT identity. Re-arm BEFORE push, so a dApp reacting synchronously cannot
883
+ * race its own event streams.
884
+ *
885
+ * From 'unavailable' -> 'live': rebind + refresh the snapshot, no identity check
886
+ * (nothing was bound to compare against) and no event (the session is already null).
627
887
  */
628
888
  updateSphere(newSphere) {
629
- this.sphere = newSphere;
630
- const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);
631
- if (existing) {
632
- existing();
633
- this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);
889
+ const wasLocked = this._walletState === "locked";
890
+ const next = newSphere ?? null;
891
+ if (!next) {
892
+ if (this._walletState === "locked") {
893
+ logger.warn("ConnectHost", "updateSphere(null) while locked \u2014 staying locked");
894
+ return;
895
+ }
896
+ logger.warn("ConnectHost", "updateSphere(null) \u2014 treating as a non-lock loss of Sphere");
897
+ this.setUnavailable();
898
+ return;
634
899
  }
635
- if (this.session?.active) {
636
- this.autoSubscribeIdentityChanged();
637
- const identity = this.getPublicIdentity();
638
- if (identity) {
639
- this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);
900
+ if (this._walletState === "live") {
901
+ this.sphere = next;
902
+ this.snapshot = buildWalletSnapshot(next);
903
+ const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);
904
+ if (existing) {
905
+ existing();
906
+ this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);
907
+ }
908
+ if (this.session?.active) {
909
+ this.autoSubscribeIdentityChanged();
910
+ const identity2 = this.getPublicIdentity();
911
+ if (identity2) {
912
+ this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity2);
913
+ }
914
+ }
915
+ return;
916
+ }
917
+ if (wasLocked && this.session?.active) {
918
+ const before = this.snapshot.identity?.chainPubkey ?? null;
919
+ const after = next.identity?.chainPubkey ?? null;
920
+ const netBefore = this.snapshot.networkId ?? null;
921
+ const netAfter = next.networkId ?? null;
922
+ if (before !== after || netBefore !== netAfter) {
923
+ logger.warn(
924
+ "ConnectHost",
925
+ `Wallet behind the lock screen is not the one this session was approved for \u2014 revoking instead of unlocking (origin=${this.config.origin ?? "unverified"})`
926
+ );
927
+ assertWalletTransition(this._walletState, "live");
928
+ this._walletState = "live";
929
+ this.sphere = next;
930
+ this.snapshot = buildWalletSnapshot(next);
931
+ this.revokeSession();
932
+ return;
933
+ }
934
+ if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
935
+ logger.warn(
936
+ "ConnectHost",
937
+ `Session expired while locked \u2014 re-handshake required (origin=${this.config.origin ?? "unverified"})`
938
+ );
939
+ assertWalletTransition(this._walletState, "live");
940
+ this._walletState = "live";
941
+ this.sphere = next;
942
+ this.snapshot = buildWalletSnapshot(next);
943
+ this.revokeSession();
944
+ return;
945
+ }
946
+ }
947
+ assertWalletTransition(this._walletState, "live");
948
+ this.sphere = next;
949
+ this.snapshot = buildWalletSnapshot(next);
950
+ this._walletState = "live";
951
+ if (!this.session?.active) {
952
+ this.suspendedSubscriptions.clear();
953
+ return;
954
+ }
955
+ this.autoSubscribeIdentityChanged();
956
+ const suspended = [...this.suspendedSubscriptions];
957
+ this.suspendedSubscriptions.clear();
958
+ for (const eventName of suspended) {
959
+ try {
960
+ this.handleSubscribe(eventName);
961
+ } catch (err) {
962
+ logger.warn("ConnectHost", `Re-subscribe failed after unlock: ${eventName}`, err);
640
963
  }
641
964
  }
965
+ logger.debug(
966
+ "ConnectHost",
967
+ `Wallet unlocked \u2014 re-armed ${suspended.length} subscription(s) (origin=${this.config.origin ?? "unverified"})`
968
+ );
969
+ this.pushClientEvent(WALLET_EVENTS.UNLOCKED, {
970
+ identity: this.getPublicIdentity()
971
+ });
972
+ const identity = this.getPublicIdentity();
973
+ if (identity) this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);
642
974
  }
643
- /** Revoke the current session */
975
+ /**
976
+ * The wallet locked (manual lock, idle auto-lock, cross-tab broadcast, cold start).
977
+ * The session is PRESERVED — a lock is a state, not a teardown. Every request outside
978
+ * the locked allow-list is answered WALLET_LOCKED (4009) until updateSphere().
979
+ *
980
+ * Idempotent: a second call is a no-op and pushes nothing. Required, because
981
+ * SphereProvider.lock(), ConnectPage's `sphere → null` effect and broadcastLock()'s
982
+ * same-tab loopback can all fire it for one user action.
983
+ *
984
+ * ORDERING CONTRACT: call this BEFORE sphere.destroy(). The host drops its Sphere
985
+ * reference here; destroying first leaves in-flight requests reading a dead instance
986
+ * (-32603, or `undefined` returned AS SUCCESS from sphere_getIdentity).
987
+ */
988
+ setLocked() {
989
+ if (this._walletState === "locked") return;
990
+ assertWalletTransition(this._walletState, "locked");
991
+ this.snapshot = buildWalletSnapshot(this.sphere);
992
+ this._walletState = "locked";
993
+ logger.debug(
994
+ "ConnectHost",
995
+ `Wallet locked \u2014 session preserved (origin=${this.config.origin ?? "unverified"}, session=${this.session?.id ?? "none"})`
996
+ );
997
+ if (this.session?.active) {
998
+ this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
999
+ }
1000
+ for (const key of this.eventSubscriptions.keys()) {
1001
+ if (key === WALLET_EVENTS.IDENTITY_CHANGED) continue;
1002
+ this.suspendedSubscriptions.add(key);
1003
+ }
1004
+ this.cleanupEventSubscriptions();
1005
+ this.autoApprovedIntents.clear();
1006
+ this.settleInFlight(ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: "locked" });
1007
+ this.sphere = null;
1008
+ }
1009
+ /**
1010
+ * The Sphere instance is gone for a NON-LOCK reason (a generic init failure leaves
1011
+ * `sphere === null, isLocked === false` in the wallet).
1012
+ * This is a DEAD END: unlocking does not cure it, so it revokes the session and pushes
1013
+ * wallet:disconnected rather than promising an unlock that cannot help.
1014
+ * Subsequent requests answer NOT_CONNECTED (4001); handshakes get the empty refusal
1015
+ * WITHOUT dereferencing a null Sphere.
1016
+ *
1017
+ * Idempotent. Pushes no 'wallet:unavailable' — there is no such event.
1018
+ */
1019
+ setUnavailable() {
1020
+ if (this._walletState === "unavailable") return;
1021
+ assertWalletTransition(this._walletState, "unavailable");
1022
+ this._walletState = "unavailable";
1023
+ logger.warn(
1024
+ "ConnectHost",
1025
+ `Sphere unavailable (non-lock) \u2014 session revoked (origin=${this.config.origin ?? "unverified"})`
1026
+ );
1027
+ this.snapshot = EMPTY_WALLET_SNAPSHOT;
1028
+ this.suspendedSubscriptions.clear();
1029
+ this.revokeSession();
1030
+ this.sphere = null;
1031
+ }
1032
+ /**
1033
+ * Destroy the SESSION (logout, wallet deleted, dApp sphere_disconnect, popup
1034
+ * beforeunload, expiry, identity mismatch at unlock). Pushes wallet:disconnected BEFORE
1035
+ * tearing down, so the dApp stops believing it is connected instead of finding out at
1036
+ * its next 4001.
1037
+ *
1038
+ * This is the TEARDOWN verb. For a lock use setLocked() — a lock never destroys the
1039
+ * session. revokeSession() does NOT touch walletState: the two axes are orthogonal.
1040
+ */
644
1041
  revokeSession() {
645
1042
  if (this.session) {
1043
+ logger.debug(
1044
+ "ConnectHost",
1045
+ `Session revoked (origin=${this.config.origin ?? "unverified"}, session=${this.session.id})`
1046
+ );
1047
+ if (this.session.active) {
1048
+ this.pushClientEvent(WALLET_EVENTS.DISCONNECTED, {});
1049
+ }
646
1050
  this.session.active = false;
647
1051
  this.cleanupEventSubscriptions();
648
1052
  this.autoApprovedIntents.clear();
649
1053
  this.session = null;
650
1054
  this.grantedPermissions.clear();
651
1055
  }
1056
+ this.suspendedSubscriptions.clear();
1057
+ this.settleInFlight(ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
652
1058
  }
653
- /**
654
- * Notify connected dApp that wallet is locked/logged out, then revoke session.
655
- * Call this BEFORE destroy() when the wallet locks so the dApp gets a clean signal
656
- * instead of receiving NOT_CONNECTED errors on the next request.
657
- */
658
- notifyWalletLocked() {
659
- if (this.session?.active) {
660
- this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
661
- }
662
- this.revokeSession();
663
- }
664
- /** Destroy the host, clean up all resources */
1059
+ /** Destroy the host, clean up all resources. Idempotent. */
665
1060
  destroy() {
666
1061
  this.revokeSession();
1062
+ this.inFlight.destroy();
667
1063
  if (this.unsubscribeTransport) {
668
1064
  this.unsubscribeTransport();
669
1065
  this.unsubscribeTransport = null;
670
1066
  }
1067
+ this.sphere = null;
1068
+ this.snapshot = EMPTY_WALLET_SNAPSHOT;
1069
+ if (this._walletState !== "unavailable") {
1070
+ assertWalletTransition(this._walletState, "unavailable");
1071
+ this._walletState = "unavailable";
1072
+ }
671
1073
  }
672
1074
  // ===========================================================================
673
1075
  // Message Handling
@@ -688,6 +1090,7 @@ var ConnectHost = class {
688
1090
  }
689
1091
  } catch (error) {
690
1092
  logger.warn("ConnectHost", "Error handling message:", error);
1093
+ this.sendUnhandledError(msg, error);
691
1094
  }
692
1095
  }
693
1096
  // ===========================================================================
@@ -699,11 +1102,27 @@ var ConnectHost = class {
699
1102
  this.sendHandshakeResponse([], void 0, void 0);
700
1103
  return;
701
1104
  }
1105
+ if (this._walletState !== "live" && !this.snapshot.identity) {
1106
+ this.sendHandshakeResponse([], void 0, void 0);
1107
+ return;
1108
+ }
1109
+ if (!this.checkRateLimit()) {
1110
+ logger.warn("ConnectHost", "Handshake rate-limited", { dapp: dapp.name });
1111
+ this.sendHandshakeResponse([], void 0, void 0);
1112
+ return;
1113
+ }
1114
+ const stateAtPrompt = this._walletState;
1115
+ let locked = stateAtPrompt === "locked";
1116
+ const handshakeDecision = gate(this._walletState, !!this.session?.active, "handshake", "handshake");
1117
+ if (handshakeDecision.kind === "refuse") {
1118
+ this.sendHandshakeResponse([], void 0, void 0);
1119
+ return;
1120
+ }
702
1121
  const result = checkCompatibility({
703
1122
  clientProtocol: msg.v,
704
1123
  walletProtocol: SPHERE_CONNECT_VERSION,
705
1124
  clientNetwork: msg.network,
706
- walletNetworkId: this.sphere.networkId ?? -1,
1125
+ walletNetworkId: this.snapshot.networkId ?? -1,
707
1126
  minMinor: this.config.minMinorVersion,
708
1127
  clientSdkVersion: msg.sdkVersion,
709
1128
  minSdkVersion: this.config.minSdkVersion
@@ -715,7 +1134,7 @@ var ConnectHost = class {
715
1134
  clientProtocol: msg.v,
716
1135
  walletProtocol: SPHERE_CONNECT_VERSION,
717
1136
  clientNetwork: msg.network ?? null,
718
- walletNetwork: this.sphere.networkId ?? null
1137
+ walletNetwork: this.snapshot.networkId ?? null
719
1138
  });
720
1139
  this.config.onConnectionRejected?.(dapp, result.error, !!msg.silent);
721
1140
  this.sendHandshakeResponse([], void 0, void 0, result.error, msg.v);
@@ -723,17 +1142,44 @@ var ConnectHost = class {
723
1142
  }
724
1143
  const clientInfo = { protocolVersion: msg.v, network: msg.network, sdkVersion: msg.sdkVersion };
725
1144
  if (msg.sessionId && this.session?.active && this.session.id === msg.sessionId) {
726
- const identity2 = this.getPublicIdentity();
727
- this.sendHandshakeResponse([...this.grantedPermissions], this.session.id, identity2);
1145
+ const identity2 = locked ? this.snapshotIdentity() : this.getPublicIdentity();
1146
+ this.sendHandshakeResponse(
1147
+ [...this.grantedPermissions],
1148
+ this.session.id,
1149
+ identity2,
1150
+ void 0,
1151
+ void 0,
1152
+ void 0,
1153
+ locked ? true : void 0
1154
+ );
1155
+ if (locked) {
1156
+ this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
1157
+ this.notifyLockedRequest("handshake", "handshake");
1158
+ }
728
1159
  return;
729
1160
  }
730
1161
  const requestedPermissions = msg.permissions;
731
- const { approved, grantedPermissions } = await this.config.onConnectionRequest(
732
- dapp,
733
- requestedPermissions,
734
- msg.silent,
735
- clientInfo
1162
+ const silent = msg.silent === true || locked;
1163
+ const { approved, grantedPermissions } = await withDeadline(
1164
+ Promise.resolve(
1165
+ this.config.onConnectionRequest(dapp, requestedPermissions, silent, clientInfo)
1166
+ ),
1167
+ this.config.handshakeDeadlineMs ?? DEFAULT_HANDSHAKE_DEADLINE_MS,
1168
+ () => {
1169
+ logger.warn("ConnectHost", "Connection approval prompt timed out", { dapp: dapp.name });
1170
+ return { approved: false, grantedPermissions: [] };
1171
+ }
736
1172
  );
1173
+ const stateAfterPrompt = this._walletState;
1174
+ if (stateAfterPrompt !== "live" && stateAfterPrompt !== stateAtPrompt) {
1175
+ logger.warn(
1176
+ "ConnectHost",
1177
+ `Wallet left 'live' while the approval prompt was open \u2014 refusing the handshake instead of minting a session (state=${stateAfterPrompt}, origin=${this.config.origin ?? "unverified"})`
1178
+ );
1179
+ this.sendHandshakeResponse([], void 0, void 0);
1180
+ return;
1181
+ }
1182
+ locked = stateAfterPrompt !== "live";
737
1183
  if (!approved) {
738
1184
  this.sendHandshakeResponse([], void 0, void 0);
739
1185
  return;
@@ -750,14 +1196,23 @@ var ConnectHost = class {
750
1196
  active: true
751
1197
  };
752
1198
  this.grantedPermissions = new Set(allPermissions);
753
- this.autoSubscribeIdentityChanged();
754
- const identity = this.getPublicIdentity();
755
- this.sendHandshakeResponse(allPermissions, sessionId, identity);
1199
+ if (!locked) this.autoSubscribeIdentityChanged();
1200
+ const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();
1201
+ this.sendHandshakeResponse(
1202
+ allPermissions,
1203
+ sessionId,
1204
+ identity,
1205
+ void 0,
1206
+ void 0,
1207
+ void 0,
1208
+ locked ? true : void 0
1209
+ );
1210
+ if (locked) this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
756
1211
  }
757
1212
  // `warning` is a forward-compatible deprecation-notice slot (see SphereHandshake.warning);
758
1213
  // no call site emits one yet — reserved for the deprecation-window policy.
759
- sendHandshakeResponse(permissions, sessionId, identity, error, echoV, warning) {
760
- const network = typeof this.sphere.networkId === "number" ? { id: this.sphere.networkId } : void 0;
1214
+ sendHandshakeResponse(permissions, sessionId, identity, error, echoV, warning, locked) {
1215
+ const network = typeof this.snapshot.networkId === "number" ? { id: this.snapshot.networkId } : void 0;
761
1216
  this.transport.send({
762
1217
  ns: SPHERE_CONNECT_NAMESPACE,
763
1218
  v: error && echoV ? echoV : SPHERE_CONNECT_VERSION,
@@ -769,7 +1224,8 @@ var ConnectHost = class {
769
1224
  network,
770
1225
  sdkVersion: SDK_VERSION,
771
1226
  error,
772
- warning
1227
+ warning,
1228
+ ...locked ? { locked: true } : {}
773
1229
  });
774
1230
  }
775
1231
  // ===========================================================================
@@ -777,12 +1233,12 @@ var ConnectHost = class {
777
1233
  // ===========================================================================
778
1234
  async handleRpcRequest(msg) {
779
1235
  if (!this.session?.active) {
780
- this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, "Not connected");
1236
+ this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
781
1237
  return;
782
1238
  }
783
1239
  if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
784
- this.revokeSession();
785
1240
  this.sendError(msg.id, ERROR_CODES.SESSION_EXPIRED, "Session expired");
1241
+ this.revokeSession();
786
1242
  return;
787
1243
  }
788
1244
  if (!this.checkRateLimit()) {
@@ -791,22 +1247,50 @@ var ConnectHost = class {
791
1247
  }
792
1248
  if (msg.method === RPC_METHODS.DISCONNECT) {
793
1249
  const disconnectedSession = this.session;
794
- this.revokeSession();
795
1250
  this.sendResult(msg.id, { disconnected: true });
1251
+ this.revokeSession();
796
1252
  if (disconnectedSession && this.config.onDisconnect) {
797
1253
  Promise.resolve(this.config.onDisconnect(disconnectedSession)).catch((err) => logger.warn("Connect", "onDisconnect handler error", err));
798
1254
  }
799
1255
  return;
800
1256
  }
1257
+ const decision = gate(this._walletState, true, "query", msg.method);
1258
+ if (decision.kind === "refuse") {
1259
+ this.sendError(msg.id, decision.error.code, decision.error.message, decision.error.data);
1260
+ if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {
1261
+ logger.debug("ConnectHost", `Refused query ${msg.method} \u2014 WALLET_LOCKED 4009 (origin=${this.config.origin ?? "unverified"})`);
1262
+ this.notifyLockedRequest("query", msg.method);
1263
+ }
1264
+ return;
1265
+ }
801
1266
  if (!hasMethodPermission(this.grantedPermissions, msg.method)) {
802
1267
  this.sendError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for ${msg.method}`);
803
1268
  return;
804
1269
  }
1270
+ if (decision.kind === "serve-from-snapshot") {
1271
+ const identity = this.snapshotIdentity();
1272
+ if (!identity) {
1273
+ this.sendError(msg.id, ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: "locked" });
1274
+ this.notifyLockedRequest("query", msg.method);
1275
+ return;
1276
+ }
1277
+ this.sendResult(msg.id, identity);
1278
+ return;
1279
+ }
1280
+ this.inFlight.add(msg.id, "query", this.config.requestDeadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS);
805
1281
  try {
806
1282
  const result = await this.executeMethod(msg.method, msg.params ?? {});
1283
+ if (!this.inFlight.settle(msg.id)) return;
807
1284
  this.sendResult(msg.id, result);
808
1285
  } catch (error) {
809
- this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, error.message);
1286
+ if (!this.inFlight.settle(msg.id)) return;
1287
+ const isSphereError = error instanceof SphereError || error?.name === "SphereError";
1288
+ if (isSphereError) {
1289
+ const e = error;
1290
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, e.message, { reason: e.code });
1291
+ return;
1292
+ }
1293
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
810
1294
  }
811
1295
  }
812
1296
  // ===========================================================================
@@ -814,68 +1298,109 @@ var ConnectHost = class {
814
1298
  // ===========================================================================
815
1299
  async handleIntentRequest(msg) {
816
1300
  if (!this.session?.active) {
817
- this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, "Not connected");
1301
+ this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
818
1302
  return;
819
1303
  }
820
1304
  if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
821
- this.revokeSession();
822
1305
  this.sendIntentError(msg.id, ERROR_CODES.SESSION_EXPIRED, "Session expired");
1306
+ this.revokeSession();
1307
+ return;
1308
+ }
1309
+ if (!this.checkRateLimit()) {
1310
+ this.sendIntentError(msg.id, ERROR_CODES.RATE_LIMITED, "Too many requests");
1311
+ return;
1312
+ }
1313
+ const decision = gate(this._walletState, true, "intent", msg.action);
1314
+ if (decision.kind === "refuse") {
1315
+ this.sendIntentError(msg.id, decision.error.code, decision.error.message, decision.error.data);
1316
+ if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {
1317
+ logger.debug("ConnectHost", `Refused intent ${msg.action} \u2014 WALLET_LOCKED 4009 (origin=${this.config.origin ?? "unverified"})`);
1318
+ this.notifyLockedRequest("intent", msg.action);
1319
+ }
823
1320
  return;
824
1321
  }
825
1322
  if (!hasIntentPermission(this.grantedPermissions, msg.action)) {
826
1323
  this.sendIntentError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for intent: ${msg.action}`);
827
1324
  return;
828
1325
  }
829
- const autoHandler = this.autoApprovedIntents.get(msg.action);
830
- if (autoHandler) {
831
- const autoResponse = await autoHandler(msg.action, msg.params, this.session);
832
- if (autoResponse.error) {
833
- this.sendIntentError(msg.id, autoResponse.error.code, autoResponse.error.message);
1326
+ const session = this.session;
1327
+ const entry = this.inFlight.add(
1328
+ msg.id,
1329
+ "intent",
1330
+ this.config.intentDeadlineMs ?? DEFAULT_INTENT_DEADLINE_MS
1331
+ );
1332
+ const ctx = {
1333
+ origin: this.config.origin,
1334
+ expiresAt: entry.deadline,
1335
+ signal: entry.controller.signal
1336
+ };
1337
+ try {
1338
+ const autoHandler = this.autoApprovedIntents.get(msg.action);
1339
+ const response = autoHandler ? await autoHandler(msg.action, msg.params, session) : await this.config.onIntent(msg.action, msg.params, session, ctx);
1340
+ if (!this.inFlight.settle(msg.id)) return;
1341
+ if (response.error) {
1342
+ const asserts = CHANNEL_ONLY_CODES.has(response.error.code);
1343
+ if (asserts) {
1344
+ logger.warn(
1345
+ "ConnectHost",
1346
+ `Wallet answered intent ${msg.action} with ${response.error.code}, which describes the channel rather than the spend \u2014 downgrading to INTENT_OUTCOME_UNKNOWN (origin=${this.config.origin ?? "unverified"})`
1347
+ );
1348
+ }
1349
+ this.sendIntentError(
1350
+ msg.id,
1351
+ asserts ? ERROR_CODES.INTENT_OUTCOME_UNKNOWN : response.error.code,
1352
+ asserts ? INTENT_UNKNOWN_MESSAGE : response.error.message
1353
+ );
834
1354
  } else {
835
- this.sendIntentResult(msg.id, autoResponse.result);
1355
+ this.sendIntentResult(msg.id, response.result);
836
1356
  }
837
- return;
838
- }
839
- const response = await this.config.onIntent(msg.action, msg.params, this.session);
840
- if (response.error) {
841
- this.sendIntentError(msg.id, response.error.code, response.error.message);
842
- } else {
843
- this.sendIntentResult(msg.id, response.result);
1357
+ } catch (error) {
1358
+ logger.warn("ConnectHost", `Intent handler threw: ${msg.action}`, error);
1359
+ if (!this.inFlight.settle(msg.id)) return;
1360
+ this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
844
1361
  }
845
1362
  }
846
1363
  // ===========================================================================
847
1364
  // Method Router
848
1365
  // ===========================================================================
849
1366
  async executeMethod(method, params) {
1367
+ switch (method) {
1368
+ case RPC_METHODS.SUBSCRIBE:
1369
+ return this.handleSubscribe(params.event);
1370
+ case RPC_METHODS.UNSUBSCRIBE:
1371
+ return this.handleUnsubscribe(params.event);
1372
+ }
1373
+ const sphere = this.requireSphere();
850
1374
  switch (method) {
851
1375
  case RPC_METHODS.GET_IDENTITY:
852
1376
  return this.getPublicIdentity();
853
1377
  case RPC_METHODS.GET_BALANCE:
854
- return this.sphere.payments.getBalance(params.coinId);
1378
+ return sphere.payments.getBalance(params.coinId);
855
1379
  case RPC_METHODS.GET_ASSETS:
856
- return this.sphere.payments.getAssets(params.coinId);
1380
+ return sphere.payments.getAssets(params.coinId);
857
1381
  case RPC_METHODS.GET_FIAT_BALANCE:
858
- return { fiatBalance: await this.sphere.payments.getFiatBalance() };
1382
+ return { fiatBalance: await sphere.payments.getFiatBalance() };
859
1383
  case RPC_METHODS.GET_TOKENS:
860
1384
  return this.stripTokenSdkData(
861
- this.sphere.payments.getTokens(
1385
+ sphere.payments.getTokens(
862
1386
  params.coinId ? { coinId: params.coinId } : void 0
863
1387
  )
864
1388
  );
865
1389
  case RPC_METHODS.GET_HISTORY:
866
- return this.sphere.payments.getHistory();
1390
+ return sphere.payments.getHistory();
867
1391
  case RPC_METHODS.RESOLVE:
868
1392
  if (!params.identifier) {
869
1393
  throw new SphereError("Missing required parameter: identifier", "VALIDATION_ERROR");
870
1394
  }
871
- return this.sphere.resolve(params.identifier);
1395
+ return sphere.resolve(params.identifier);
872
1396
  case RPC_METHODS.SUBSCRIBE:
873
1397
  return this.handleSubscribe(params.event);
874
1398
  case RPC_METHODS.UNSUBSCRIBE:
875
1399
  return this.handleUnsubscribe(params.event);
876
1400
  case RPC_METHODS.GET_CONVERSATIONS: {
877
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
878
- const convos = this.sphere.communications.getConversations();
1401
+ const comms = sphere.communications;
1402
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1403
+ const convos = comms.getConversations();
879
1404
  const result = [];
880
1405
  const needsResolve = [];
881
1406
  for (const [peer, messages] of convos) {
@@ -887,7 +1412,7 @@ var ConnectHost = class {
887
1412
  peerPubkey: peer,
888
1413
  peerNametag,
889
1414
  lastMessage: last,
890
- unreadCount: this.sphere.communications.getUnreadCount(peer),
1415
+ unreadCount: comms.getUnreadCount(peer),
891
1416
  messageCount: messages.length
892
1417
  });
893
1418
  if (!peerNametag) {
@@ -897,7 +1422,7 @@ var ConnectHost = class {
897
1422
  if (needsResolve.length > 0) {
898
1423
  const resolved = await Promise.all(
899
1424
  needsResolve.map(
900
- ({ peerPubkey }) => this.sphere.communications.resolvePeerNametag(peerPubkey).catch((err) => {
1425
+ ({ peerPubkey }) => comms.resolvePeerNametag(peerPubkey).catch((err) => {
901
1426
  logger.debug("Connect", "Peer Unicity ID resolution failed", err);
902
1427
  return void 0;
903
1428
  })
@@ -913,9 +1438,10 @@ var ConnectHost = class {
913
1438
  return result;
914
1439
  }
915
1440
  case RPC_METHODS.GET_MESSAGES: {
916
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1441
+ const comms = sphere.communications;
1442
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
917
1443
  if (!params.peerPubkey) throw new SphereError("Missing required parameter: peerPubkey", "VALIDATION_ERROR");
918
- return this.sphere.communications.getConversationPage(
1444
+ return comms.getConversationPage(
919
1445
  params.peerPubkey,
920
1446
  {
921
1447
  limit: params.limit,
@@ -924,23 +1450,26 @@ var ConnectHost = class {
924
1450
  );
925
1451
  }
926
1452
  case RPC_METHODS.GET_DM_UNREAD_COUNT: {
927
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1453
+ const comms = sphere.communications;
1454
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
928
1455
  return {
929
- unreadCount: this.sphere.communications.getUnreadCount(
1456
+ unreadCount: comms.getUnreadCount(
930
1457
  params.peerPubkey
931
1458
  )
932
1459
  };
933
1460
  }
934
1461
  case RPC_METHODS.MARK_AS_READ: {
935
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1462
+ const comms = sphere.communications;
1463
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
936
1464
  if (!params.messageIds || !Array.isArray(params.messageIds)) {
937
1465
  throw new SphereError("Missing required parameter: messageIds (string[])", "VALIDATION_ERROR");
938
1466
  }
939
- await this.sphere.communications.markAsRead(params.messageIds);
1467
+ await comms.markAsRead(params.messageIds);
940
1468
  return { marked: true, count: params.messageIds.length };
941
1469
  }
942
1470
  case RPC_METHODS.GET_INVOICES: {
943
- if (!this.sphere.accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
1471
+ const accounting = sphere.accounting;
1472
+ if (!accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
944
1473
  const invoiceOpts = {};
945
1474
  if (params.state !== void 0) invoiceOpts.state = params.state;
946
1475
  if (params.limit !== void 0) invoiceOpts.limit = params.limit;
@@ -949,14 +1478,15 @@ var ConnectHost = class {
949
1478
  if (params.sortOrder !== void 0) invoiceOpts.sortOrder = params.sortOrder;
950
1479
  if (params.createdByMe !== void 0) invoiceOpts.createdByMe = params.createdByMe;
951
1480
  if (params.targetingMe !== void 0) invoiceOpts.targetingMe = params.targetingMe;
952
- return this.sphere.accounting.getInvoices(invoiceOpts);
1481
+ return accounting.getInvoices(invoiceOpts);
953
1482
  }
954
1483
  case RPC_METHODS.GET_INVOICE_STATUS: {
955
- if (!this.sphere.accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
1484
+ const accounting = sphere.accounting;
1485
+ if (!accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
956
1486
  if (!params.invoiceId || typeof params.invoiceId !== "string") {
957
1487
  throw new SphereError("Missing required parameter: invoiceId", "VALIDATION_ERROR");
958
1488
  }
959
- return this.sphere.accounting.getInvoiceStatus(params.invoiceId);
1489
+ return accounting.getInvoiceStatus(params.invoiceId);
960
1490
  }
961
1491
  default:
962
1492
  throw new SphereError(`Unknown method: ${method}`, "VALIDATION_ERROR");
@@ -967,17 +1497,24 @@ var ConnectHost = class {
967
1497
  // ===========================================================================
968
1498
  autoSubscribeIdentityChanged() {
969
1499
  if (this.eventSubscriptions.has(WALLET_EVENTS.IDENTITY_CHANGED)) return;
970
- const unsub = this.sphere.on("identity:changed", (data) => {
1500
+ const unsub = this.requireSphere().on("identity:changed", (data) => {
971
1501
  this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, data);
972
1502
  });
973
1503
  this.eventSubscriptions.set(WALLET_EVENTS.IDENTITY_CHANGED, unsub);
974
1504
  }
975
1505
  handleSubscribe(eventName) {
976
1506
  if (!eventName) throw new SphereError("Missing required parameter: event", "VALIDATION_ERROR");
1507
+ if (isAutoPushedEvent(eventName)) {
1508
+ return { subscribed: true, event: eventName };
1509
+ }
1510
+ if (this._walletState === "locked") {
1511
+ this.suspendedSubscriptions.add(eventName);
1512
+ return { subscribed: true, event: eventName };
1513
+ }
977
1514
  if (this.eventSubscriptions.has(eventName)) {
978
1515
  return { subscribed: true, event: eventName };
979
1516
  }
980
- const unsub = this.sphere.on(eventName, (data) => {
1517
+ const unsub = this.requireSphere().on(eventName, (data) => {
981
1518
  this.transport.send({
982
1519
  ns: SPHERE_CONNECT_NAMESPACE,
983
1520
  v: SPHERE_CONNECT_VERSION,
@@ -996,6 +1533,7 @@ var ConnectHost = class {
996
1533
  unsub();
997
1534
  this.eventSubscriptions.delete(eventName);
998
1535
  }
1536
+ this.suspendedSubscriptions.delete(eventName);
999
1537
  return { unsubscribed: true, event: eventName };
1000
1538
  }
1001
1539
  cleanupEventSubscriptions() {
@@ -1017,8 +1555,90 @@ var ConnectHost = class {
1017
1555
  data
1018
1556
  });
1019
1557
  }
1558
+ /** The bound Sphere, or a typed refusal. The ONLY way the router may reach Sphere.
1559
+ * Unreachable in practice — the gate guarantees 'live' before the router is entered —
1560
+ * so this is defence in depth, not the primary mechanism. */
1561
+ requireSphere() {
1562
+ if (!this.sphere) {
1563
+ throw new SphereError(
1564
+ this._walletState === "locked" ? WALLET_LOCKED_MESSAGE : "Wallet unavailable",
1565
+ "NOT_INITIALIZED"
1566
+ );
1567
+ }
1568
+ return this.sphere;
1569
+ }
1570
+ /** SNAPSHOT read. `undefined` means "we never saw an identity": callers MUST refuse,
1571
+ * never answer undefined-as-success — a dApp reads that as "the wallet has no
1572
+ * identity". Two explicit methods instead of one dual-mode method, so nobody can serve
1573
+ * a snapshot believing it is live. */
1574
+ snapshotIdentity() {
1575
+ return this.snapshot.identity;
1576
+ }
1577
+ /** InFlightRegistry.onExpire sink. Filled in a later task; declared here so the
1578
+ * constructor can wire it. */
1579
+ settleExpired(entry) {
1580
+ logger.warn(
1581
+ "ConnectHost",
1582
+ `Host deadline reached, answering on our own: ${entry.kind} ${entry.id} (origin=${this.config.origin ?? "unverified"})`
1583
+ );
1584
+ if (entry.kind === "query") {
1585
+ this.sendError(entry.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1586
+ } else {
1587
+ this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);
1588
+ }
1589
+ }
1590
+ /** Answer every request already in flight with one coded frame each. A request in flight
1591
+ * when the Sphere goes away otherwise answers -32603 with a raw JS message, returns
1592
+ * `undefined` AS SUCCESS (sphere_getIdentity), or — for a delegated intent — never
1593
+ * answers at all until the client's own 120 s timeout. */
1594
+ settleInFlight(code, message, data) {
1595
+ for (const entry of this.inFlight.settleAll()) {
1596
+ if (entry.kind === "query") {
1597
+ this.sendError(entry.id, code, message, data);
1598
+ continue;
1599
+ }
1600
+ this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);
1601
+ }
1602
+ }
1603
+ /**
1604
+ * Notify-only. The host has ALREADY answered and never waits for the wallet.
1605
+ *
1606
+ * The wallet's only permitted reaction is a PASSIVE badge in its PERMANENT chrome; a
1607
+ * dApp request may trigger a CONSENT prompt but never a credential prompt. Volume is
1608
+ * bounded by checkRateLimit(), which guards all three entry points — there is no
1609
+ * coalescing, no cooldown and no cap by design.
1610
+ *
1611
+ * A throwing handler must not break the host.
1612
+ */
1613
+ notifyLockedRequest(kind, name) {
1614
+ try {
1615
+ this.config.onLockedRequest?.({ origin: this.config.origin, kind, name });
1616
+ } catch (err) {
1617
+ logger.warn("ConnectHost", "onLockedRequest handler error", err);
1618
+ }
1619
+ }
1620
+ /** Last-resort answer for a handler that threw before its own catch could run.
1621
+ * Id-bearing frames get a coded error (InFlightRegistry guarantees exactly one answer
1622
+ * per id); a handshake gets today's empty refusal, because a failed handshake must
1623
+ * reveal nothing. */
1624
+ sendUnhandledError(msg, error) {
1625
+ if (msg.type === "request") {
1626
+ if (!this.inFlight.settle(msg.id) && this.inFlight.has(msg.id)) return;
1627
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1628
+ return;
1629
+ }
1630
+ if (msg.type === "intent") {
1631
+ this.inFlight.settle(msg.id);
1632
+ this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1633
+ return;
1634
+ }
1635
+ if (msg.type === "handshake" && msg.direction === "request") {
1636
+ logger.warn("ConnectHost", "Handshake handler threw; sending the empty refusal", error);
1637
+ this.sendHandshakeResponse([], void 0, void 0);
1638
+ }
1639
+ }
1020
1640
  getPublicIdentity() {
1021
- const id = this.sphere.identity;
1641
+ const id = this.requireSphere().identity;
1022
1642
  if (!id) return void 0;
1023
1643
  return {
1024
1644
  chainPubkey: id.chainPubkey,
@@ -1042,13 +1662,13 @@ var ConnectHost = class {
1042
1662
  result
1043
1663
  });
1044
1664
  }
1045
- sendError(id, code, message) {
1665
+ sendError(id, code, message, data) {
1046
1666
  this.transport.send({
1047
1667
  ns: SPHERE_CONNECT_NAMESPACE,
1048
1668
  v: SPHERE_CONNECT_VERSION,
1049
1669
  type: "response",
1050
1670
  id,
1051
- error: { code, message }
1671
+ error: { code, message, ...data !== void 0 ? { data } : {} }
1052
1672
  });
1053
1673
  }
1054
1674
  sendIntentResult(id, result) {
@@ -1060,13 +1680,13 @@ var ConnectHost = class {
1060
1680
  result
1061
1681
  });
1062
1682
  }
1063
- sendIntentError(id, code, message) {
1683
+ sendIntentError(id, code, message, data) {
1064
1684
  this.transport.send({
1065
1685
  ns: SPHERE_CONNECT_NAMESPACE,
1066
1686
  v: SPHERE_CONNECT_VERSION,
1067
1687
  type: "intent_result",
1068
1688
  id,
1069
- error: { code, message }
1689
+ error: { code, message, ...data !== void 0 ? { data } : {} }
1070
1690
  });
1071
1691
  }
1072
1692
  checkRateLimit() {
@@ -1105,6 +1725,8 @@ var ConnectClient = class {
1105
1725
  grantedPermissions = [];
1106
1726
  identity = null;
1107
1727
  walletNet = null;
1728
+ walletProto = null;
1729
+ locked = false;
1108
1730
  connected = false;
1109
1731
  pendingRequests = /* @__PURE__ */ new Map();
1110
1732
  eventHandlers = /* @__PURE__ */ new Map();
@@ -1177,12 +1799,37 @@ var ConnectClient = class {
1177
1799
  get walletNetwork() {
1178
1800
  return this.walletNet;
1179
1801
  }
1802
+ /**
1803
+ * The wallet's Connect protocol version, captured from the handshake response `v`.
1804
+ * Null before the first handshake response and after a disconnect.
1805
+ *
1806
+ * Feature-detect with it: compare against '2.1' to decide whether the wallet can be
1807
+ * trusted to send wallet:unlocked / wallet:disconnected. A Connect 2.0 wallet destroys
1808
+ * the session on lock and never emits either, so a dApp waiting for them against one
1809
+ * waits forever.
1810
+ *
1811
+ * CAVEAT: on an ERROR response the host echoes the dApp's own `v` back
1812
+ * (ConnectHost.sendHandshakeResponse), so after a refused connection this may be the
1813
+ * dApp's version rather than the wallet's. Only trust it after a successful handshake.
1814
+ */
1815
+ get walletProtocol() {
1816
+ return this.walletProto;
1817
+ }
1818
+ /**
1819
+ * Whether the wallet was locked at the last handshake or lifecycle event.
1820
+ * A locked client is still CONNECTED: `isConnected` stays true and `session` stays valid.
1821
+ * Requests answer WALLET_LOCKED (4009) until `wallet:unlocked` arrives on the SAME
1822
+ * session — do not disconnect, do not clear the session, do not re-handshake.
1823
+ */
1824
+ get walletLocked() {
1825
+ return this.locked;
1826
+ }
1180
1827
  // ===========================================================================
1181
1828
  // Query (read data)
1182
1829
  // ===========================================================================
1183
1830
  /** Send a query request and return the result */
1184
1831
  async query(method, params) {
1185
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
1832
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
1186
1833
  const id = createRequestId();
1187
1834
  return new Promise((resolve, reject) => {
1188
1835
  const timer = setTimeout(() => {
@@ -1192,7 +1839,8 @@ var ConnectClient = class {
1192
1839
  this.pendingRequests.set(id, {
1193
1840
  resolve,
1194
1841
  reject,
1195
- timer
1842
+ timer,
1843
+ kind: "query"
1196
1844
  });
1197
1845
  this.transport.send({
1198
1846
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -1209,17 +1857,23 @@ var ConnectClient = class {
1209
1857
  // ===========================================================================
1210
1858
  /** Send an intent request. The wallet will open its UI for user confirmation. */
1211
1859
  async intent(action, params) {
1212
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
1860
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
1213
1861
  const id = createRequestId();
1214
1862
  return new Promise((resolve, reject) => {
1215
1863
  const timer = setTimeout(() => {
1216
1864
  this.pendingRequests.delete(id);
1217
- reject(new Error(`Intent timeout: ${action}`));
1865
+ reject(
1866
+ new ConnectError(
1867
+ `Intent outcome unknown \u2014 do not retry; reconcile before acting: ${action}`,
1868
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
1869
+ )
1870
+ );
1218
1871
  }, this.intentTimeout);
1219
1872
  this.pendingRequests.set(id, {
1220
1873
  resolve,
1221
1874
  reject,
1222
- timer
1875
+ timer,
1876
+ kind: "intent"
1223
1877
  });
1224
1878
  this.transport.send({
1225
1879
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -1238,7 +1892,7 @@ var ConnectClient = class {
1238
1892
  on(event, handler) {
1239
1893
  if (!this.eventHandlers.has(event)) {
1240
1894
  this.eventHandlers.set(event, /* @__PURE__ */ new Set());
1241
- if (this.connected) {
1895
+ if (this.connected && !isAutoPushedEvent(event)) {
1242
1896
  this.query(RPC_METHODS.SUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event subscription failed", err));
1243
1897
  }
1244
1898
  }
@@ -1249,7 +1903,7 @@ var ConnectClient = class {
1249
1903
  handlers.delete(handler);
1250
1904
  if (handlers.size === 0) {
1251
1905
  this.eventHandlers.delete(event);
1252
- if (this.connected) {
1906
+ if (this.connected && !isAutoPushedEvent(event)) {
1253
1907
  this.query(RPC_METHODS.UNSUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event unsubscription failed", err));
1254
1908
  }
1255
1909
  }
@@ -1273,15 +1927,36 @@ var ConnectClient = class {
1273
1927
  return;
1274
1928
  }
1275
1929
  if (msg.type === "event") {
1276
- const handlers = this.eventHandlers.get(msg.event);
1277
- if (handlers) {
1278
- for (const handler of handlers) {
1279
- try {
1280
- handler(msg.data);
1281
- } catch (err) {
1282
- logger.debug("Connect", "Event handler error", err);
1283
- }
1284
- }
1930
+ if (!this.connected || !this.sessionId) {
1931
+ logger.warn("Connect", `Ignoring wallet event before a session exists: ${msg.event}`);
1932
+ return;
1933
+ }
1934
+ if (msg.event === WALLET_EVENTS.LOCKED) {
1935
+ this.locked = true;
1936
+ } else if (msg.event === WALLET_EVENTS.UNLOCKED) {
1937
+ this.locked = false;
1938
+ const identity = msg.data?.identity;
1939
+ if (identity) this.identity = identity;
1940
+ } else if (msg.event === WALLET_EVENTS.DISCONNECTED) {
1941
+ this.connected = false;
1942
+ } else if (msg.event === WALLET_EVENTS.IDENTITY_CHANGED) {
1943
+ const data = msg.data;
1944
+ if (data && typeof data.chainPubkey === "string") this.identity = data;
1945
+ }
1946
+ this.dispatchEvent(msg.event, msg.data);
1947
+ if (msg.event === WALLET_EVENTS.DISCONNECTED) {
1948
+ this.cleanup();
1949
+ }
1950
+ }
1951
+ }
1952
+ dispatchEvent(event, data) {
1953
+ const handlers = this.eventHandlers.get(event);
1954
+ if (!handlers) return;
1955
+ for (const handler of handlers) {
1956
+ try {
1957
+ handler(data);
1958
+ } catch (err) {
1959
+ logger.debug("Connect", "Event handler error", err);
1285
1960
  }
1286
1961
  }
1287
1962
  }
@@ -1289,6 +1964,7 @@ var ConnectClient = class {
1289
1964
  if (!this.handshakeResolver) return;
1290
1965
  clearTimeout(this.handshakeResolver.timer);
1291
1966
  const m = msg;
1967
+ this.walletProto = msg.v ?? null;
1292
1968
  if (m.error) {
1293
1969
  this.handshakeResolver.reject(new ConnectError(m.error.message, m.error.code, m.error.data));
1294
1970
  this.handshakeResolver = null;
@@ -1299,12 +1975,16 @@ var ConnectClient = class {
1299
1975
  this.grantedPermissions = msg.permissions;
1300
1976
  this.identity = msg.identity;
1301
1977
  this.walletNet = m.network ?? null;
1978
+ this.locked = m.locked === true;
1302
1979
  this.connected = true;
1303
1980
  if (m.warning) logger.warn("Connect", "Wallet deprecation notice", m.warning.message);
1304
1981
  this.handshakeResolver.resolve({
1305
1982
  sessionId: msg.sessionId,
1306
1983
  permissions: this.grantedPermissions,
1307
- identity: msg.identity
1984
+ identity: msg.identity,
1985
+ // A resume DURING a lock succeeds: the dApp is connected on the same session and
1986
+ // must not re-handshake. It will get wallet:unlocked when the user unlocks.
1987
+ ...this.locked ? { locked: true } : {}
1308
1988
  });
1309
1989
  } else {
1310
1990
  this.handshakeResolver.reject(new Error("Connection rejected by wallet"));
@@ -1330,9 +2010,19 @@ var ConnectClient = class {
1330
2010
  this.unsubscribeTransport();
1331
2011
  this.unsubscribeTransport = null;
1332
2012
  }
2013
+ if (this.handshakeResolver) {
2014
+ clearTimeout(this.handshakeResolver.timer);
2015
+ this.handshakeResolver.reject(new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED));
2016
+ this.handshakeResolver = null;
2017
+ }
1333
2018
  for (const [, pending] of this.pendingRequests) {
1334
2019
  clearTimeout(pending.timer);
1335
- pending.reject(new Error("Disconnected"));
2020
+ pending.reject(
2021
+ pending.kind === "intent" ? new ConnectError(
2022
+ "Intent outcome unknown \u2014 do not retry; reconcile before acting",
2023
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
2024
+ ) : new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED)
2025
+ );
1336
2026
  }
1337
2027
  this.pendingRequests.clear();
1338
2028
  this.eventHandlers.clear();
@@ -1341,6 +2031,8 @@ var ConnectClient = class {
1341
2031
  this.grantedPermissions = [];
1342
2032
  this.identity = null;
1343
2033
  this.walletNet = null;
2034
+ this.walletProto = null;
2035
+ this.locked = false;
1344
2036
  }
1345
2037
  };
1346
2038
  //# sourceMappingURL=index.cjs.map