@meshconnect/uwc-core 1.7.0 → 1.9.0-snapshot.66a45b0

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.
@@ -8,7 +8,41 @@ import type { SessionManager } from '../managers/session-manager'
8
8
  import type { EventManager } from '../managers/event-manager'
9
9
  import type { ServiceOptions } from './connection-service'
10
10
 
11
+ /**
12
+ * How long an EMPTY capabilities result is served from cache before a re-fetch
13
+ * is allowed. Empty is ambiguous: it means either "wallet doesn't support
14
+ * wallet_getCapabilities" (stable — could be cached forever) or "the fetch
15
+ * failed/timed out" (transient — caching it forever would freeze capabilities
16
+ * at {} for the whole session). A short TTL serves both: repeat UI triggers
17
+ * stay coalesced/cached in the window that matters, while a transient failure
18
+ * self-heals on a later trigger. Non-empty results don't expire — a wallet's
19
+ * capability surface doesn't change mid-session.
20
+ */
21
+ const EMPTY_RESULT_RETRY_MS = 30_000
22
+
11
23
  export class WalletCapabilitiesService {
24
+ /**
25
+ * In-flight `connector.getWalletCapabilities` promise per cache key, so
26
+ * concurrent callers share ONE wallet RPC instead of each issuing their own.
27
+ * Why this matters: consumers commonly trigger a capabilities fetch from UI
28
+ * state (per rendered component, per session change), fanning N identical
29
+ * calls at once. MetaMask processes `wallet_getCapabilities` slowly (~1s
30
+ * each, observed) AND serializes dapp requests behind it, so an uncoalesced
31
+ * flood queues for ~10s and delays every subsequent RPC — measured as a 5s+
32
+ * `eth_accounts` stall on reconnect. The connector-level 3s timeout doesn't
33
+ * help: racing past a request can't cancel it wallet-side.
34
+ */
35
+ private readonly inFlightFetches = new Map<
36
+ string,
37
+ Promise<Record<string, EVMCapabilities>>
38
+ >()
39
+
40
+ /** Settled results per cache key; empty results expire (see EMPTY_RESULT_RETRY_MS). */
41
+ private readonly resultCache = new Map<
42
+ string,
43
+ { capabilities: Record<string, EVMCapabilities>; fetchedAt: number }
44
+ >()
45
+
12
46
  constructor(
13
47
  private networks: Network[],
14
48
  private connectors: Map<ConnectionMode, Connector>,
@@ -16,6 +50,65 @@ export class WalletCapabilitiesService {
16
50
  private eventManager: EventManager
17
51
  ) {}
18
52
 
53
+ /**
54
+ * Fetch capabilities through the coalescing/caching layer. The key includes
55
+ * the connection mode, the active wallet id, and the address, so switching
56
+ * wallet, account, or mode naturally misses the cache — the wallet id
57
+ * matters because the same address can live in two different wallets (one
58
+ * seed imported into both), whose capability surfaces differ; `networks` is
59
+ * constructor-fixed and needs no key part. The result promise never
60
+ * rejects — failures resolve to {} (preserving the previous inline
61
+ * behavior), which also guarantees in-flight cleanup runs.
62
+ *
63
+ * `source` reports how THIS caller was served (`cache` / `inflight` /
64
+ * `rpc`) and rides the `capabilitiesUpdated` emit — the prod signal that
65
+ * the coalescing layer works (a flood should show one `rpc` and N-1
66
+ * `inflight`/`cache` records, not N slow `rpc`s).
67
+ */
68
+ private fetchCapabilities(
69
+ connectionMode: ConnectionMode,
70
+ walletId: string | undefined,
71
+ fetch: () => Promise<Record<string, EVMCapabilities>>,
72
+ address: string
73
+ ): {
74
+ source: 'cache' | 'inflight' | 'rpc'
75
+ result: Promise<Record<string, EVMCapabilities>>
76
+ } {
77
+ const key = `${connectionMode}:${walletId ?? ''}:${address.toLowerCase()}`
78
+
79
+ const cached = this.resultCache.get(key)
80
+ if (cached) {
81
+ const isEmpty = Object.keys(cached.capabilities).length === 0
82
+ if (!isEmpty || Date.now() - cached.fetchedAt < EMPTY_RESULT_RETRY_MS) {
83
+ return { source: 'cache', result: Promise.resolve(cached.capabilities) }
84
+ }
85
+ // Empty and past its retry window — fall through to a fresh fetch.
86
+ }
87
+
88
+ const pending = this.inFlightFetches.get(key)
89
+ if (pending) {
90
+ return { source: 'inflight', result: pending }
91
+ }
92
+
93
+ const fetchPromise = (async () => {
94
+ try {
95
+ return await fetch()
96
+ } catch {
97
+ return {}
98
+ }
99
+ })()
100
+ .then(capabilities => {
101
+ this.resultCache.set(key, { capabilities, fetchedAt: Date.now() })
102
+ return capabilities
103
+ })
104
+ .finally(() => {
105
+ this.inFlightFetches.delete(key)
106
+ })
107
+
108
+ this.inFlightFetches.set(key, fetchPromise)
109
+ return { source: 'rpc', result: fetchPromise }
110
+ }
111
+
19
112
  async getWalletCapabilities(
20
113
  address: string,
21
114
  activeNetwork?: Network,
@@ -43,15 +136,17 @@ export class WalletCapabilitiesService {
43
136
  activeConnector &&
44
137
  typeof activeConnector.getWalletCapabilities === 'function'
45
138
  ) {
46
- let capabilities: Record<string, EVMCapabilities>
47
- try {
48
- capabilities = await activeConnector.getWalletCapabilities(
49
- address,
50
- this.networks
51
- )
52
- } catch {
53
- capabilities = {}
54
- }
139
+ const fetchStarted = Date.now()
140
+ const { source, result } = this.fetchCapabilities(
141
+ session.connectionMode,
142
+ session.activeWallet?.id,
143
+ () => activeConnector.getWalletCapabilities!(address, this.networks),
144
+ address
145
+ )
146
+ const capabilities = await result
147
+ // THIS caller's wait — for an 'inflight' join that's the remaining RPC
148
+ // time, for 'cache' ~0, for 'rpc' the full wallet round-trip.
149
+ const durationMs = Date.now() - fetchStarted
55
150
 
56
151
  options?.signal?.throwIfAborted()
57
152
 
@@ -79,7 +174,9 @@ export class WalletCapabilitiesService {
79
174
  }
80
175
 
81
176
  this.eventManager.emit('capabilitiesUpdated', {
82
- capabilities: activeWalletCapabilities
177
+ capabilities: activeWalletCapabilities,
178
+ source,
179
+ durationMs
83
180
  })
84
181
  this.eventManager.emit('sessionChanged', {
85
182
  session: this.sessionManager.getSession()
@@ -309,7 +309,22 @@ export class UniversalWalletConnector {
309
309
  type: 'unknown',
310
310
  message: `legacy injected-Solana provider at "${info.injectedId}" for ${info.walletName} is not a valid Solana provider`
311
311
  }
312
- })
312
+ }),
313
+ // Triage telemetry (ONC-3536 follow-up): the secondary Solana gather
314
+ // gate was active for a legacy-`injectedId` wallet inside the wallet's
315
+ // own browser (framed embed or top-level in-app / dapp deep-link flow)
316
+ // so an EVM send can't be parked on the Solana namespace (Coinbase
317
+ // Legacy-mode hang).
318
+ onSecondarySolanaConnectSkipped: info =>
319
+ this.eventManager.emit('secondarySolanaConnectSkipped', info),
320
+ // Positive counterpart (ONC-231): a legacy-`injectedId` wallet's secondary
321
+ // Solana address WAS gathered — confirms the framed gather works.
322
+ onSecondarySolanaConnectGathered: info =>
323
+ this.eventManager.emit('secondarySolanaConnectGathered', info),
324
+ // The wallet-switch guard released a different wallet's live state —
325
+ // surface it so the otherwise-silent release is observable in prod.
326
+ onWalletConnectionsReleased: info =>
327
+ this.eventManager.emit('walletConnectionsReleased', info)
313
328
  })
314
329
  this.connectors.set('injected', this.injectedConnector)
315
330
 
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs — do not edit.
2
- export const UWC_CORE_VERSION = '1.7.0'
2
+ export const UWC_CORE_VERSION = '1.9.0'