nebula-ai-core 0.1.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.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,454 @@
1
+ import { EthereumProvider } from '@walletconnect/ethereum-provider'
2
+ import qrcode from 'qrcode-terminal'
3
+ import {
4
+ http,
5
+ type Address,
6
+ type Chain,
7
+ type PublicClient,
8
+ type WalletClient,
9
+ createPublicClient,
10
+ createWalletClient,
11
+ custom,
12
+ getTypesForEIP712Domain,
13
+ numberToHex,
14
+ } from 'viem'
15
+ import { type LocalAccount, toAccount } from 'viem/accounts'
16
+ import { mantleChain } from '../chain'
17
+ import { NETWORK_CHAIN_ID, NETWORK_RPC } from '../config'
18
+ import type { NebulaNetwork } from '../config'
19
+ import type { OperatorSigner } from './signer'
20
+
21
+ /**
22
+ * Recursively replace BigInt values with hex strings ("0x..."). Standard
23
+ * Ethereum JSON-RPC encoding for `gas`, `value`, `nonce`, `maxFeePerGas`,
24
+ * `maxPriorityFeePerGas`, etc. Used at the WC transport boundary because
25
+ * the universal-provider JSON.stringifies its payload before sending over
26
+ * the relay, and JSON.stringify throws on BigInt.
27
+ */
28
+ function jsonSafe(value: unknown): unknown {
29
+ if (typeof value === 'bigint') return numberToHex(value)
30
+ if (Array.isArray(value)) return value.map(jsonSafe)
31
+ if (value !== null && typeof value === 'object') {
32
+ const out: Record<string, unknown> = {}
33
+ for (const [k, v] of Object.entries(value)) out[k] = jsonSafe(v)
34
+ return out
35
+ }
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * Ephemeral in-memory storage adapter for WC v2. WC's default storage uses
41
+ * a disk-persisted cache that leaks session state across CLI runs: old
42
+ * sessions trigger `No matching key` errors AND `session_event` chainChanged
43
+ * messages get replayed, which crashes EthereumProvider's default handler
44
+ * when the chain isn't in our active config. By using a fresh Map per
45
+ * provider instance, every `nebula init` / `nebula restore` starts WC with a
46
+ * clean slate.
47
+ */
48
+ class EphemeralWcStorage {
49
+ private store = new Map<string, unknown>()
50
+ async getKeys(): Promise<string[]> {
51
+ return Array.from(this.store.keys())
52
+ }
53
+ async getEntries<T = unknown>(): Promise<[string, T][]> {
54
+ return Array.from(this.store.entries()) as [string, T][]
55
+ }
56
+ async getItem<T = unknown>(key: string): Promise<T | undefined> {
57
+ return this.store.get(key) as T | undefined
58
+ }
59
+ async setItem<T = unknown>(key: string, value: T): Promise<void> {
60
+ this.store.set(key, value as unknown)
61
+ }
62
+ async removeItem(key: string): Promise<void> {
63
+ this.store.delete(key)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * nebula-registered WalletConnect v2 project ID. Not a secret (WC project
69
+ * IDs are public client-side identifiers, same category as Stripe publishable
70
+ * keys). Users can override with `NEBULA_WC_PROJECT_ID` env var if they want
71
+ * their own project for isolated rate-limits/analytics.
72
+ */
73
+ export const NEBULA_WC_PROJECT_ID =
74
+ process.env.NEBULA_WC_PROJECT_ID ?? '974ed7663d88e07086104fa9a73b2d87'
75
+
76
+ type EthProvider = Awaited<ReturnType<typeof EthereumProvider.init>>
77
+
78
+ export interface WalletConnectOperatorSignerOptions {
79
+ /** WC project ID. Defaults to the nebula-bundled one. */
80
+ projectId?: string
81
+ /** Networks to expose to the wallet. Default: both Mantle mainnet and testnet. */
82
+ networks?: NebulaNetwork[]
83
+ /** Render the pairing QR to stdout automatically. Default true. */
84
+ showQr?: boolean
85
+ /** Callback with the pairing URI for custom rendering (copy-to-clipboard, etc). */
86
+ onDisplayUri?: (uri: string) => void
87
+ /** Max time to wait for user to connect (ms). Default 180000 (3 min). */
88
+ connectTimeoutMs?: number
89
+ }
90
+
91
+ /**
92
+ * Operator source backed by WalletConnect v2. QR-pair with any WC-compatible
93
+ * mobile wallet (MetaMask Mobile, Rainbow, Trust, Coinbase Wallet, Zerion,
94
+ * Safe, Ledger Live, Phantom, OKX, Binance Wallet — 300+ wallets total).
95
+ *
96
+ * Signing flows: the CLI generates a pairing URI, renders it as an ASCII QR
97
+ * in the terminal, user scans with their phone. Subsequent signing requests
98
+ * pop up on the phone; the user approves, signed tx comes back over the WC
99
+ * relay. Keys never leave the phone. Fully non-custodial.
100
+ *
101
+ * Session is NOT persisted across `nebula` invocations in MVP. For the init
102
+ * flow that's fine (one-shot). Post-MVP: persist session to
103
+ * `~/.nebula/wc-session.json` so `nebula topup` reuses the pair.
104
+ */
105
+ export class WalletConnectOperatorSigner implements OperatorSigner {
106
+ readonly source: string
107
+ private provider: EthProvider | null = null
108
+ private connectedAddress: Address | null = null
109
+ private readonly options: Required<WalletConnectOperatorSignerOptions>
110
+
111
+ constructor(options: WalletConnectOperatorSignerOptions = {}) {
112
+ const networks = options.networks ?? (['mantle-mainnet', 'mantle-testnet'] as NebulaNetwork[])
113
+ this.options = {
114
+ projectId: options.projectId ?? NEBULA_WC_PROJECT_ID,
115
+ networks,
116
+ showQr: options.showQr ?? true,
117
+ onDisplayUri: options.onDisplayUri ?? (() => {}),
118
+ connectTimeoutMs: options.connectTimeoutMs ?? 180_000,
119
+ }
120
+ this.source = 'walletconnect'
121
+ }
122
+
123
+ private chainIds(): number[] {
124
+ return this.options.networks.map(n => NETWORK_CHAIN_ID[n])
125
+ }
126
+
127
+ private async ensureProvider(): Promise<EthProvider> {
128
+ if (this.provider && this.connectedAddress) return this.provider
129
+
130
+ // Reown (WalletConnect v2) best-practice init: optionalChains + rpcMap.
131
+ // Per docs (docs.reown.com/advanced/providers/ethereum, verified Apr 27
132
+ // 2026): "We recommend using optionalChains (optional namespaces) over
133
+ // chains (required namespaces). Required namespaces will block wallets
134
+ // from connecting if any of the chains are not supported by the wallet."
135
+ // Mantle is not in MM Mobile's built-in chain registry, so Mantle in REQUIRED
136
+ // returns `User rejected methods` at session establishment.
137
+ //
138
+ // `rpcMap` is required for chains not in WalletConnect's Blockchain API
139
+ // catalog (which excludes Mantle). Without it, universal-provider falls back
140
+ // to a non-existent endpoint and chain-aware methods fail silently.
141
+ //
142
+ // `optionalMethods` is left to defaults; WC includes the full EIP-1193
143
+ // method list automatically (eth_sendTransaction, eth_signTypedData_v4,
144
+ // wallet_switchEthereumChain, wallet_addEthereumChain, etc.).
145
+ // Session chains config: `chains: [1]` is the REQUIRED handshake anchor.
146
+ // Every WC wallet supports Ethereum mainnet, so the session never fails
147
+ // on "wallet doesn't know this chain". `optionalChains: [5000, ...]` is
148
+ // where the actual work happens, MM accepts each that it has in its
149
+ // chain registry. When the user has Mantle pre-added in MM Mobile,
150
+ // 5000 lands in the session's approved namespaces alongside chain 1.
151
+ //
152
+ // Pure `optionalChains` without `chains` was tested and produced a
153
+ // session whose namespace was empty/chain-1-only, so `eth_sendTransaction`
154
+ // for 5000 silently failed at WC layer with `-32004 Method not supported`
155
+ // before reaching MM (no popup). The required handshake on chain 1 is
156
+ // what gives WC enough state to route requests correctly.
157
+ //
158
+ // `rpcMap` provides a custom RPC for Mantle chains since they're not in
159
+ // WC's Blockchain API catalog.
160
+ const optionalChains = this.chainIds() as [number, ...number[]]
161
+ const rpcMap: Record<number, string> = {}
162
+ for (const net of this.options.networks) rpcMap[NETWORK_CHAIN_ID[net]] = NETWORK_RPC[net]
163
+ const provider = await EthereumProvider.init({
164
+ projectId: this.options.projectId,
165
+ chains: [1],
166
+ optionalChains,
167
+ rpcMap,
168
+ showQrModal: false,
169
+ // biome-ignore lint/suspicious/noExplicitAny: WC's IKeyValueStorage has loose generics
170
+ storage: new EphemeralWcStorage() as any,
171
+ metadata: {
172
+ name: 'Nebula',
173
+ description: 'Sovereign agent harness on Mantle',
174
+ url: 'https://nebula.xyz',
175
+ icons: [],
176
+ },
177
+ })
178
+
179
+ // Replace WC's default `setChainId` with one that updates internal
180
+ // chainId WITHOUT calling switchEthereumChain back to the wallet. WC's
181
+ // default handler does:
182
+ // if (isCompatibleChainId(t)) {
183
+ // this.chainId = parseChainId(t)
184
+ // this.switchEthereumChain(parseChainId(t)) // ← crashes
185
+ // }
186
+ // The crash: `switchEthereumChain` calls `this.request(...)` which routes
187
+ // through `getProvider(namespace).request`, but for chains we never
188
+ // configured a provider for, `getProvider(...)` returns undefined and
189
+ // the `.request` dereference is an uncaught TypeError that kills the
190
+ // process. We still need the `this.chainId = ...` part; without it,
191
+ // `eth_sendTransaction` routes to the wrong namespace and MM hangs.
192
+ type ProvWithChainState = {
193
+ isCompatibleChainId?: (id: string) => boolean
194
+ chainId?: number
195
+ }
196
+ const provInternal = provider as unknown as ProvWithChainState
197
+ type WithSetChainId = { setChainId?: (chainId: string) => void }
198
+ const provWithChainId = provider as unknown as WithSetChainId
199
+ provWithChainId.setChainId = (chainId: string) => {
200
+ if (!provInternal.isCompatibleChainId?.(chainId)) return
201
+ const parsed = Number(chainId.split(':')[1])
202
+ if (Number.isFinite(parsed)) provInternal.chainId = parsed
203
+ }
204
+
205
+ if (provider.session && provider.accounts.length > 0) {
206
+ this.provider = provider
207
+ this.connectedAddress = provider.accounts[0] as Address
208
+ return provider
209
+ }
210
+
211
+ const uriPromise = new Promise<string>(resolve => {
212
+ provider.once('display_uri', resolve)
213
+ })
214
+
215
+ const connectPromise = provider.connect({ chains: [1], optionalChains })
216
+ const uri = await uriPromise
217
+ this.options.onDisplayUri(uri)
218
+ if (this.options.showQr) {
219
+ qrcode.generate(uri, { small: true })
220
+ console.log(`\nScan with any WalletConnect-compatible mobile wallet.\nOr copy URI:\n${uri}\n`)
221
+ }
222
+
223
+ let timeoutHandle: NodeJS.Timeout | undefined
224
+ const timeoutPromise = new Promise<never>((_, reject) => {
225
+ timeoutHandle = setTimeout(
226
+ () =>
227
+ reject(
228
+ new Error(`WalletConnect pair timeout after ${this.options.connectTimeoutMs / 1000}s`),
229
+ ),
230
+ this.options.connectTimeoutMs,
231
+ )
232
+ })
233
+ try {
234
+ await Promise.race([connectPromise, timeoutPromise])
235
+ } catch (e) {
236
+ // Surface the underlying WC error in a form the wizard can display.
237
+ // Tear down listeners/session before throwing so any tail relay events
238
+ // (chainChanged for unknown chains, disconnect from a lingering peer
239
+ // session) can't crash the process after we've already given up.
240
+ try {
241
+ provider.events.removeAllListeners()
242
+ provider.signer?.events?.removeAllListeners?.()
243
+ } catch {}
244
+ try {
245
+ await provider.disconnect()
246
+ } catch {}
247
+ const msg = (e as Error).message ?? String(e)
248
+ if (/User rejected/i.test(msg)) {
249
+ throw new Error(
250
+ 'WalletConnect: wallet rejected the session request. Approve in your wallet app and retry.',
251
+ )
252
+ }
253
+ if (/timeout/i.test(msg)) {
254
+ throw new Error(
255
+ `WalletConnect: pairing timed out after ${this.options.connectTimeoutMs / 1000}s. Scan the QR within the timeout window or rerun.`,
256
+ )
257
+ }
258
+ if (/No matching key/i.test(msg)) {
259
+ throw new Error(
260
+ 'WalletConnect: stale session detected (likely from a previous interrupted run). Disconnect nebula from your wallet app and retry.',
261
+ )
262
+ }
263
+ throw new Error(`WalletConnect connect failed: ${msg}`)
264
+ } finally {
265
+ if (timeoutHandle) clearTimeout(timeoutHandle)
266
+ }
267
+
268
+ if (!provider.accounts || provider.accounts.length === 0) {
269
+ throw new Error('WalletConnect paired but no accounts returned')
270
+ }
271
+
272
+ this.provider = provider
273
+ this.connectedAddress = provider.accounts[0] as Address
274
+ return provider
275
+ }
276
+
277
+ async address(): Promise<Address> {
278
+ await this.ensureProvider()
279
+ if (!this.connectedAddress) throw new Error('WalletConnect: not connected')
280
+ return this.connectedAddress
281
+ }
282
+
283
+ async account(): Promise<LocalAccount> {
284
+ const provider = await this.ensureProvider()
285
+ const addr = await this.address()
286
+
287
+ const account = toAccount({
288
+ address: addr,
289
+ async signMessage({ message }) {
290
+ const raw =
291
+ typeof message === 'string' ? message : `0x${Buffer.from(message.raw).toString('hex')}`
292
+ const result = await provider.request({
293
+ method: 'personal_sign',
294
+ params: [raw, addr],
295
+ })
296
+ return result as `0x${string}`
297
+ },
298
+ async signTransaction(tx) {
299
+ const result = await provider.request({
300
+ method: 'eth_signTransaction',
301
+ params: [jsonSafe(tx)],
302
+ })
303
+ return result as `0x${string}`
304
+ },
305
+ async signTypedData(typedData) {
306
+ // v0.24.9: inject canonical `EIP712Domain` into `types` so the
307
+ // domain separator matches viem's `hashTypedData`. Without this MM's
308
+ // `sanitizeData` adds `EIP712Domain: []` (empty) and the resulting
309
+ // sig diverges from LocalAccount sigs over the same payload.
310
+ // `signTypedDataLegacyEmptyDomain` (attached below) preserves the
311
+ // pre-v0.24.9 verbatim shape so legacy WC-init'd keystores still
312
+ // decrypt via the keystore-crypto fallback. See
313
+ // feedback-wc-signTypedData-eip712domain-trap.md.
314
+ const td = typedData as Parameters<typeof getTypesForEIP712Domain>[0] & {
315
+ types?: Record<string, unknown>
316
+ primaryType: string
317
+ message: Record<string, unknown>
318
+ }
319
+ const withDomain = {
320
+ ...td,
321
+ types: {
322
+ EIP712Domain: getTypesForEIP712Domain({ domain: td.domain }),
323
+ ...(td.types ?? {}),
324
+ },
325
+ }
326
+ const result = await provider.request({
327
+ method: 'eth_signTypedData_v4',
328
+ params: [addr, JSON.stringify(withDomain)],
329
+ })
330
+ return result as `0x${string}`
331
+ },
332
+ })
333
+ // Attach the legacy variant as a sibling method on the Account. The
334
+ // keystore-crypto fallback path discovers it via duck-typing and calls
335
+ // it only after canonical-key decrypt fails AES-GCM (i.e. when a
336
+ // pre-v0.24.9 WC-init'd keystore is being unlocked). LocalAccount
337
+ // signers (raw-privkey, keystore-file, keychain) never expose this
338
+ // method, so canonical-only behavior is preserved for them.
339
+ Object.defineProperty(account, 'signTypedDataLegacyEmptyDomain', {
340
+ value: async (typedData: unknown) => {
341
+ const result = await provider.request({
342
+ method: 'eth_signTypedData_v4',
343
+ params: [addr, JSON.stringify(typedData)],
344
+ })
345
+ return result as `0x${string}`
346
+ },
347
+ enumerable: false,
348
+ writable: false,
349
+ })
350
+ return account
351
+ }
352
+
353
+ /**
354
+ * Ensure the wallet has the target Mantle chain in its registry and active.
355
+ * MM does NOT support `eth_signTransaction`, so the wallet itself has to
356
+ * broadcast via `eth_sendTransaction`; that requires the chain to be
357
+ * configured wallet-side. Idempotent, safe to call before every tx.
358
+ */
359
+ private async addAndSwitchChain(network: NebulaNetwork): Promise<void> {
360
+ const provider = this.provider
361
+ if (!provider) return
362
+ const chainId = numberToHex(NETWORK_CHAIN_ID[network])
363
+ const chain = mantleChain(network)
364
+ try {
365
+ await provider.request({
366
+ method: 'wallet_addEthereumChain',
367
+ params: [
368
+ {
369
+ chainId,
370
+ chainName: chain.name,
371
+ nativeCurrency: chain.nativeCurrency,
372
+ rpcUrls: chain.rpcUrls.default.http,
373
+ blockExplorerUrls:
374
+ network === 'mantle-mainnet'
375
+ ? ['https://chainscan.mantle.xyz']
376
+ : ['https://chainscan-galileo.mantle.xyz'],
377
+ },
378
+ ],
379
+ })
380
+ } catch {
381
+ // Already added: benign.
382
+ }
383
+ try {
384
+ await provider.request({
385
+ method: 'wallet_switchEthereumChain',
386
+ params: [{ chainId }],
387
+ })
388
+ } catch {
389
+ // Switch failed; eth_sendTransaction will surface a clearer error.
390
+ }
391
+ }
392
+
393
+ async walletClient(network: NebulaNetwork): Promise<WalletClient> {
394
+ const provider = await this.ensureProvider()
395
+ await this.addAndSwitchChain(network)
396
+ const addr = await this.address()
397
+ const chain = mantleChain(network)
398
+ // Account MUST be type 'json-rpc' so viem routes via eth_sendTransaction;
399
+ // see walletconnect.test.ts for the regression that pins this contract.
400
+ return createWalletClient({
401
+ account: { address: addr, type: 'json-rpc' as const },
402
+ chain,
403
+ transport: custom({
404
+ async request({ method, params }) {
405
+ // jsonSafe normalizes BigInts to hex; WC's universal-provider
406
+ // JSON.stringifies the payload and BigInt has no JSON encoding.
407
+ const normalized = jsonSafe(params) as unknown[]
408
+ return provider.request({ method, params: normalized })
409
+ },
410
+ }),
411
+ })
412
+ }
413
+
414
+ async publicClient(network: NebulaNetwork): Promise<PublicClient> {
415
+ const chain = mantleChain(network)
416
+ return createPublicClient({
417
+ transport: http(chain.rpcUrls.default.http[0]),
418
+ chain,
419
+ })
420
+ }
421
+
422
+ chain(network: NebulaNetwork): Chain {
423
+ return mantleChain(network)
424
+ }
425
+
426
+ async close(): Promise<void> {
427
+ if (this.provider) {
428
+ const p = this.provider
429
+ // Strip ALL listeners before disconnect: WC's universal-provider keeps
430
+ // emitting `session_event` (chainChanged, accountsChanged) up until
431
+ // `disconnect()` resolves, and the EthereumProvider's default handler
432
+ // calls `wallet_switchEthereumChain` against `getProvider(chain)` which
433
+ // can return undefined for chains we never configured. The result is
434
+ // an uncaught TypeError that crashes the process AFTER the caller has
435
+ // already decided to bail. Pulling listeners first is the only way to
436
+ // reliably suppress those tail events.
437
+ // EthereumProvider attaches its real listeners on `signer.events`
438
+ // (the universal-provider's emitter), not on `p.events`. Strip both.
439
+ try {
440
+ p.events.removeAllListeners()
441
+ p.signer?.events?.removeAllListeners?.()
442
+ } catch {
443
+ // events bag might already be torn down; non-fatal.
444
+ }
445
+ try {
446
+ await p.disconnect()
447
+ } catch {
448
+ // Idempotent close; disconnect on an already-closed session throws.
449
+ }
450
+ this.provider = null
451
+ this.connectedAddress = null
452
+ }
453
+ }
454
+ }