@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.
@@ -340,7 +340,7 @@ var HOST_READY_TIMEOUT = 3e4;
340
340
 
341
341
  // connect/protocol.ts
342
342
  var SPHERE_CONNECT_NAMESPACE = "sphere-connect";
343
- var SPHERE_CONNECT_VERSION = "2.0";
343
+ var SPHERE_CONNECT_VERSION = "2.1";
344
344
  var RPC_METHODS = {
345
345
  GET_IDENTITY: "sphere_getIdentity",
346
346
  GET_BALANCE: "sphere_getBalance",
@@ -394,19 +394,60 @@ var ERROR_CODES = {
394
394
  // Connect MAJOR mismatch (incompatible era)
395
395
  INCOMPATIBLE_NETWORK: 4008,
396
396
  // dApp targets a different network than the wallet
397
+ // Wallet locked; THE SESSION IS STILL ALIVE. A QUERY may be retried after wallet:unlocked.
398
+ // An INTENT already delegated to the wallet is NEVER answered with this code — it gets
399
+ // INTENT_OUTCOME_UNKNOWN (4201) instead, because a retry could double-spend.
400
+ WALLET_LOCKED: 4009,
397
401
  INSUFFICIENT_BALANCE: 4100,
398
402
  INVALID_RECIPIENT: 4101,
399
403
  TRANSFER_FAILED: 4102,
400
- INTENT_CANCELLED: 4200
404
+ INTENT_CANCELLED: 4200,
405
+ /**
406
+ * The intent was DELEGATED to the wallet and the host lost track of the answer — a host
407
+ * deadline fired, or the wallet locked / logged out mid-flight. **The outcome is UNKNOWN:
408
+ * the money may or may not have moved.**
409
+ *
410
+ * A dApp MUST NOT retry on this code. Reconcile out of band (poll the recipient, the
411
+ * aggregator, or your own backend) and only then decide.
412
+ *
413
+ * This code exists because every other answer would be a lie. `INTENT_CANCELLED` (4200)
414
+ * asserts the user declined and nothing happened; `WALLET_LOCKED` (4009) invites a retry
415
+ * after the unlock. Sending either for an intent the wallet had already submitted is how a
416
+ * paid-but-not-credited order — and then a double spend on retry — happens.
417
+ */
418
+ INTENT_OUTCOME_UNKNOWN: 4201
401
419
  };
402
420
  var WALLET_EVENTS = {
403
- /** Wallet locked or user logged out. dApp shows locked state and waits for unlock.
404
- * Pushed automatically by ConnectHost — no sphere_subscribe needed. */
421
+ /** Wallet is LOCKED — the session is STILL ALIVE. Requests are answered
422
+ * WALLET_LOCKED (4009) until `wallet:unlocked`. The dApp must NOT disconnect,
423
+ * must NOT clear its sessionId, and must NOT re-handshake.
424
+ * Payload: {@link WalletLockedPayload}. Pushed by ConnectHost.setLocked() and
425
+ * immediately after a handshake response carrying `locked: true`. */
405
426
  LOCKED: "wallet:locked",
427
+ /** Wallet was unlocked — the SAME session continues: no re-handshake, no re-approval,
428
+ * no re-subscribe (the host re-arms the dApp's subscriptions before pushing this).
429
+ * Payload: {@link WalletUnlockedPayload} — carries the CURRENT identity, which may
430
+ * differ from the one the dApp connected with. Pushed by ConnectHost.updateSphere()
431
+ * on the locked -> live edge only. */
432
+ UNLOCKED: "wallet:unlocked",
433
+ /** The session is GONE (logout, wallet deleted, dApp sphere_disconnect, expiry seen at
434
+ * unlock, a different seed behind the lock screen, host destroy).
435
+ * The dApp must clear its session and re-handshake to continue. Unlocking does not cure it.
436
+ * Payload: {@link WalletDisconnectedPayload}. Pushed by ConnectHost.revokeSession(). */
437
+ DISCONNECTED: "wallet:disconnected",
406
438
  /** Active wallet address changed. dApp should update displayed identity.
407
439
  * Pushed automatically by ConnectHost — no sphere_subscribe needed. */
408
440
  IDENTITY_CHANGED: "identity:changed"
409
441
  };
442
+ var AUTO_PUSHED_EVENTS = [
443
+ WALLET_EVENTS.LOCKED,
444
+ WALLET_EVENTS.UNLOCKED,
445
+ WALLET_EVENTS.DISCONNECTED,
446
+ WALLET_EVENTS.IDENTITY_CHANGED
447
+ ];
448
+ function isAutoPushedEvent(event) {
449
+ return AUTO_PUSHED_EVENTS.includes(event);
450
+ }
410
451
  function isSphereConnectMessage(msg) {
411
452
  if (!msg || typeof msg !== "object") return false;
412
453
  const m = msg;
@@ -465,7 +506,7 @@ function checkCompatibility(input) {
465
506
  }
466
507
 
467
508
  // connect/version.ts
468
- var SDK_VERSION = "0.12.0-dev.1";
509
+ var SDK_VERSION = "0.13.0";
469
510
 
470
511
  // connect/permissions.ts
471
512
  var PERMISSION_SCOPES = {
@@ -538,11 +579,188 @@ function validatePermissions(permissions) {
538
579
  return permissions.every((p) => validScopes.has(p));
539
580
  }
540
581
 
582
+ // connect/host/host-state.ts
583
+ var WALLET_LOCKED_MESSAGE = "Wallet is locked";
584
+ var INTERNAL_ERROR_MESSAGE = "Internal wallet error";
585
+ var INTENT_UNKNOWN_MESSAGE = "Intent outcome unknown \u2014 do not retry; reconcile before acting";
586
+ var NOT_CONNECTED_MESSAGE = "Not connected";
587
+ var VALID_WALLET_TRANSITIONS = {
588
+ live: ["locked", "unavailable"],
589
+ locked: ["live", "unavailable"],
590
+ unavailable: ["live", "locked"]
591
+ };
592
+ function isValidWalletTransition(from, to) {
593
+ return VALID_WALLET_TRANSITIONS[from].includes(to);
594
+ }
595
+ function assertWalletTransition(from, to) {
596
+ if (!isValidWalletTransition(from, to)) {
597
+ throw new SphereError(`Invalid wallet state transition: ${from} -> ${to}`, "VALIDATION_ERROR");
598
+ }
599
+ }
600
+ var LOCKED_ALLOWLIST = /* @__PURE__ */ new Set([
601
+ RPC_METHODS.GET_IDENTITY,
602
+ RPC_METHODS.SUBSCRIBE,
603
+ RPC_METHODS.UNSUBSCRIBE,
604
+ RPC_METHODS.DISCONNECT
605
+ ]);
606
+ var REFUSE_NOT_CONNECTED = {
607
+ kind: "refuse",
608
+ error: { code: ERROR_CODES.NOT_CONNECTED, message: NOT_CONNECTED_MESSAGE }
609
+ };
610
+ var REFUSE_LOCKED = {
611
+ kind: "refuse",
612
+ error: {
613
+ code: ERROR_CODES.WALLET_LOCKED,
614
+ message: WALLET_LOCKED_MESSAGE,
615
+ data: { reason: "locked" }
616
+ }
617
+ };
618
+ function gate(walletState, hasActiveSession, requestKind, name) {
619
+ if (walletState === "unavailable") return REFUSE_NOT_CONNECTED;
620
+ if (requestKind === "handshake") {
621
+ return walletState === "locked" ? { kind: "serve-from-snapshot" } : { kind: "serve" };
622
+ }
623
+ if (!hasActiveSession) return REFUSE_NOT_CONNECTED;
624
+ if (walletState === "live") return { kind: "serve" };
625
+ if (requestKind === "query" && LOCKED_ALLOWLIST.has(name)) {
626
+ return name === RPC_METHODS.GET_IDENTITY ? { kind: "serve-from-snapshot" } : { kind: "serve" };
627
+ }
628
+ return REFUSE_LOCKED;
629
+ }
630
+
631
+ // connect/host/WalletSnapshot.ts
632
+ var EMPTY_WALLET_SNAPSHOT = Object.freeze({ capturedAt: 0 });
633
+ function buildWalletSnapshot(sphere) {
634
+ if (!sphere) return EMPTY_WALLET_SNAPSHOT;
635
+ const id = sphere.identity;
636
+ return Object.freeze({
637
+ ...typeof sphere.networkId === "number" ? { networkId: sphere.networkId } : {},
638
+ ...id ? {
639
+ identity: Object.freeze({
640
+ chainPubkey: id.chainPubkey,
641
+ directAddress: id.directAddress,
642
+ nametag: id.nametag
643
+ })
644
+ } : {},
645
+ capturedAt: Date.now()
646
+ });
647
+ }
648
+
649
+ // connect/host/InFlightRegistry.ts
650
+ var InFlightRegistry = class {
651
+ slots = /* @__PURE__ */ new Map();
652
+ onExpire;
653
+ constructor(options) {
654
+ this.onExpire = options.onExpire;
655
+ }
656
+ get size() {
657
+ return this.slots.size;
658
+ }
659
+ has(id) {
660
+ return this.slots.has(id);
661
+ }
662
+ /** Register BEFORE the first await and arm the timer AT INSERTION TIME.
663
+ * A duplicate id is a protocol violation: warn and reuse the existing entry, so a
664
+ * hostile or buggy client cannot arm unbounded timers by replaying one id. */
665
+ add(id, kind, deadlineMs) {
666
+ const existing = this.slots.get(id);
667
+ if (existing) {
668
+ logger.warn("InFlightRegistry", `Duplicate request id, reusing entry: ${id}`);
669
+ return existing.entry;
670
+ }
671
+ const entry = {
672
+ id,
673
+ kind,
674
+ deadline: Date.now() + deadlineMs,
675
+ controller: new AbortController()
676
+ };
677
+ const timer = setTimeout(() => {
678
+ this.slots.delete(id);
679
+ entry.controller.abort();
680
+ this.onExpire(entry);
681
+ }, deadlineMs);
682
+ this.slots.set(id, { entry, timer });
683
+ return entry;
684
+ }
685
+ /** Remove + abort BEFORE the caller sends. Returns the entry, or null when it was
686
+ * already settled — in which case the caller MUST send nothing. */
687
+ settle(id) {
688
+ const slot = this.slots.get(id);
689
+ if (!slot) {
690
+ logger.warn("InFlightRegistry", `Already settled, dropping second answer: ${id}`);
691
+ return null;
692
+ }
693
+ this.slots.delete(id);
694
+ clearTimeout(slot.timer);
695
+ slot.entry.controller.abort();
696
+ return slot.entry;
697
+ }
698
+ /** Settle every entry, in insertion order, aborting each. The caller sends one frame per
699
+ * returned entry. Used by setLocked (4009), revokeSession (4001), destroy (4001). */
700
+ settleAll() {
701
+ const out = [];
702
+ for (const slot of this.slots.values()) {
703
+ clearTimeout(slot.timer);
704
+ slot.entry.controller.abort();
705
+ out.push(slot.entry);
706
+ }
707
+ this.slots.clear();
708
+ return out;
709
+ }
710
+ /** Clear all timers WITHOUT invoking onExpire. Host teardown only. */
711
+ destroy() {
712
+ for (const slot of this.slots.values()) clearTimeout(slot.timer);
713
+ this.slots.clear();
714
+ }
715
+ };
716
+
541
717
  // connect/host/ConnectHost.ts
542
718
  var DEFAULT_SESSION_TTL_MS = 864e5;
543
719
  var DEFAULT_MAX_RPS = 20;
720
+ var CHANNEL_ONLY_CODES = /* @__PURE__ */ new Set([
721
+ ERROR_CODES.WALLET_LOCKED,
722
+ ERROR_CODES.NOT_CONNECTED
723
+ ]);
724
+ var DEFAULT_REQUEST_DEADLINE_MS = 25e3;
725
+ var DEFAULT_INTENT_DEADLINE_MS = 18e4;
726
+ var DEFAULT_HANDSHAKE_DEADLINE_MS = 12e4;
727
+ function withDeadline(promise, ms, fallback) {
728
+ return new Promise((resolve, reject) => {
729
+ const timer = setTimeout(() => resolve(fallback()), ms);
730
+ promise.then(
731
+ (value) => {
732
+ clearTimeout(timer);
733
+ resolve(value);
734
+ },
735
+ (error) => {
736
+ clearTimeout(timer);
737
+ reject(error);
738
+ }
739
+ );
740
+ });
741
+ }
544
742
  var ConnectHost = class {
743
+ /** Null whenever _walletState is 'locked' or 'unavailable' (invariant B). */
545
744
  sphere;
745
+ /** The wallet-binding axis. Underscored because `walletState` is the public getter.
746
+ * ORTHOGONAL to `session` — a locked wallet keeps its session, a live wallet may have
747
+ * none. Written only by the WALLET (setLocked / setUnavailable / updateSphere / destroy);
748
+ * `session` is written by the dApp handshake, sphere_disconnect and expiry. */
749
+ _walletState;
750
+ /** Immutable public facts about the current binding. Refreshed on every bind
751
+ * (constructor, updateSphere); FROZEN by setLocked(); EMPTY after setUnavailable() and
752
+ * destroy(). Never read from Sphere while locked — that is a property of the types
753
+ * here, not of code review. */
754
+ snapshot;
755
+ /** Subscription KEYS captured by setLocked() BEFORE the unsub closures are detached.
756
+ * Sphere.destroy() kills those closures, so the keys are the only recoverable
757
+ * information. Excludes 'identity:changed' (autoSubscribeIdentityChanged re-arms it).
758
+ * A Set, not an array: handleSubscribe may be called twice for the same key while
759
+ * locked. */
760
+ suspendedSubscriptions = /* @__PURE__ */ new Set();
761
+ /** Every accepted id, with its own host-side timer. The single convergence point for
762
+ * lock / revoke / unavailable / destroy / deadline. */
763
+ inFlight;
546
764
  transport;
547
765
  config;
548
766
  session = null;
@@ -557,11 +775,33 @@ var ConnectHost = class {
557
775
  rateLimitResetAt = 0;
558
776
  unsubscribeTransport = null;
559
777
  constructor(config) {
560
- this.sphere = config.sphere;
561
778
  this.transport = config.transport;
562
779
  this.config = config;
780
+ this._walletState = config.initialWalletState ?? "live";
781
+ this.sphere = config.sphere ?? null;
782
+ if (this._walletState === "live" && !this.sphere) {
783
+ logger.warn(
784
+ "ConnectHost",
785
+ 'Constructed live with sphere === null; coercing to unavailable. Pass initialWalletState: "locked" when the wallet is locked at construction time.'
786
+ );
787
+ this._walletState = "unavailable";
788
+ }
789
+ if (this._walletState !== "live") this.sphere = null;
790
+ this.snapshot = buildWalletSnapshot(this.sphere);
791
+ this.inFlight = new InFlightRegistry({ onExpire: (e) => this.settleExpired(e) });
563
792
  this.unsubscribeTransport = this.transport.onMessage(this.handleMessage.bind(this));
564
793
  }
794
+ /** The wallet-binding axis. Orthogonal to {@link getSession}. Read-only —
795
+ * transitions go through setLocked() / setUnavailable() / updateSphere(). */
796
+ get walletState() {
797
+ return this._walletState;
798
+ }
799
+ /** Both axes in one read, for UI that must render "connected AND locked".
800
+ * Required by the wallet's ConnectPage, which today renders a green pulsing
801
+ * "Connected to {dapp}" with no regard for lock state. */
802
+ getState() {
803
+ return { walletState: this._walletState, session: this.session };
804
+ }
565
805
  /** Get current active session */
566
806
  getSession() {
567
807
  return this.session;
@@ -575,52 +815,212 @@ var ConnectHost = class {
575
815
  this.autoApprovedIntents.delete(action);
576
816
  }
577
817
  /**
578
- * Update the Sphere instance (e.g. user switched address — new Sphere created).
579
- * Re-subscribes auto-push events and notifies connected dApp of the new identity.
818
+ * Bind a (new) Sphere instance. This is BOTH the re-arm path after setLocked() /
819
+ * setUnavailable() AND the existing address-switch path in a live wallet.
820
+ *
821
+ * From 'live' (address switch): today's behaviour, unchanged — re-arm identity:changed,
822
+ * push identity:changed. NO identity comparison: an address switch is legal.
823
+ *
824
+ * On the 'locked' -> 'live' edge, in this order:
825
+ * 1. compare snapshot.identity?.chainPubkey with the new Sphere's chainPubkey.
826
+ * MISMATCH => revokeSession() (which pushes wallet:disconnected) and RETURN.
827
+ * Never wallet:unlocked. This is the "Forgot password -> restore recovery phrase
828
+ * installed a different seed behind an origin-keyed approval" guard.
829
+ * 2. session.expiresAt passed => revokeSession() and RETURN. A wallet:unlocked into a
830
+ * dead session would make the dApp's next request answer SESSION_EXPIRED 4004.
831
+ * 3. rebind, refresh the snapshot, go live, re-arm identity:changed, replay every
832
+ * suspended sphere_subscribe key, and ONLY THEN push wallet:unlocked with the
833
+ * CURRENT identity. Re-arm BEFORE push, so a dApp reacting synchronously cannot
834
+ * race its own event streams.
835
+ *
836
+ * From 'unavailable' -> 'live': rebind + refresh the snapshot, no identity check
837
+ * (nothing was bound to compare against) and no event (the session is already null).
580
838
  */
581
839
  updateSphere(newSphere) {
582
- this.sphere = newSphere;
583
- const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);
584
- if (existing) {
585
- existing();
586
- this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);
840
+ const wasLocked = this._walletState === "locked";
841
+ const next = newSphere ?? null;
842
+ if (!next) {
843
+ if (this._walletState === "locked") {
844
+ logger.warn("ConnectHost", "updateSphere(null) while locked \u2014 staying locked");
845
+ return;
846
+ }
847
+ logger.warn("ConnectHost", "updateSphere(null) \u2014 treating as a non-lock loss of Sphere");
848
+ this.setUnavailable();
849
+ return;
587
850
  }
588
- if (this.session?.active) {
589
- this.autoSubscribeIdentityChanged();
590
- const identity = this.getPublicIdentity();
591
- if (identity) {
592
- this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);
851
+ if (this._walletState === "live") {
852
+ this.sphere = next;
853
+ this.snapshot = buildWalletSnapshot(next);
854
+ const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);
855
+ if (existing) {
856
+ existing();
857
+ this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);
858
+ }
859
+ if (this.session?.active) {
860
+ this.autoSubscribeIdentityChanged();
861
+ const identity2 = this.getPublicIdentity();
862
+ if (identity2) {
863
+ this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity2);
864
+ }
865
+ }
866
+ return;
867
+ }
868
+ if (wasLocked && this.session?.active) {
869
+ const before = this.snapshot.identity?.chainPubkey ?? null;
870
+ const after = next.identity?.chainPubkey ?? null;
871
+ const netBefore = this.snapshot.networkId ?? null;
872
+ const netAfter = next.networkId ?? null;
873
+ if (before !== after || netBefore !== netAfter) {
874
+ logger.warn(
875
+ "ConnectHost",
876
+ `Wallet behind the lock screen is not the one this session was approved for \u2014 revoking instead of unlocking (origin=${this.config.origin ?? "unverified"})`
877
+ );
878
+ assertWalletTransition(this._walletState, "live");
879
+ this._walletState = "live";
880
+ this.sphere = next;
881
+ this.snapshot = buildWalletSnapshot(next);
882
+ this.revokeSession();
883
+ return;
884
+ }
885
+ if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
886
+ logger.warn(
887
+ "ConnectHost",
888
+ `Session expired while locked \u2014 re-handshake required (origin=${this.config.origin ?? "unverified"})`
889
+ );
890
+ assertWalletTransition(this._walletState, "live");
891
+ this._walletState = "live";
892
+ this.sphere = next;
893
+ this.snapshot = buildWalletSnapshot(next);
894
+ this.revokeSession();
895
+ return;
896
+ }
897
+ }
898
+ assertWalletTransition(this._walletState, "live");
899
+ this.sphere = next;
900
+ this.snapshot = buildWalletSnapshot(next);
901
+ this._walletState = "live";
902
+ if (!this.session?.active) {
903
+ this.suspendedSubscriptions.clear();
904
+ return;
905
+ }
906
+ this.autoSubscribeIdentityChanged();
907
+ const suspended = [...this.suspendedSubscriptions];
908
+ this.suspendedSubscriptions.clear();
909
+ for (const eventName of suspended) {
910
+ try {
911
+ this.handleSubscribe(eventName);
912
+ } catch (err) {
913
+ logger.warn("ConnectHost", `Re-subscribe failed after unlock: ${eventName}`, err);
593
914
  }
594
915
  }
916
+ logger.debug(
917
+ "ConnectHost",
918
+ `Wallet unlocked \u2014 re-armed ${suspended.length} subscription(s) (origin=${this.config.origin ?? "unverified"})`
919
+ );
920
+ this.pushClientEvent(WALLET_EVENTS.UNLOCKED, {
921
+ identity: this.getPublicIdentity()
922
+ });
923
+ const identity = this.getPublicIdentity();
924
+ if (identity) this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);
595
925
  }
596
- /** Revoke the current session */
926
+ /**
927
+ * The wallet locked (manual lock, idle auto-lock, cross-tab broadcast, cold start).
928
+ * The session is PRESERVED — a lock is a state, not a teardown. Every request outside
929
+ * the locked allow-list is answered WALLET_LOCKED (4009) until updateSphere().
930
+ *
931
+ * Idempotent: a second call is a no-op and pushes nothing. Required, because
932
+ * SphereProvider.lock(), ConnectPage's `sphere → null` effect and broadcastLock()'s
933
+ * same-tab loopback can all fire it for one user action.
934
+ *
935
+ * ORDERING CONTRACT: call this BEFORE sphere.destroy(). The host drops its Sphere
936
+ * reference here; destroying first leaves in-flight requests reading a dead instance
937
+ * (-32603, or `undefined` returned AS SUCCESS from sphere_getIdentity).
938
+ */
939
+ setLocked() {
940
+ if (this._walletState === "locked") return;
941
+ assertWalletTransition(this._walletState, "locked");
942
+ this.snapshot = buildWalletSnapshot(this.sphere);
943
+ this._walletState = "locked";
944
+ logger.debug(
945
+ "ConnectHost",
946
+ `Wallet locked \u2014 session preserved (origin=${this.config.origin ?? "unverified"}, session=${this.session?.id ?? "none"})`
947
+ );
948
+ if (this.session?.active) {
949
+ this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
950
+ }
951
+ for (const key of this.eventSubscriptions.keys()) {
952
+ if (key === WALLET_EVENTS.IDENTITY_CHANGED) continue;
953
+ this.suspendedSubscriptions.add(key);
954
+ }
955
+ this.cleanupEventSubscriptions();
956
+ this.autoApprovedIntents.clear();
957
+ this.settleInFlight(ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: "locked" });
958
+ this.sphere = null;
959
+ }
960
+ /**
961
+ * The Sphere instance is gone for a NON-LOCK reason (a generic init failure leaves
962
+ * `sphere === null, isLocked === false` in the wallet).
963
+ * This is a DEAD END: unlocking does not cure it, so it revokes the session and pushes
964
+ * wallet:disconnected rather than promising an unlock that cannot help.
965
+ * Subsequent requests answer NOT_CONNECTED (4001); handshakes get the empty refusal
966
+ * WITHOUT dereferencing a null Sphere.
967
+ *
968
+ * Idempotent. Pushes no 'wallet:unavailable' — there is no such event.
969
+ */
970
+ setUnavailable() {
971
+ if (this._walletState === "unavailable") return;
972
+ assertWalletTransition(this._walletState, "unavailable");
973
+ this._walletState = "unavailable";
974
+ logger.warn(
975
+ "ConnectHost",
976
+ `Sphere unavailable (non-lock) \u2014 session revoked (origin=${this.config.origin ?? "unverified"})`
977
+ );
978
+ this.snapshot = EMPTY_WALLET_SNAPSHOT;
979
+ this.suspendedSubscriptions.clear();
980
+ this.revokeSession();
981
+ this.sphere = null;
982
+ }
983
+ /**
984
+ * Destroy the SESSION (logout, wallet deleted, dApp sphere_disconnect, popup
985
+ * beforeunload, expiry, identity mismatch at unlock). Pushes wallet:disconnected BEFORE
986
+ * tearing down, so the dApp stops believing it is connected instead of finding out at
987
+ * its next 4001.
988
+ *
989
+ * This is the TEARDOWN verb. For a lock use setLocked() — a lock never destroys the
990
+ * session. revokeSession() does NOT touch walletState: the two axes are orthogonal.
991
+ */
597
992
  revokeSession() {
598
993
  if (this.session) {
994
+ logger.debug(
995
+ "ConnectHost",
996
+ `Session revoked (origin=${this.config.origin ?? "unverified"}, session=${this.session.id})`
997
+ );
998
+ if (this.session.active) {
999
+ this.pushClientEvent(WALLET_EVENTS.DISCONNECTED, {});
1000
+ }
599
1001
  this.session.active = false;
600
1002
  this.cleanupEventSubscriptions();
601
1003
  this.autoApprovedIntents.clear();
602
1004
  this.session = null;
603
1005
  this.grantedPermissions.clear();
604
1006
  }
1007
+ this.suspendedSubscriptions.clear();
1008
+ this.settleInFlight(ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
605
1009
  }
606
- /**
607
- * Notify connected dApp that wallet is locked/logged out, then revoke session.
608
- * Call this BEFORE destroy() when the wallet locks so the dApp gets a clean signal
609
- * instead of receiving NOT_CONNECTED errors on the next request.
610
- */
611
- notifyWalletLocked() {
612
- if (this.session?.active) {
613
- this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
614
- }
615
- this.revokeSession();
616
- }
617
- /** Destroy the host, clean up all resources */
1010
+ /** Destroy the host, clean up all resources. Idempotent. */
618
1011
  destroy() {
619
1012
  this.revokeSession();
1013
+ this.inFlight.destroy();
620
1014
  if (this.unsubscribeTransport) {
621
1015
  this.unsubscribeTransport();
622
1016
  this.unsubscribeTransport = null;
623
1017
  }
1018
+ this.sphere = null;
1019
+ this.snapshot = EMPTY_WALLET_SNAPSHOT;
1020
+ if (this._walletState !== "unavailable") {
1021
+ assertWalletTransition(this._walletState, "unavailable");
1022
+ this._walletState = "unavailable";
1023
+ }
624
1024
  }
625
1025
  // ===========================================================================
626
1026
  // Message Handling
@@ -641,6 +1041,7 @@ var ConnectHost = class {
641
1041
  }
642
1042
  } catch (error) {
643
1043
  logger.warn("ConnectHost", "Error handling message:", error);
1044
+ this.sendUnhandledError(msg, error);
644
1045
  }
