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.
- package/README.md +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +75 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +213 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- 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
|
+
}
|