@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.
- package/dist/connect/index.cjs +802 -110
- package/dist/connect/index.cjs.map +1 -1
- package/dist/connect/index.d.cts +305 -16
- package/dist/connect/index.d.ts +305 -16
- package/dist/connect/index.js +802 -110
- package/dist/connect/index.js.map +1 -1
- package/dist/core/index.cjs +20 -1
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +4 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +20 -1
- package/dist/core/index.js.map +1 -1
- package/dist/impl/browser/connect/index.cjs +208 -41
- package/dist/impl/browser/connect/index.cjs.map +1 -1
- package/dist/impl/browser/connect/index.d.cts +33 -1
- package/dist/impl/browser/connect/index.d.ts +33 -1
- package/dist/impl/browser/connect/index.js +208 -41
- package/dist/impl/browser/connect/index.js.map +1 -1
- package/dist/impl/nodejs/connect/index.cjs +101 -1
- package/dist/impl/nodejs/connect/index.cjs.map +1 -1
- package/dist/impl/nodejs/connect/index.d.cts +6 -1
- package/dist/impl/nodejs/connect/index.d.ts +6 -1
- package/dist/impl/nodejs/connect/index.js +101 -1
- package/dist/impl/nodejs/connect/index.js.map +1 -1
- package/dist/index.cjs +20 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/connect/index.cjs
CHANGED
|
@@ -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.
|
|
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
|
|
451
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
626
|
-
*
|
|
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.
|
|
630
|
-
const
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
|
|
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.
|
|
636
|
-
this.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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(
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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,
|
|
1355
|
+
this.sendIntentResult(msg.id, response.result);
|
|
836
1356
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
1378
|
+
return sphere.payments.getBalance(params.coinId);
|
|
855
1379
|
case RPC_METHODS.GET_ASSETS:
|
|
856
|
-
return
|
|
1380
|
+
return sphere.payments.getAssets(params.coinId);
|
|
857
1381
|
case RPC_METHODS.GET_FIAT_BALANCE:
|
|
858
|
-
return { fiatBalance: await
|
|
1382
|
+
return { fiatBalance: await sphere.payments.getFiatBalance() };
|
|
859
1383
|
case RPC_METHODS.GET_TOKENS:
|
|
860
1384
|
return this.stripTokenSdkData(
|
|
861
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
878
|
-
|
|
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:
|
|
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 }) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1453
|
+
const comms = sphere.communications;
|
|
1454
|
+
if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
|
|
928
1455
|
return {
|
|
929
|
-
unreadCount:
|
|
1456
|
+
unreadCount: comms.getUnreadCount(
|
|
930
1457
|
params.peerPubkey
|
|
931
1458
|
)
|
|
932
1459
|
};
|
|
933
1460
|
}
|
|
934
1461
|
case RPC_METHODS.MARK_AS_READ: {
|
|
935
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1481
|
+
return accounting.getInvoices(invoiceOpts);
|
|
953
1482
|
}
|
|
954
1483
|
case RPC_METHODS.GET_INVOICE_STATUS: {
|
|
955
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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(
|
|
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
|