645
1046
  }
646
1047
  // ===========================================================================
@@ -652,11 +1053,27 @@ var ConnectHost = class {
652
1053
  this.sendHandshakeResponse([], void 0, void 0);
653
1054
  return;
654
1055
  }
1056
+ if (this._walletState !== "live" && !this.snapshot.identity) {
1057
+ this.sendHandshakeResponse([], void 0, void 0);
1058
+ return;
1059
+ }
1060
+ if (!this.checkRateLimit()) {
1061
+ logger.warn("ConnectHost", "Handshake rate-limited", { dapp: dapp.name });
1062
+ this.sendHandshakeResponse([], void 0, void 0);
1063
+ return;
1064
+ }
1065
+ const stateAtPrompt = this._walletState;
1066
+ let locked = stateAtPrompt === "locked";
1067
+ const handshakeDecision = gate(this._walletState, !!this.session?.active, "handshake", "handshake");
1068
+ if (handshakeDecision.kind === "refuse") {
1069
+ this.sendHandshakeResponse([], void 0, void 0);
1070
+ return;
1071
+ }
655
1072
  const result = checkCompatibility({
656
1073
  clientProtocol: msg.v,
657
1074
  walletProtocol: SPHERE_CONNECT_VERSION,
658
1075
  clientNetwork: msg.network,
659
- walletNetworkId: this.sphere.networkId ?? -1,
1076
+ walletNetworkId: this.snapshot.networkId ?? -1,
660
1077
  minMinor: this.config.minMinorVersion,
661
1078
  clientSdkVersion: msg.sdkVersion,
662
1079
  minSdkVersion: this.config.minSdkVersion
@@ -668,7 +1085,7 @@ var ConnectHost = class {
668
1085
  clientProtocol: msg.v,
669
1086
  walletProtocol: SPHERE_CONNECT_VERSION,
670
1087
  clientNetwork: msg.network ?? null,
671
- walletNetwork: this.sphere.networkId ?? null
1088
+ walletNetwork: this.snapshot.networkId ?? null
672
1089
  });
673
1090
  this.config.onConnectionRejected?.(dapp, result.error, !!msg.silent);
674
1091
  this.sendHandshakeResponse([], void 0, void 0, result.error, msg.v);
@@ -676,17 +1093,44 @@ var ConnectHost = class {
676
1093
  }
677
1094
  const clientInfo = { protocolVersion: msg.v, network: msg.network, sdkVersion: msg.sdkVersion };
678
1095
  if (msg.sessionId && this.session?.active && this.session.id === msg.sessionId) {
679
- const identity2 = this.getPublicIdentity();
680
- this.sendHandshakeResponse([...this.grantedPermissions], this.session.id, identity2);
1096
+ const identity2 = locked ? this.snapshotIdentity() : this.getPublicIdentity();
1097
+ this.sendHandshakeResponse(
1098
+ [...this.grantedPermissions],
1099
+ this.session.id,
1100
+ identity2,
1101
+ void 0,
1102
+ void 0,
1103
+ void 0,
1104
+ locked ? true : void 0
1105
+ );
1106
+ if (locked) {
1107
+ this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
1108
+ this.notifyLockedRequest("handshake", "handshake");
1109
+ }
681
1110
  return;
682
1111
  }
683
1112
  const requestedPermissions = msg.permissions;
684
- const { approved, grantedPermissions } = await this.config.onConnectionRequest(
685
- dapp,
686
- requestedPermissions,
687
- msg.silent,
688
- clientInfo
1113
+ const silent = msg.silent === true || locked;
1114
+ const { approved, grantedPermissions } = await withDeadline(
1115
+ Promise.resolve(
1116
+ this.config.onConnectionRequest(dapp, requestedPermissions, silent, clientInfo)
1117
+ ),
1118
+ this.config.handshakeDeadlineMs ?? DEFAULT_HANDSHAKE_DEADLINE_MS,
1119
+ () => {
1120
+ logger.warn("ConnectHost", "Connection approval prompt timed out", { dapp: dapp.name });
1121
+ return { approved: false, grantedPermissions: [] };
1122
+ }
689
1123
  );
1124
+ const stateAfterPrompt = this._walletState;
1125
+ if (stateAfterPrompt !== "live" && stateAfterPrompt !== stateAtPrompt) {
1126
+ logger.warn(
1127
+ "ConnectHost",
1128
+ `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"})`
1129
+ );
1130
+ this.sendHandshakeResponse([], void 0, void 0);
1131
+ return;
1132
+ }
1133
+ locked = stateAfterPrompt !== "live";
690
1134
  if (!approved) {
691
1135
  this.sendHandshakeResponse([], void 0, void 0);
692
1136
  return;
@@ -703,14 +1147,23 @@ var ConnectHost = class {
703
1147
  active: true
704
1148
  };
705
1149
  this.grantedPermissions = new Set(allPermissions);
706
- this.autoSubscribeIdentityChanged();
707
- const identity = this.getPublicIdentity();
708
- this.sendHandshakeResponse(allPermissions, sessionId, identity);
1150
+ if (!locked) this.autoSubscribeIdentityChanged();
1151
+ const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();
1152
+ this.sendHandshakeResponse(
1153
+ allPermissions,
1154
+ sessionId,
1155
+ identity,
1156
+ void 0,
1157
+ void 0,
1158
+ void 0,
1159
+ locked ? true : void 0
1160
+ );
1161
+ if (locked) this.pushClientEvent(WALLET_EVENTS.LOCKED, {});
709
1162
  }
710
1163
  // `warning` is a forward-compatible deprecation-notice slot (see SphereHandshake.warning);
711
1164
  // no call site emits one yet — reserved for the deprecation-window policy.
712
- sendHandshakeResponse(permissions, sessionId, identity, error, echoV, warning) {
713
- const network = typeof this.sphere.networkId === "number" ? { id: this.sphere.networkId } : void 0;
1165
+ sendHandshakeResponse(permissions, sessionId, identity, error, echoV, warning, locked) {
1166
+ const network = typeof this.snapshot.networkId === "number" ? { id: this.snapshot.networkId } : void 0;
714
1167
  this.transport.send({
715
1168
  ns: SPHERE_CONNECT_NAMESPACE,
716
1169
  v: error && echoV ? echoV : SPHERE_CONNECT_VERSION,
@@ -722,7 +1175,8 @@ var ConnectHost = class {
722
1175
  network,
723
1176
  sdkVersion: SDK_VERSION,
724
1177
  error,
725
- warning
1178
+ warning,
1179
+ ...locked ? { locked: true } : {}
726
1180
  });
727
1181
  }
728
1182
  // ===========================================================================
@@ -730,12 +1184,12 @@ var ConnectHost = class {
730
1184
  // ===========================================================================
731
1185
  async handleRpcRequest(msg) {
732
1186
  if (!this.session?.active) {
733
- this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, "Not connected");
1187
+ this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
734
1188
  return;
735
1189
  }
736
1190
  if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
737
- this.revokeSession();
738
1191
  this.sendError(msg.id, ERROR_CODES.SESSION_EXPIRED, "Session expired");
1192
+ this.revokeSession();
739
1193
  return;
740
1194
  }
741
1195
  if (!this.checkRateLimit()) {
@@ -744,22 +1198,50 @@ var ConnectHost = class {
744
1198
  }
745
1199
  if (msg.method === RPC_METHODS.DISCONNECT) {
746
1200
  const disconnectedSession = this.session;
747
- this.revokeSession();
748
1201
  this.sendResult(msg.id, { disconnected: true });
1202
+ this.revokeSession();
749
1203
  if (disconnectedSession && this.config.onDisconnect) {
750
1204
  Promise.resolve(this.config.onDisconnect(disconnectedSession)).catch((err) => logger.warn("Connect", "onDisconnect handler error", err));
751
1205
  }
752
1206
  return;
753
1207
  }
1208
+ const decision = gate(this._walletState, true, "query", msg.method);
1209
+ if (decision.kind === "refuse") {
1210
+ this.sendError(msg.id, decision.error.code, decision.error.message, decision.error.data);
1211
+ if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {
1212
+ logger.debug("ConnectHost", `Refused query ${msg.method} \u2014 WALLET_LOCKED 4009 (origin=${this.config.origin ?? "unverified"})`);
1213
+ this.notifyLockedRequest("query", msg.method);
1214
+ }
1215
+ return;
1216
+ }
754
1217
  if (!hasMethodPermission(this.grantedPermissions, msg.method)) {
755
1218
  this.sendError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for ${msg.method}`);
756
1219
  return;
757
1220
  }
1221
+ if (decision.kind === "serve-from-snapshot") {
1222
+ const identity = this.snapshotIdentity();
1223
+ if (!identity) {
1224
+ this.sendError(msg.id, ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: "locked" });
1225
+ this.notifyLockedRequest("query", msg.method);
1226
+ return;
1227
+ }
1228
+ this.sendResult(msg.id, identity);
1229
+ return;
1230
+ }
1231
+ this.inFlight.add(msg.id, "query", this.config.requestDeadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS);
758
1232
  try {
759
1233
  const result = await this.executeMethod(msg.method, msg.params ?? {});
1234
+ if (!this.inFlight.settle(msg.id)) return;
760
1235
  this.sendResult(msg.id, result);
761
1236
  } catch (error) {
762
- this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, error.message);
1237
+ if (!this.inFlight.settle(msg.id)) return;
1238
+ const isSphereError = error instanceof SphereError || error?.name === "SphereError";
1239
+ if (isSphereError) {
1240
+ const e = error;
1241
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, e.message, { reason: e.code });
1242
+ return;
1243
+ }
1244
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
763
1245
  }
764
1246
  }
765
1247
  // ===========================================================================
@@ -767,68 +1249,109 @@ var ConnectHost = class {
767
1249
  // ===========================================================================
768
1250
  async handleIntentRequest(msg) {
769
1251
  if (!this.session?.active) {
770
- this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, "Not connected");
1252
+ this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);
771
1253
  return;
772
1254
  }
773
1255
  if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {
774
- this.revokeSession();
775
1256
  this.sendIntentError(msg.id, ERROR_CODES.SESSION_EXPIRED, "Session expired");
1257
+ this.revokeSession();
1258
+ return;
1259
+ }
1260
+ if (!this.checkRateLimit()) {
1261
+ this.sendIntentError(msg.id, ERROR_CODES.RATE_LIMITED, "Too many requests");
1262
+ return;
1263
+ }
1264
+ const decision = gate(this._walletState, true, "intent", msg.action);
1265
+ if (decision.kind === "refuse") {
1266
+ this.sendIntentError(msg.id, decision.error.code, decision.error.message, decision.error.data);
1267
+ if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {
1268
+ logger.debug("ConnectHost", `Refused intent ${msg.action} \u2014 WALLET_LOCKED 4009 (origin=${this.config.origin ?? "unverified"})`);
1269
+ this.notifyLockedRequest("intent", msg.action);
1270
+ }
776
1271
  return;
777
1272
  }
778
1273
  if (!hasIntentPermission(this.grantedPermissions, msg.action)) {
779
1274
  this.sendIntentError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for intent: ${msg.action}`);
780
1275
  return;
781
1276
  }
782
- const autoHandler = this.autoApprovedIntents.get(msg.action);
783
- if (autoHandler) {
784
- const autoResponse = await autoHandler(msg.action, msg.params, this.session);
785
- if (autoResponse.error) {
786
- this.sendIntentError(msg.id, autoResponse.error.code, autoResponse.error.message);
1277
+ const session = this.session;
1278
+ const entry = this.inFlight.add(
1279
+ msg.id,
1280
+ "intent",
1281
+ this.config.intentDeadlineMs ?? DEFAULT_INTENT_DEADLINE_MS
1282
+ );
1283
+ const ctx = {
1284
+ origin: this.config.origin,
1285
+ expiresAt: entry.deadline,
1286
+ signal: entry.controller.signal
1287
+ };
1288
+ try {
1289
+ const autoHandler = this.autoApprovedIntents.get(msg.action);
1290
+ const response = autoHandler ? await autoHandler(msg.action, msg.params, session) : await this.config.onIntent(msg.action, msg.params, session, ctx);
1291
+ if (!this.inFlight.settle(msg.id)) return;
1292
+ if (response.error) {
1293
+ const asserts = CHANNEL_ONLY_CODES.has(response.error.code);
1294
+ if (asserts) {
1295
+ logger.warn(
1296
+ "ConnectHost",
1297
+ `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"})`
1298
+ );
1299
+ }
1300
+ this.sendIntentError(
1301
+ msg.id,
1302
+ asserts ? ERROR_CODES.INTENT_OUTCOME_UNKNOWN : response.error.code,
1303
+ asserts ? INTENT_UNKNOWN_MESSAGE : response.error.message
1304
+ );
787
1305
  } else {
788
- this.sendIntentResult(msg.id, autoResponse.result);
1306
+ this.sendIntentResult(msg.id, response.result);
789
1307
  }
790
- return;
791
- }
792
- const response = await this.config.onIntent(msg.action, msg.params, this.session);
793
- if (response.error) {
794
- this.sendIntentError(msg.id, response.error.code, response.error.message);
795
- } else {
796
- this.sendIntentResult(msg.id, response.result);
1308
+ } catch (error) {
1309
+ logger.warn("ConnectHost", `Intent handler threw: ${msg.action}`, error);
1310
+ if (!this.inFlight.settle(msg.id)) return;
1311
+ this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
797
1312
  }
798
1313
  }
799
1314
  // ===========================================================================
800
1315
  // Method Router
801
1316
  // ===========================================================================
802
1317
  async executeMethod(method, params) {
1318
+ switch (method) {
1319
+ case RPC_METHODS.SUBSCRIBE:
1320
+ return this.handleSubscribe(params.event);
1321
+ case RPC_METHODS.UNSUBSCRIBE:
1322
+ return this.handleUnsubscribe(params.event);
1323
+ }
1324
+ const sphere = this.requireSphere();
803
1325
  switch (method) {
804
1326
  case RPC_METHODS.GET_IDENTITY:
805
1327
  return this.getPublicIdentity();
806
1328
  case RPC_METHODS.GET_BALANCE:
807
- return this.sphere.payments.getBalance(params.coinId);
1329
+ return sphere.payments.getBalance(params.coinId);
808
1330
  case RPC_METHODS.GET_ASSETS:
809
- return this.sphere.payments.getAssets(params.coinId);
1331
+ return sphere.payments.getAssets(params.coinId);
810
1332
  case RPC_METHODS.GET_FIAT_BALANCE:
811
- return { fiatBalance: await this.sphere.payments.getFiatBalance() };
1333
+ return { fiatBalance: await sphere.payments.getFiatBalance() };
812
1334
  case RPC_METHODS.GET_TOKENS:
813
1335
  return this.stripTokenSdkData(
814
- this.sphere.payments.getTokens(
1336
+ sphere.payments.getTokens(
815
1337
  params.coinId ? { coinId: params.coinId } : void 0
816
1338
  )
817
1339
  );
818
1340
  case RPC_METHODS.GET_HISTORY:
819
- return this.sphere.payments.getHistory();
1341
+ return sphere.payments.getHistory();
820
1342
  case RPC_METHODS.RESOLVE:
821
1343
  if (!params.identifier) {
822
1344
  throw new SphereError("Missing required parameter: identifier", "VALIDATION_ERROR");
823
1345
  }
824
- return this.sphere.resolve(params.identifier);
1346
+ return sphere.resolve(params.identifier);
825
1347
  case RPC_METHODS.SUBSCRIBE:
826
1348
  return this.handleSubscribe(params.event);
827
1349
  case RPC_METHODS.UNSUBSCRIBE:
828
1350
  return this.handleUnsubscribe(params.event);
829
1351
  case RPC_METHODS.GET_CONVERSATIONS: {
830
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
831
- const convos = this.sphere.communications.getConversations();
1352
+ const comms = sphere.communications;
1353
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1354
+ const convos = comms.getConversations();
832
1355
  const result = [];
833
1356
  const needsResolve = [];
834
1357
  for (const [peer, messages] of convos) {
@@ -840,7 +1363,7 @@ var ConnectHost = class {
840
1363
  peerPubkey: peer,
841
1364
  peerNametag,
842
1365
  lastMessage: last,
843
- unreadCount: this.sphere.communications.getUnreadCount(peer),
1366
+ unreadCount: comms.getUnreadCount(peer),
844
1367
  messageCount: messages.length
845
1368
  });
846
1369
  if (!peerNametag) {
@@ -850,7 +1373,7 @@ var ConnectHost = class {
850
1373
  if (needsResolve.length > 0) {
851
1374
  const resolved = await Promise.all(
852
1375
  needsResolve.map(
853
- ({ peerPubkey }) => this.sphere.communications.resolvePeerNametag(peerPubkey).catch((err) => {
1376
+ ({ peerPubkey }) => comms.resolvePeerNametag(peerPubkey).catch((err) => {
854
1377
  logger.debug("Connect", "Peer Unicity ID resolution failed", err);
855
1378
  return void 0;
856
1379
  })
@@ -866,9 +1389,10 @@ var ConnectHost = class {
866
1389
  return result;
867
1390
  }
868
1391
  case RPC_METHODS.GET_MESSAGES: {
869
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1392
+ const comms = sphere.communications;
1393
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
870
1394
  if (!params.peerPubkey) throw new SphereError("Missing required parameter: peerPubkey", "VALIDATION_ERROR");
871
- return this.sphere.communications.getConversationPage(
1395
+ return comms.getConversationPage(
872
1396
  params.peerPubkey,
873
1397
  {
874
1398
  limit: params.limit,
@@ -877,23 +1401,26 @@ var ConnectHost = class {
877
1401
  );
878
1402
  }
879
1403
  case RPC_METHODS.GET_DM_UNREAD_COUNT: {
880
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1404
+ const comms = sphere.communications;
1405
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
881
1406
  return {
882
- unreadCount: this.sphere.communications.getUnreadCount(
1407
+ unreadCount: comms.getUnreadCount(
883
1408
  params.peerPubkey
884
1409
  )
885
1410
  };
886
1411
  }
887
1412
  case RPC_METHODS.MARK_AS_READ: {
888
- if (!this.sphere.communications) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
1413
+ const comms = sphere.communications;
1414
+ if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
889
1415
  if (!params.messageIds || !Array.isArray(params.messageIds)) {
890
1416
  throw new SphereError("Missing required parameter: messageIds (string[])", "VALIDATION_ERROR");
891
1417
  }
892
- await this.sphere.communications.markAsRead(params.messageIds);
1418
+ await comms.markAsRead(params.messageIds);
893
1419
  return { marked: true, count: params.messageIds.length };
894
1420
  }
895
1421
  case RPC_METHODS.GET_INVOICES: {
896
- if (!this.sphere.accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
1422
+ const accounting = sphere.accounting;
1423
+ if (!accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
897
1424
  const invoiceOpts = {};
898
1425
  if (params.state !== void 0) invoiceOpts.state = params.state;
899
1426
  if (params.limit !== void 0) invoiceOpts.limit = params.limit;
@@ -902,14 +1429,15 @@ var ConnectHost = class {
902
1429
  if (params.sortOrder !== void 0) invoiceOpts.sortOrder = params.sortOrder;
903
1430
  if (params.createdByMe !== void 0) invoiceOpts.createdByMe = params.createdByMe;
904
1431
  if (params.targetingMe !== void 0) invoiceOpts.targetingMe = params.targetingMe;
905
- return this.sphere.accounting.getInvoices(invoiceOpts);
1432
+ return accounting.getInvoices(invoiceOpts);
906
1433
  }
907
1434
  case RPC_METHODS.GET_INVOICE_STATUS: {
908
- if (!this.sphere.accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
1435
+ const accounting = sphere.accounting;
1436
+ if (!accounting) throw new SphereError("Accounting module not available", "MODULE_NOT_AVAILABLE");
909
1437
  if (!params.invoiceId || typeof params.invoiceId !== "string") {
910
1438
  throw new SphereError("Missing required parameter: invoiceId", "VALIDATION_ERROR");
911
1439
  }
912
- return this.sphere.accounting.getInvoiceStatus(params.invoiceId);
1440
+ return accounting.getInvoiceStatus(params.invoiceId);
913
1441
  }
914
1442
  default:
915
1443
  throw new SphereError(`Unknown method: ${method}`, "VALIDATION_ERROR");
@@ -920,17 +1448,24 @@ var ConnectHost = class {
920
1448
  // ===========================================================================
921
1449
  autoSubscribeIdentityChanged() {
922
1450
  if (this.eventSubscriptions.has(WALLET_EVENTS.IDENTITY_CHANGED)) return;
923
- const unsub = this.sphere.on("identity:changed", (data) => {
1451
+ const unsub = this.requireSphere().on("identity:changed", (data) => {
924
1452
  this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, data);
925
1453
  });
926
1454
  this.eventSubscriptions.set(WALLET_EVENTS.IDENTITY_CHANGED, unsub);
927
1455
  }
928
1456
  handleSubscribe(eventName) {
929
1457
  if (!eventName) throw new SphereError("Missing required parameter: event", "VALIDATION_ERROR");
1458
+ if (isAutoPushedEvent(eventName)) {
1459
+ return { subscribed: true, event: eventName };
1460
+ }
1461
+ if (this._walletState === "locked") {
1462
+ this.suspendedSubscriptions.add(eventName);
1463
+ return { subscribed: true, event: eventName };
1464
+ }
930
1465
  if (this.eventSubscriptions.has(eventName)) {
931
1466
  return { subscribed: true, event: eventName };
932
1467
  }
933
- const unsub = this.sphere.on(eventName, (data) => {
1468
+ const unsub = this.requireSphere().on(eventName, (data) => {
934
1469
  this.transport.send({
935
1470
  ns: SPHERE_CONNECT_NAMESPACE,
936
1471
  v: SPHERE_CONNECT_VERSION,
@@ -949,6 +1484,7 @@ var ConnectHost = class {
949
1484
  unsub();
950
1485
  this.eventSubscriptions.delete(eventName);
951
1486
  }
1487
+ this.suspendedSubscriptions.delete(eventName);
952
1488
  return { unsubscribed: true, event: eventName };
953
1489
  }
954
1490
  cleanupEventSubscriptions() {
@@ -970,8 +1506,90 @@ var ConnectHost = class {
970
1506
  data
971
1507
  });
972
1508
  }
1509
+ /** The bound Sphere, or a typed refusal. The ONLY way the router may reach Sphere.
1510
+ * Unreachable in practice — the gate guarantees 'live' before the router is entered —
1511
+ * so this is defence in depth, not the primary mechanism. */
1512
+ requireSphere() {
1513
+ if (!this.sphere) {
1514
+ throw new SphereError(
1515
+ this._walletState === "locked" ? WALLET_LOCKED_MESSAGE : "Wallet unavailable",
1516
+ "NOT_INITIALIZED"
1517
+ );
1518
+ }
1519
+ return this.sphere;
1520
+ }
1521
+ /** SNAPSHOT read. `undefined` means "we never saw an identity": callers MUST refuse,
1522
+ * never answer undefined-as-success — a dApp reads that as "the wallet has no
1523
+ * identity". Two explicit methods instead of one dual-mode method, so nobody can serve
1524
+ * a snapshot believing it is live. */
1525
+ snapshotIdentity() {
1526
+ return this.snapshot.identity;
1527
+ }
1528
+ /** InFlightRegistry.onExpire sink. Filled in a later task; declared here so the
1529
+ * constructor can wire it. */
1530
+ settleExpired(entry) {
1531
+ logger.warn(
1532
+ "ConnectHost",
1533
+ `Host deadline reached, answering on our own: ${entry.kind} ${entry.id} (origin=${this.config.origin ?? "unverified"})`
1534
+ );
1535
+ if (entry.kind === "query") {
1536
+ this.sendError(entry.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1537
+ } else {
1538
+ this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);
1539
+ }
1540
+ }
1541
+ /** Answer every request already in flight with one coded frame each. A request in flight
1542
+ * when the Sphere goes away otherwise answers -32603 with a raw JS message, returns
1543
+ * `undefined` AS SUCCESS (sphere_getIdentity), or — for a delegated intent — never
1544
+ * answers at all until the client's own 120 s timeout. */
1545
+ settleInFlight(code, message, data) {
1546
+ for (const entry of this.inFlight.settleAll()) {
1547
+ if (entry.kind === "query") {
1548
+ this.sendError(entry.id, code, message, data);
1549
+ continue;
1550
+ }
1551
+ this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);
1552
+ }
1553
+ }
1554
+ /**
1555
+ * Notify-only. The host has ALREADY answered and never waits for the wallet.
1556
+ *
1557
+ * The wallet's only permitted reaction is a PASSIVE badge in its PERMANENT chrome; a
1558
+ * dApp request may trigger a CONSENT prompt but never a credential prompt. Volume is
1559
+ * bounded by checkRateLimit(), which guards all three entry points — there is no
1560
+ * coalescing, no cooldown and no cap by design.
1561
+ *
1562
+ * A throwing handler must not break the host.
1563
+ */
1564
+ notifyLockedRequest(kind, name) {
1565
+ try {
1566
+ this.config.onLockedRequest?.({ origin: this.config.origin, kind, name });
1567
+ } catch (err) {
1568
+ logger.warn("ConnectHost", "onLockedRequest handler error", err);
1569
+ }
1570
+ }
1571
+ /** Last-resort answer for a handler that threw before its own catch could run.
1572
+ * Id-bearing frames get a coded error (InFlightRegistry guarantees exactly one answer
1573
+ * per id); a handshake gets today's empty refusal, because a failed handshake must
1574
+ * reveal nothing. */
1575
+ sendUnhandledError(msg, error) {
1576
+ if (msg.type === "request") {
1577
+ if (!this.inFlight.settle(msg.id) && this.inFlight.has(msg.id)) return;
1578
+ this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1579
+ return;
1580
+ }
1581
+ if (msg.type === "intent") {
1582
+ this.inFlight.settle(msg.id);
1583
+ this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);
1584
+ return;
1585
+ }
1586
+ if (msg.type === "handshake" && msg.direction === "request") {
1587
+ logger.warn("ConnectHost", "Handshake handler threw; sending the empty refusal", error);
1588
+ this.sendHandshakeResponse([], void 0, void 0);
1589
+ }
1590
+ }
973
1591
  getPublicIdentity() {
974
- const id = this.sphere.identity;
1592
+ const id = this.requireSphere().identity;
975
1593
  if (!id) return void 0;
976
1594
  return {
977
1595
  chainPubkey: id.chainPubkey,
@@ -995,13 +1613,13 @@ var ConnectHost = class {
995
1613
  result
996
1614
  });
997
1615
  }
998
- sendError(id, code, message) {
1616
+ sendError(id, code, message, data) {
999
1617
  this.transport.send({
1000
1618
  ns: SPHERE_CONNECT_NAMESPACE,
1001
1619
  v: SPHERE_CONNECT_VERSION,
1002
1620
  type: "response",
1003
1621
  id,
1004
- error: { code, message }
1622
+ error: { code, message, ...data !== void 0 ? { data } : {} }
1005
1623
  });
1006
1624
  }
1007
1625
  sendIntentResult(id, result) {
@@ -1013,13 +1631,13 @@ var ConnectHost = class {
1013
1631
  result
1014
1632
  });
1015
1633
  }
1016
- sendIntentError(id, code, message) {
1634
+ sendIntentError(id, code, message, data) {
1017
1635
  this.transport.send({
1018
1636
  ns: SPHERE_CONNECT_NAMESPACE,
1019
1637
  v: SPHERE_CONNECT_VERSION,
1020
1638
  type: "intent_result",
1021
1639
  id,
1022
- error: { code, message }
1640
+ error: { code, message, ...data !== void 0 ? { data } : {} }
1023
1641
  });
1024
1642
  }
1025
1643
  checkRateLimit() {
@@ -1058,6 +1676,8 @@ var ConnectClient = class {
1058
1676
  grantedPermissions = [];
1059
1677
  identity = null;
1060
1678
  walletNet = null;
1679
+ walletProto = null;
1680
+ locked = false;
1061
1681
  connected = false;
1062
1682
  pendingRequests = /* @__PURE__ */ new Map();
1063
1683
  eventHandlers = /* @__PURE__ */ new Map();
@@ -1130,12 +1750,37 @@ var ConnectClient = class {
1130
1750
  get walletNetwork() {
1131
1751
  return this.walletNet;
1132
1752
  }
1753
+ /**
1754
+ * The wallet's Connect protocol version, captured from the handshake response `v`.
1755
+ * Null before the first handshake response and after a disconnect.
1756
+ *
1757
+ * Feature-detect with it: compare against '2.1' to decide whether the wallet can be
1758
+ * trusted to send wallet:unlocked / wallet:disconnected. A Connect 2.0 wallet destroys
1759
+ * the session on lock and never emits either, so a dApp waiting for them against one
1760
+ * waits forever.
1761
+ *
1762
+ * CAVEAT: on an ERROR response the host echoes the dApp's own `v` back
1763
+ * (ConnectHost.sendHandshakeResponse), so after a refused connection this may be the
1764
+ * dApp's version rather than the wallet's. Only trust it after a successful handshake.
1765
+ */
1766
+ get walletProtocol() {
1767
+ return this.walletProto;
1768
+ }
1769
+ /**
1770
+ * Whether the wallet was locked at the last handshake or lifecycle event.
1771
+ * A locked client is still CONNECTED: `isConnected` stays true and `session` stays valid.
1772
+ * Requests answer WALLET_LOCKED (4009) until `wallet:unlocked` arrives on the SAME
1773
+ * session — do not disconnect, do not clear the session, do not re-handshake.
1774
+ */
1775
+ get walletLocked() {
1776
+ return this.locked;
1777
+ }
1133
1778
  // ===========================================================================
1134
1779
  // Query (read data)
1135
1780
  // ===========================================================================
1136
1781
  /** Send a query request and return the result */
1137
1782
  async query(method, params) {
1138
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
1783
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
1139
1784
  const id = createRequestId();
1140
1785
  return new Promise((resolve, reject) => {
1141
1786
  const timer = setTimeout(() => {
@@ -1145,7 +1790,8 @@ var ConnectClient = class {
1145
1790
  this.pendingRequests.set(id, {
1146
1791
  resolve,
1147
1792
  reject,
1148
- timer
1793
+ timer,
1794
+ kind: "query"
1149
1795
  });
1150
1796
  this.transport.send({
1151
1797
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -1162,17 +1808,23 @@ var ConnectClient = class {
1162
1808
  // ===========================================================================
1163
1809
  /** Send an intent request. The wallet will open its UI for user confirmation. */
1164
1810
  async intent(action, params) {
1165
- if (!this.connected) throw new SphereError("Not connected", "NOT_INITIALIZED");
1811
+ if (!this.connected) throw new ConnectError("Not connected", ERROR_CODES.NOT_CONNECTED);
1166
1812
  const id = createRequestId();
1167
1813
  return new Promise((resolve, reject) => {
1168
1814
  const timer = setTimeout(() => {
1169
1815
  this.pendingRequests.delete(id);
1170
- reject(new Error(`Intent timeout: ${action}`));
1816
+ reject(
1817
+ new ConnectError(
1818
+ `Intent outcome unknown \u2014 do not retry; reconcile before acting: ${action}`,
1819
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
1820
+ )
1821
+ );
1171
1822
  }, this.intentTimeout);
1172
1823
  this.pendingRequests.set(id, {
1173
1824
  resolve,
1174
1825
  reject,
1175
- timer
1826
+ timer,
1827
+ kind: "intent"
1176
1828
  });
1177
1829
  this.transport.send({
1178
1830
  ns: SPHERE_CONNECT_NAMESPACE,
@@ -1191,7 +1843,7 @@ var ConnectClient = class {
1191
1843
  on(event, handler) {
1192
1844
  if (!this.eventHandlers.has(event)) {
1193
1845
  this.eventHandlers.set(event, /* @__PURE__ */ new Set());
1194
- if (this.connected) {
1846
+ if (this.connected && !isAutoPushedEvent(event)) {
1195
1847
  this.query(RPC_METHODS.SUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event subscription failed", err));
1196
1848
  }
1197
1849
  }
@@ -1202,7 +1854,7 @@ var ConnectClient = class {
1202
1854
  handlers.delete(handler);
1203
1855
  if (handlers.size === 0) {
1204
1856
  this.eventHandlers.delete(event);
1205
- if (this.connected) {
1857
+ if (this.connected && !isAutoPushedEvent(event)) {
1206
1858
  this.query(RPC_METHODS.UNSUBSCRIBE, { event }).catch((err) => logger.debug("Connect", "Event unsubscription failed", err));
1207
1859
  }
1208
1860
  }
@@ -1226,15 +1878,36 @@ var ConnectClient = class {
1226
1878
  return;
1227
1879
  }
1228
1880
  if (msg.type === "event") {
1229
- const handlers = this.eventHandlers.get(msg.event);
1230
- if (handlers) {
1231
- for (const handler of handlers) {
1232
- try {
1233
- handler(msg.data);
1234
- } catch (err) {
1235
- logger.debug("Connect", "Event handler error", err);
1236
- }
1237
- }
1881
+ if (!this.connected || !this.sessionId) {
1882
+ logger.warn("Connect", `Ignoring wallet event before a session exists: ${msg.event}`);
1883
+ return;
1884
+ }
1885
+ if (msg.event === WALLET_EVENTS.LOCKED) {
1886
+ this.locked = true;
1887
+ } else if (msg.event === WALLET_EVENTS.UNLOCKED) {
1888
+ this.locked = false;
1889
+ const identity = msg.data?.identity;
1890
+ if (identity) this.identity = identity;
1891
+ } else if (msg.event === WALLET_EVENTS.DISCONNECTED) {
1892
+ this.connected = false;
1893
+ } else if (msg.event === WALLET_EVENTS.IDENTITY_CHANGED) {
1894
+ const data = msg.data;
1895
+ if (data && typeof data.chainPubkey === "string") this.identity = data;
1896
+ }
1897
+ this.dispatchEvent(msg.event, msg.data);
1898
+ if (msg.event === WALLET_EVENTS.DISCONNECTED) {
1899
+ this.cleanup();
1900
+ }
1901
+ }
1902
+ }
1903
+ dispatchEvent(event, data) {
1904
+ const handlers = this.eventHandlers.get(event);
1905
+ if (!handlers) return;
1906
+ for (const handler of handlers) {
1907
+ try {
1908
+ handler(data);
1909
+ } catch (err) {
1910
+ logger.debug("Connect", "Event handler error", err);
1238
1911
  }
1239
1912
  }
1240
1913
  }
@@ -1242,6 +1915,7 @@ var ConnectClient = class {
1242
1915
  if (!this.handshakeResolver) return;
1243
1916
  clearTimeout(this.handshakeResolver.timer);
1244
1917
  const m = msg;
1918
+ this.walletProto = msg.v ?? null;
1245
1919
  if (m.error) {
1246
1920
  this.handshakeResolver.reject(new ConnectError(m.error.message, m.error.code, m.error.data));
1247
1921
  this.handshakeResolver = null;
@@ -1252,12 +1926,16 @@ var ConnectClient = class {
1252
1926
  this.grantedPermissions = msg.permissions;
1253
1927
  this.identity = msg.identity;
1254
1928
  this.walletNet = m.network ?? null;
1929
+ this.locked = m.locked === true;
1255
1930
  this.connected = true;
1256
1931
  if (m.warning) logger.warn("Connect", "Wallet deprecation notice", m.warning.message);
1257
1932
  this.handshakeResolver.resolve({
1258
1933
  sessionId: msg.sessionId,
1259
1934
  permissions: this.grantedPermissions,
1260
- identity: msg.identity
1935
+ identity: msg.identity,
1936
+ // A resume DURING a lock succeeds: the dApp is connected on the same session and
1937
+ // must not re-handshake. It will get wallet:unlocked when the user unlocks.
1938
+ ...this.locked ? { locked: true } : {}
1261
1939
  });
1262
1940
  } else {
1263
1941
  this.handshakeResolver.reject(new Error("Connection rejected by wallet"));
@@ -1283,9 +1961,19 @@ var ConnectClient = class {
1283
1961
  this.unsubscribeTransport();
1284
1962
  this.unsubscribeTransport = null;
1285
1963
  }
1964
+ if (this.handshakeResolver) {
1965
+ clearTimeout(this.handshakeResolver.timer);
1966
+ this.handshakeResolver.reject(new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED));
1967
+ this.handshakeResolver = null;
1968
+ }
1286
1969
  for (const [, pending] of this.pendingRequests) {
1287
1970
  clearTimeout(pending.timer);
1288
- pending.reject(new Error("Disconnected"));
1971
+ pending.reject(
1972
+ pending.kind === "intent" ? new ConnectError(
1973
+ "Intent outcome unknown \u2014 do not retry; reconcile before acting",
1974
+ ERROR_CODES.INTENT_OUTCOME_UNKNOWN
1975
+ ) : new ConnectError("Disconnected", ERROR_CODES.NOT_CONNECTED)
1976
+ );
1289
1977
  }
1290
1978
  this.pendingRequests.clear();
1291
1979
  this.eventHandlers.clear();
@@ -1294,10 +1982,13 @@ var ConnectClient = class {
1294
1982
  this.grantedPermissions = [];
1295
1983
  this.identity = null;
1296
1984
  this.walletNet = null;
1985
+ this.walletProto = null;
1986
+ this.locked = false;
1297
1987
  }
1298
1988
  };
1299
1989
  export {
1300
1990
  ALL_PERMISSIONS,
1991
+ AUTO_PUSHED_EVENTS,
1301
1992
  ConnectClient,
1302
1993
  ConnectError,
1303
1994
  ConnectHost,
@@ -1317,6 +2008,7 @@ export {
1317
2008
  createRequestId,
1318
2009
  hasIntentPermission,
1319
2010
  hasMethodPermission,
2011
+ isAutoPushedEvent,
1320
2012
  isSphereConnectMessage,
1321
2013
  validatePermissions
1322
2014
  };