@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.js
CHANGED
|
@@ -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.
|
|
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
|
|
404
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
579
|
-
*
|
|
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.
|
|
583
|
-
const
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
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.
|
|
589
|
-
this.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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(
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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,
|
|
1306
|
+
this.sendIntentResult(msg.id, response.result);
|
|
789
1307
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
1329
|
+
return sphere.payments.getBalance(params.coinId);
|
|
808
1330
|
case RPC_METHODS.GET_ASSETS:
|
|
809
|
-
return
|
|
1331
|
+
return sphere.payments.getAssets(params.coinId);
|
|
810
1332
|
case RPC_METHODS.GET_FIAT_BALANCE:
|
|
811
|
-
return { fiatBalance: await
|
|
1333
|
+
return { fiatBalance: await sphere.payments.getFiatBalance() };
|
|
812
1334
|
case RPC_METHODS.GET_TOKENS:
|
|
813
1335
|
return this.stripTokenSdkData(
|
|
814
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
831
|
-
|
|
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:
|
|
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 }) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1404
|
+
const comms = sphere.communications;
|
|
1405
|
+
if (!comms) throw new SphereError("Communications module not available", "MODULE_NOT_AVAILABLE");
|
|
881
1406
|
return {
|
|
882
|
-
unreadCount:
|
|
1407
|
+
unreadCount: comms.getUnreadCount(
|
|
883
1408
|
params.peerPubkey
|
|
884
1409
|
)
|
|
885
1410
|
};
|
|
886
1411
|
}
|
|
887
1412
|
case RPC_METHODS.MARK_AS_READ: {
|
|
888
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1432
|
+
return accounting.getInvoices(invoiceOpts);
|
|
906
1433
|
}
|
|
907
1434
|
case RPC_METHODS.GET_INVOICE_STATUS: {
|
|
908
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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(
|
|
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
|
};
|