@hybrd/xmtp 1.0.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/src/client.ts ADDED
@@ -0,0 +1,940 @@
1
+ import { ReactionCodec } from "@xmtp/content-type-reaction"
2
+ import { ReplyCodec } from "@xmtp/content-type-reply"
3
+ import { TransactionReferenceCodec } from "@xmtp/content-type-transaction-reference"
4
+ import { WalletSendCallsCodec } from "@xmtp/content-type-wallet-send-calls"
5
+ import { Client, IdentifierKind, type Signer, XmtpEnv } from "@xmtp/node-sdk"
6
+ import { getRandomValues } from "node:crypto"
7
+ import fs from "node:fs"
8
+ import path from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+ import { fromString, toString as uint8arraysToString } from "uint8arrays"
11
+ import { createWalletClient, http, toBytes } from "viem"
12
+ import { privateKeyToAccount } from "viem/accounts"
13
+ import { sepolia } from "viem/chains"
14
+ import { revokeOldInstallations } from "../scripts/revoke-installations"
15
+ import { XmtpClient } from "./types"
16
+
17
+ // ===================================================================
18
+ // Module Setup
19
+ // ===================================================================
20
+ // ES module equivalent of __dirname
21
+ const __filename = fileURLToPath(import.meta.url)
22
+ const __dirname = path.dirname(__filename)
23
+
24
+ // ===================================================================
25
+ // Type Definitions
26
+ // ===================================================================
27
+ interface User {
28
+ key: `0x${string}`
29
+ account: ReturnType<typeof privateKeyToAccount>
30
+ wallet: any // Simplified to avoid deep type instantiation
31
+ }
32
+
33
+ // ===================================================================
34
+ // User and Signer Creation
35
+ // ===================================================================
36
+ export const createUser = (key: string): User => {
37
+ const account = privateKeyToAccount(key as `0x${string}`)
38
+ return {
39
+ key: key as `0x${string}`,
40
+ account,
41
+ wallet: createWalletClient({
42
+ account,
43
+ chain: sepolia,
44
+ transport: http()
45
+ })
46
+ }
47
+ }
48
+
49
+ export const createSigner = (key: string): Signer => {
50
+ if (!key || typeof key !== "string") {
51
+ throw new Error("XMTP wallet key must be a non-empty string")
52
+ }
53
+ const sanitizedKey = key.startsWith("0x") ? key : `0x${key}`
54
+ const user = createUser(sanitizedKey)
55
+ return {
56
+ type: "EOA",
57
+ getIdentifier: () => ({
58
+ identifierKind: 0 as IdentifierKind.Ethereum, // Use numeric value to avoid ambient const enum issue
59
+ identifier: user.account.address.toLowerCase()
60
+ }),
61
+ signMessage: async (message: string) => {
62
+ const signature = await user.wallet.signMessage({
63
+ message,
64
+ account: user.account
65
+ })
66
+ return toBytes(signature)
67
+ }
68
+ }
69
+ }
70
+
71
+ // XMTP XmtpClient setup
72
+ // const xmtpClient: XmtpClient | null = null
73
+
74
+ // Function to clear XMTP database when hitting installation limits
75
+ async function clearXMTPDatabase(address: string, env: string) {
76
+ console.log("๐Ÿงน Clearing XMTP database to resolve installation limit...")
77
+
78
+ // Get the storage directory using the same logic as getDbPath
79
+ const getStorageDirectory = () => {
80
+ const customStoragePath = process.env.XMTP_STORAGE_PATH
81
+
82
+ if (customStoragePath) {
83
+ return path.isAbsolute(customStoragePath)
84
+ ? customStoragePath
85
+ : path.resolve(process.cwd(), customStoragePath)
86
+ }
87
+
88
+ // Use existing logic as fallback
89
+ const projectRoot =
90
+ process.env.PROJECT_ROOT || path.resolve(__dirname, "../../..")
91
+
92
+ return path.join(projectRoot, ".data/xmtp") // Local development
93
+ }
94
+
95
+ // Clear local database files
96
+ const dbPattern = `${env}-${address}.db3`
97
+ const storageDir = getStorageDirectory()
98
+
99
+ // Primary storage directory
100
+ const possiblePaths = [
101
+ storageDir,
102
+ // Legacy fallback paths for backward compatibility
103
+ path.join(process.cwd(), ".data", "xmtp"),
104
+ path.join(process.cwd(), "..", ".data", "xmtp"),
105
+ path.join(process.cwd(), "..", "..", ".data", "xmtp")
106
+ ]
107
+
108
+ for (const dir of possiblePaths) {
109
+ try {
110
+ if (fs.existsSync(dir)) {
111
+ const files = fs.readdirSync(dir)
112
+ const matchingFiles = files.filter(
113
+ (file) =>
114
+ file.includes(dbPattern) ||
115
+ file.includes(address) ||
116
+ file.includes(`xmtp-${env}-${address}`)
117
+ )
118
+
119
+ for (const file of matchingFiles) {
120
+ const fullPath = path.join(dir, file)
121
+ try {
122
+ fs.unlinkSync(fullPath)
123
+ console.log(`โœ… Removed: ${fullPath}`)
124
+ } catch (err) {
125
+ console.log(`โš ๏ธ Could not remove ${fullPath}:`, err)
126
+ }
127
+ }
128
+ }
129
+ } catch (err) {
130
+ // Ignore errors when checking directories
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function createXMTPClient(
136
+ // signer: Signer,
137
+ privateKey: string,
138
+ opts?: {
139
+ persist?: boolean
140
+ maxRetries?: number
141
+ storagePath?: string
142
+ }
143
+ ): Promise<XmtpClient> {
144
+ const { persist = true, maxRetries = 3, storagePath } = opts ?? {}
145
+ let attempt = 0
146
+
147
+ // Extract common variables for error handling
148
+ // const actualSigner = signer
149
+ const signer = createSigner(privateKey)
150
+
151
+ if (!signer) {
152
+ throw new Error(
153
+ "No signer provided and XMTP_WALLET_KEY environment variable is not set"
154
+ )
155
+ }
156
+
157
+ const { XMTP_ENCRYPTION_KEY, XMTP_ENV } = process.env
158
+
159
+ // Get the wallet address to use the correct database
160
+ const identifier = await signer.getIdentifier()
161
+ const address = identifier.identifier
162
+
163
+ while (attempt < maxRetries) {
164
+ try {
165
+ console.log(
166
+ `๐Ÿ”„ Attempt ${attempt + 1}/${maxRetries} to create XMTP client...`
167
+ )
168
+
169
+ // Always require encryption key and persistence - no stateless mode
170
+ if (!persist) {
171
+ throw new Error(
172
+ "Stateless mode is not supported. XMTP client must run in persistent mode " +
173
+ "to properly receive and process messages. Set persist: true or remove the persist option " +
174
+ "to use the default persistent mode."
175
+ )
176
+ }
177
+
178
+ if (!XMTP_ENCRYPTION_KEY) {
179
+ throw new Error("XMTP_ENCRYPTION_KEY must be set for persistent mode")
180
+ }
181
+
182
+ const dbEncryptionKey = getEncryptionKeyFromHex(XMTP_ENCRYPTION_KEY)
183
+ const dbPath = await getDbPath(
184
+ `${XMTP_ENV || "dev"}-${address}`,
185
+ storagePath
186
+ )
187
+ console.log(`๐Ÿ“ Using database path: ${dbPath}`)
188
+
189
+ // Always create a fresh client and sync it
190
+ const client = await Client.create(signer, {
191
+ dbEncryptionKey,
192
+ env: XMTP_ENV as XmtpEnv,
193
+ dbPath,
194
+ codecs: [
195
+ new ReplyCodec(),
196
+ new ReactionCodec(),
197
+ new WalletSendCallsCodec(),
198
+ new TransactionReferenceCodec()
199
+ ]
200
+ })
201
+
202
+ // Force sync conversations to ensure we have the latest data
203
+ console.log("๐Ÿ“ก Syncing conversations to ensure latest state...")
204
+ await client.conversations.sync()
205
+
206
+ await backupDbToPersistentStorage(
207
+ dbPath,
208
+ `${XMTP_ENV || "dev"}-${address}`
209
+ )
210
+
211
+ console.log("โœ… XMTP XmtpClient created")
212
+ console.log(`๐Ÿ”‘ Wallet address: ${address}`)
213
+ console.log(`๐ŸŒ Environment: ${XMTP_ENV || "dev"}`)
214
+ console.log(`๐Ÿ’พ Storage mode: persistent`)
215
+
216
+ return client
217
+ } catch (error) {
218
+ attempt++
219
+
220
+ if (
221
+ error instanceof Error &&
222
+ error.message.includes("5/5 installations")
223
+ ) {
224
+ console.log(
225
+ `๐Ÿ’ฅ Installation limit reached (attempt ${attempt}/${maxRetries})`
226
+ )
227
+
228
+ if (attempt < maxRetries) {
229
+ // Get wallet address for database clearing
230
+ const identifier = await signer.getIdentifier()
231
+ const address = identifier.identifier
232
+
233
+ // Extract inboxId from the error message
234
+ const inboxIdMatch = error.message.match(/InboxID ([a-f0-9]+)/)
235
+ const inboxId = inboxIdMatch ? inboxIdMatch[1] : undefined
236
+
237
+ // First try to revoke old installations
238
+ const revocationSuccess = await revokeOldInstallations(
239
+ signer,
240
+ inboxId
241
+ )
242
+
243
+ if (revocationSuccess) {
244
+ console.log("๐ŸŽฏ Installations revoked, retrying connection...")
245
+ } else {
246
+ console.log(
247
+ "โš ๏ธ Installation revocation failed or not needed, clearing database..."
248
+ )
249
+ // Clear database as fallback
250
+ await clearXMTPDatabase(address, process.env.XMTP_ENV || "dev")
251
+ }
252
+
253
+ // Wait a bit before retrying
254
+ const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
255
+ console.log(`โณ Waiting ${delay}ms before retry...`)
256
+ await new Promise((resolve) => setTimeout(resolve, delay))
257
+ } else {
258
+ console.error(
259
+ "โŒ Failed to resolve installation limit after all retries"
260
+ )
261
+ console.error("๐Ÿ’ก Possible solutions:")
262
+ console.error(" 1. Use a different wallet (generate new keys)")
263
+ console.error(" 2. Switch XMTP environments (dev <-> production)")
264
+ console.error(" 3. Wait and try again later")
265
+ console.error(" 4. Contact XMTP support for manual intervention")
266
+ throw error
267
+ }
268
+ } else if (
269
+ error instanceof Error &&
270
+ error.message.includes("Association error: Missing identity update")
271
+ ) {
272
+ console.log(
273
+ `๐Ÿ”„ Identity association error detected (attempt ${attempt}/${maxRetries})`
274
+ )
275
+
276
+ if (attempt < maxRetries) {
277
+ console.log("๐Ÿ”ง Attempting automatic identity refresh...")
278
+
279
+ // Try to refresh identity by creating a persistent client first
280
+ try {
281
+ console.log("๐Ÿ“ Creating persistent client to refresh identity...")
282
+ const tempEncryptionKey = XMTP_ENCRYPTION_KEY
283
+ ? getEncryptionKeyFromHex(XMTP_ENCRYPTION_KEY)
284
+ : getEncryptionKeyFromHex(generateEncryptionKeyHex())
285
+ const tempClient = await Client.create(signer, {
286
+ dbEncryptionKey: tempEncryptionKey,
287
+ env: XMTP_ENV as XmtpEnv,
288
+ dbPath: await getDbPath(
289
+ `${XMTP_ENV || "dev"}-${address}`,
290
+ storagePath
291
+ ),
292
+ codecs: [
293
+ new ReplyCodec(),
294
+ new ReactionCodec(),
295
+ new WalletSendCallsCodec(),
296
+ new TransactionReferenceCodec()
297
+ ]
298
+ })
299
+
300
+ console.log("๐Ÿ“ก Syncing identity and conversations...")
301
+ await tempClient.conversations.sync()
302
+
303
+ console.log(
304
+ "โœ… Identity refresh successful, retrying original request..."
305
+ )
306
+
307
+ // Wait a bit before retrying
308
+ const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
309
+ console.log(`โณ Waiting ${delay}ms before retry...`)
310
+ await new Promise((resolve) => setTimeout(resolve, delay))
311
+ } catch (refreshError) {
312
+ console.log(`โŒ Identity refresh failed:`, refreshError)
313
+ // Continue to the retry logic
314
+ }
315
+ } else {
316
+ console.error(
317
+ "โŒ Failed to resolve identity association error after all retries"
318
+ )
319
+ console.error(
320
+ "๐Ÿ’ก Try running: pnpm with-env pnpm --filter @hybrd/xmtp refresh:identity"
321
+ )
322
+ throw error
323
+ }
324
+ } else {
325
+ // For other errors, don't retry
326
+ throw error
327
+ }
328
+ }
329
+ }
330
+
331
+ throw new Error("Max retries exceeded")
332
+ }
333
+
334
+ // ===================================================================
335
+ // Encryption Key Management
336
+ // ===================================================================
337
+ /**
338
+ * Generate a random encryption key
339
+ * @returns The encryption key as a hex string
340
+ */
341
+ export const generateEncryptionKeyHex = () => {
342
+ const uint8Array = getRandomValues(new Uint8Array(32))
343
+ return uint8arraysToString(uint8Array, "hex")
344
+ }
345
+
346
+ /**
347
+ * Get the encryption key from a hex string
348
+ * @param hex - The hex string
349
+ * @returns The encryption key as Uint8Array
350
+ */
351
+ export const getEncryptionKeyFromHex = (hex: string): Uint8Array => {
352
+ return fromString(hex, "hex")
353
+ }
354
+
355
+ // ===================================================================
356
+ // Database Path Management
357
+ // ===================================================================
358
+ export const getDbPath = async (description = "xmtp", storagePath?: string) => {
359
+ // Allow custom storage path via environment variable
360
+ const customStoragePath = process.env.XMTP_STORAGE_PATH
361
+
362
+ let volumePath: string
363
+
364
+ if (customStoragePath) {
365
+ // Use custom storage path if provided
366
+ volumePath = path.isAbsolute(customStoragePath)
367
+ ? customStoragePath
368
+ : path.resolve(process.cwd(), customStoragePath)
369
+ } else if (storagePath) {
370
+ volumePath = path.isAbsolute(storagePath)
371
+ ? storagePath
372
+ : path.resolve(process.cwd(), storagePath)
373
+ } else {
374
+ // Use existing logic as fallback
375
+ const projectRoot =
376
+ process.env.PROJECT_ROOT || path.resolve(__dirname, "../../..")
377
+
378
+ // Default storage path for local development
379
+ volumePath = path.join(projectRoot, ".data/xmtp")
380
+ }
381
+
382
+ const dbPath = `${volumePath}/${description}.db3`
383
+
384
+ if (typeof globalThis !== "undefined" && "XMTP_STORAGE" in globalThis) {
385
+ try {
386
+ console.log(`๐Ÿ“ฆ Using Cloudflare R2 storage for: ${dbPath}`)
387
+
388
+ const r2Bucket = (globalThis as any).XMTP_STORAGE
389
+ const remotePath = `xmtp-databases/${description}.db3`
390
+
391
+ try {
392
+ const existingObject = await r2Bucket.head(remotePath)
393
+ if (existingObject) {
394
+ console.log(`๐Ÿ“ฅ Downloading existing database from R2 storage...`)
395
+
396
+ if (!fs.existsSync(volumePath)) {
397
+ fs.mkdirSync(volumePath, { recursive: true })
398
+ }
399
+
400
+ const object = await r2Bucket.get(remotePath)
401
+ if (object) {
402
+ const fileData = await object.arrayBuffer()
403
+ fs.writeFileSync(dbPath, new Uint8Array(fileData))
404
+ console.log(`โœ… Database downloaded from R2 storage`)
405
+ }
406
+ } else {
407
+ console.log(`๐Ÿ“ No existing database found in R2 storage`)
408
+ }
409
+ } catch (error) {
410
+ console.log(`โš ๏ธ Failed to download database from R2 storage:`, error)
411
+ }
412
+ } catch (error) {
413
+ console.log(`โš ๏ธ R2 storage not available:`, error)
414
+ }
415
+ }
416
+
417
+ if (!fs.existsSync(volumePath)) {
418
+ fs.mkdirSync(volumePath, { recursive: true })
419
+ }
420
+
421
+ return dbPath
422
+ }
423
+
424
+ export const backupDbToPersistentStorage = async (
425
+ dbPath: string,
426
+ description: string
427
+ ) => {
428
+ if (
429
+ typeof globalThis !== "undefined" &&
430
+ "XMTP_STORAGE" in globalThis &&
431
+ fs.existsSync(dbPath)
432
+ ) {
433
+ try {
434
+ console.log(`๐Ÿ“ฆ Backing up database to R2 storage: ${dbPath}`)
435
+
436
+ const r2Bucket = (globalThis as any).XMTP_STORAGE
437
+ const remotePath = `xmtp-databases/${description}.db3`
438
+
439
+ const fileData = fs.readFileSync(dbPath)
440
+ await r2Bucket.put(remotePath, fileData)
441
+ console.log(`โœ… Database backed up to R2 storage: ${remotePath}`)
442
+ } catch (error) {
443
+ console.log(`โš ๏ธ Failed to backup database to R2 storage:`, error)
444
+ }
445
+ }
446
+ }
447
+
448
+ // ===================================================================
449
+ // Logging and Debugging
450
+ // ===================================================================
451
+ export const logAgentDetails = async (
452
+ clients: XmtpClient | XmtpClient[]
453
+ ): Promise<void> => {
454
+ const clientsByAddress = Array.isArray(clients)
455
+ ? clients.reduce<Record<string, XmtpClient[]>>((acc, XmtpClient) => {
456
+ const address = XmtpClient.accountIdentifier?.identifier ?? ""
457
+ acc[address] = acc[address] ?? []
458
+ acc[address].push(XmtpClient)
459
+ return acc
460
+ }, {})
461
+ : {
462
+ [clients.accountIdentifier?.identifier ?? ""]: [clients]
463
+ }
464
+
465
+ for (const [address, clientGroup] of Object.entries(clientsByAddress)) {
466
+ const firstClient = clientGroup[0]
467
+ const inboxId = firstClient?.inboxId
468
+ const environments = clientGroup
469
+ .map((c) => c.options?.env ?? "dev")
470
+ .join(", ")
471
+ console.log(`\x1b[38;2;252;76;52m
472
+ โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—
473
+ โ•šโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—
474
+ โ•šโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•
475
+ โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ•โ•
476
+ โ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ•šโ•โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘
477
+ โ•šโ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•
478
+ \x1b[0m`)
479
+
480
+ const urls = [`http://xmtp.chat/dm/${address}`]
481
+
482
+ const conversations = await firstClient?.conversations.list()
483
+
484
+ console.log(`
485
+ โœ“ XMTP XmtpClient:
486
+ โ€ข Address: ${address}
487
+ โ€ข Conversations: ${conversations?.length}
488
+ โ€ข InboxId: ${inboxId}
489
+ โ€ข Networks: ${environments}
490
+ ${urls.map((url) => `โ€ข URL: ${url}`).join("\n")}`)
491
+ }
492
+ }
493
+
494
+ // ===================================================================
495
+ // Environment Validation
496
+ // ===================================================================
497
+ export function validateEnvironment(vars: string[]): Record<string, string> {
498
+ const missing = vars.filter((v) => !process.env[v])
499
+
500
+ if (missing.length) {
501
+ try {
502
+ const envPath = path.resolve(process.cwd(), ".env")
503
+ if (fs.existsSync(envPath)) {
504
+ const envVars = fs
505
+ .readFileSync(envPath, "utf-8")
506
+ .split("\n")
507
+ .filter((line) => line.trim() && !line.startsWith("#"))
508
+ .reduce<Record<string, string>>((acc, line) => {
509
+ const [key, ...val] = line.split("=")
510
+ if (key && val.length) acc[key.trim()] = val.join("=").trim()
511
+ return acc
512
+ }, {})
513
+
514
+ missing.forEach((v) => {
515
+ if (envVars[v]) process.env[v] = envVars[v]
516
+ })
517
+ }
518
+ } catch (e) {
519
+ console.error(e)
520
+ /* ignore errors */
521
+ }
522
+
523
+ const stillMissing = vars.filter((v) => !process.env[v])
524
+ if (stillMissing.length) {
525
+ console.error("Missing env vars:", stillMissing.join(", "))
526
+ process.exit(1)
527
+ }
528
+ }
529
+
530
+ return vars.reduce<Record<string, string>>((acc, key) => {
531
+ acc[key] = process.env[key] as string
532
+ return acc
533
+ }, {})
534
+ }
535
+
536
+ /**
537
+ * Diagnose XMTP environment and identity issues
538
+ */
539
+ export async function diagnoseXMTPIdentityIssue(
540
+ client: XmtpClient,
541
+ inboxId: string,
542
+ environment: string
543
+ ): Promise<{
544
+ canResolve: boolean
545
+ suggestions: string[]
546
+ details: Record<string, any>
547
+ }> {
548
+ const suggestions: string[] = []
549
+ const details: Record<string, any> = {
550
+ environment,
551
+ inboxId,
552
+ timestamp: new Date().toISOString()
553
+ }
554
+
555
+ try {
556
+ // Try to resolve the inbox state
557
+ const inboxState = await client.preferences.inboxStateFromInboxIds([
558
+ inboxId
559
+ ])
560
+
561
+ if (inboxState.length === 0) {
562
+ suggestions.push(
563
+ `Inbox ID ${inboxId} not found in ${environment} environment`
564
+ )
565
+ suggestions.push(
566
+ "Try switching XMTP_ENV to 'dev' if currently 'production' or vice versa"
567
+ )
568
+ suggestions.push(
569
+ "Verify the user has created an identity on this XMTP network"
570
+ )
571
+ details.inboxStateFound = false
572
+ return { canResolve: false, suggestions, details }
573
+ }
574
+
575
+ const inbox = inboxState[0]
576
+ if (!inbox) {
577
+ suggestions.push("Inbox state returned empty data")
578
+ details.inboxStateFound = false
579
+ return { canResolve: false, suggestions, details }
580
+ }
581
+
582
+ details.inboxStateFound = true
583
+ details.identifierCount = inbox.identifiers?.length || 0
584
+
585
+ if (!inbox.identifiers || inbox.identifiers.length === 0) {
586
+ suggestions.push("Inbox found but has no identifiers")
587
+ suggestions.push("This indicates incomplete identity registration")
588
+ suggestions.push("User may need to re-register their identity on XMTP")
589
+ details.hasIdentifiers = false
590
+ return { canResolve: false, suggestions, details }
591
+ }
592
+
593
+ // Successfully resolved
594
+ details.hasIdentifiers = true
595
+ details.resolvedAddress = inbox.identifiers[0]?.identifier
596
+ return {
597
+ canResolve: true,
598
+ suggestions: ["Identity resolved successfully"],
599
+ details
600
+ }
601
+ } catch (error) {
602
+ const errorMessage = error instanceof Error ? error.message : String(error)
603
+ details.error = errorMessage
604
+
605
+ if (errorMessage.includes("Association error")) {
606
+ suggestions.push("XMTP identity association error detected")
607
+ suggestions.push(
608
+ "Check if user exists on the correct XMTP environment (dev vs production)"
609
+ )
610
+ suggestions.push(
611
+ "Identity may need to be recreated on the current environment"
612
+ )
613
+ }
614
+
615
+ if (errorMessage.includes("Missing identity update")) {
616
+ suggestions.push("Missing identity updates in XMTP network")
617
+ suggestions.push("This can indicate network sync issues")
618
+ suggestions.push("Wait a few minutes and retry, or recreate identity")
619
+ }
620
+
621
+ if (errorMessage.includes("database") || errorMessage.includes("storage")) {
622
+ suggestions.push("XMTP local database/storage issue")
623
+ suggestions.push("Try clearing XMTP database and resyncing")
624
+ suggestions.push("Check .data/xmtp directory permissions")
625
+ }
626
+
627
+ suggestions.push("Consider testing with a fresh XMTP identity")
628
+ return { canResolve: false, suggestions, details }
629
+ }
630
+ }
631
+
632
+ // ===================================================================
633
+ // Enhanced Connection Management & Health Monitoring
634
+ // ===================================================================
635
+
636
+ export interface XMTPConnectionConfig {
637
+ maxRetries?: number
638
+ retryDelayMs?: number
639
+ healthCheckIntervalMs?: number
640
+ connectionTimeoutMs?: number
641
+ reconnectOnFailure?: boolean
642
+ }
643
+
644
+ export interface XMTPConnectionHealth {
645
+ isConnected: boolean
646
+ lastHealthCheck: Date
647
+ consecutiveFailures: number
648
+ totalReconnects: number
649
+ avgResponseTime: number
650
+ }
651
+
652
+ export class XMTPConnectionManager {
653
+ private client: XmtpClient | null = null
654
+ private privateKey: string
655
+ private config: Required<XMTPConnectionConfig>
656
+ private health: XMTPConnectionHealth
657
+ private healthCheckTimer: NodeJS.Timeout | null = null
658
+ private isReconnecting = false
659
+
660
+ constructor(privateKey: string, config: XMTPConnectionConfig = {}) {
661
+ this.privateKey = privateKey
662
+ this.config = {
663
+ maxRetries: config.maxRetries ?? 5,
664
+ retryDelayMs: config.retryDelayMs ?? 1000,
665
+ healthCheckIntervalMs: config.healthCheckIntervalMs ?? 30000,
666
+ connectionTimeoutMs: config.connectionTimeoutMs ?? 10000,
667
+ reconnectOnFailure: config.reconnectOnFailure ?? true
668
+ }
669
+
670
+ this.health = {
671
+ isConnected: false,
672
+ lastHealthCheck: new Date(),
673
+ consecutiveFailures: 0,
674
+ totalReconnects: 0,
675
+ avgResponseTime: 0
676
+ }
677
+ }
678
+
679
+ async connect(persist = false): Promise<XmtpClient> {
680
+ if (this.client && this.health.isConnected) {
681
+ return this.client
682
+ }
683
+
684
+ let attempt = 0
685
+ while (attempt < this.config.maxRetries) {
686
+ try {
687
+ console.log(
688
+ `๐Ÿ”„ XMTP connection attempt ${attempt + 1}/${this.config.maxRetries}`
689
+ )
690
+
691
+ this.client = await createXMTPClient(this.privateKey, { persist })
692
+ this.health.isConnected = true
693
+ this.health.consecutiveFailures = 0
694
+
695
+ // Start health monitoring
696
+ this.startHealthMonitoring()
697
+
698
+ console.log("โœ… XMTP client connected successfully")
699
+ return this.client
700
+ } catch (error) {
701
+ attempt++
702
+ this.health.consecutiveFailures++
703
+
704
+ console.error(`โŒ XMTP connection attempt ${attempt} failed:`, error)
705
+
706
+ if (attempt < this.config.maxRetries) {
707
+ const delay = this.config.retryDelayMs * Math.pow(2, attempt - 1)
708
+ console.log(`โณ Retrying in ${delay}ms...`)
709
+ await this.sleep(delay)
710
+ }
711
+ }
712
+ }
713
+
714
+ throw new Error(
715
+ `Failed to connect to XMTP after ${this.config.maxRetries} attempts`
716
+ )
717
+ }
718
+
719
+ // private async createClientWithTimeout(persist: boolean): Promise<XmtpClient> {
720
+ // const timeoutPromise = new Promise<never>((_, reject) => {
721
+ // setTimeout(
722
+ // () => reject(new Error("Connection timeout")),
723
+ // this.config.connectionTimeoutMs
724
+ // )
725
+ // })
726
+
727
+ // const clientPromise = createXMTPClient(this.signer, { persist })
728
+
729
+ // return Promise.race([clientPromise, timeoutPromise])
730
+ // }
731
+
732
+ private startHealthMonitoring(): void {
733
+ if (this.healthCheckTimer) {
734
+ clearInterval(this.healthCheckTimer)
735
+ }
736
+
737
+ this.healthCheckTimer = setInterval(() => {
738
+ this.performHealthCheck()
739
+ }, this.config.healthCheckIntervalMs)
740
+ }
741
+
742
+ private async performHealthCheck(): Promise<void> {
743
+ if (!this.client) return
744
+
745
+ const startTime = Date.now()
746
+
747
+ try {
748
+ // Simple health check: try to list conversations
749
+ await this.client.conversations.list()
750
+
751
+ const responseTime = Date.now() - startTime
752
+ this.health.avgResponseTime =
753
+ (this.health.avgResponseTime + responseTime) / 2
754
+ this.health.lastHealthCheck = new Date()
755
+ this.health.consecutiveFailures = 0
756
+ this.health.isConnected = true
757
+
758
+ console.log(`๐Ÿ’“ XMTP health check passed (${responseTime}ms)`)
759
+ } catch (error) {
760
+ this.health.consecutiveFailures++
761
+ this.health.isConnected = false
762
+
763
+ console.error(`๐Ÿ’” XMTP health check failed:`, error)
764
+
765
+ // Trigger reconnection if enabled
766
+ if (this.config.reconnectOnFailure && !this.isReconnecting) {
767
+ this.handleConnectionFailure()
768
+ }
769
+ }
770
+ }
771
+
772
+ private async handleConnectionFailure(): Promise<void> {
773
+ if (this.isReconnecting) return
774
+
775
+ this.isReconnecting = true
776
+ this.health.totalReconnects++
777
+
778
+ console.log("๐Ÿ”„ XMTP connection lost, attempting to reconnect...")
779
+
780
+ try {
781
+ this.client = null
782
+ await this.connect()
783
+ console.log("โœ… XMTP reconnection successful")
784
+ } catch (error) {
785
+ console.error("โŒ XMTP reconnection failed:", error)
786
+ } finally {
787
+ this.isReconnecting = false
788
+ }
789
+ }
790
+
791
+ private sleep(ms: number): Promise<void> {
792
+ return new Promise((resolve) => setTimeout(resolve, ms))
793
+ }
794
+
795
+ getHealth(): XMTPConnectionHealth {
796
+ return { ...this.health }
797
+ }
798
+
799
+ getClient(): XmtpClient | null {
800
+ return this.client
801
+ }
802
+
803
+ async disconnect(): Promise<void> {
804
+ if (this.healthCheckTimer) {
805
+ clearInterval(this.healthCheckTimer)
806
+ this.healthCheckTimer = null
807
+ }
808
+
809
+ this.client = null
810
+ this.health.isConnected = false
811
+ console.log("๐Ÿ”Œ XMTP client disconnected")
812
+ }
813
+ }
814
+
815
+ // Enhanced client creation with connection management
816
+ export async function createXMTPConnectionManager(
817
+ privateKey: string,
818
+ config?: XMTPConnectionConfig
819
+ ): Promise<XMTPConnectionManager> {
820
+ const manager = new XMTPConnectionManager(privateKey, config)
821
+ await manager.connect()
822
+ return manager
823
+ }
824
+
825
+ // ===================================================================
826
+ // User Address Resolution with Auto-Refresh
827
+ // ===================================================================
828
+
829
+ /**
830
+ * Resolve user address from inbox ID with automatic identity refresh on association errors
831
+ */
832
+ export async function resolveUserAddress(
833
+ client: XmtpClient,
834
+ senderInboxId: string,
835
+ maxRetries = 2
836
+ ): Promise<string> {
837
+ let attempt = 0
838
+
839
+ while (attempt < maxRetries) {
840
+ try {
841
+ console.log(
842
+ `๐Ÿ” Resolving user address (attempt ${attempt + 1}/${maxRetries})...`
843
+ )
844
+
845
+ const inboxState = await client.preferences.inboxStateFromInboxIds([
846
+ senderInboxId
847
+ ])
848
+
849
+ const firstInbox = inboxState[0]
850
+ if (
851
+ inboxState.length > 0 &&
852
+ firstInbox?.identifiers &&
853
+ firstInbox.identifiers.length > 0
854
+ ) {
855
+ const userAddress = firstInbox.identifiers[0]?.identifier
856
+ if (userAddress) {
857
+ console.log("โœ… Resolved user address:", userAddress)
858
+ return userAddress
859
+ }
860
+ }
861
+
862
+ console.log("โš ๏ธ No identifiers found in inbox state")
863
+ return "unknown"
864
+ } catch (error) {
865
+ attempt++
866
+
867
+ if (
868
+ error instanceof Error &&
869
+ error.message.includes("Association error: Missing identity update")
870
+ ) {
871
+ console.log(
872
+ `๐Ÿ”„ Identity association error during address resolution (attempt ${attempt}/${maxRetries})`
873
+ )
874
+
875
+ if (attempt < maxRetries) {
876
+ console.log(
877
+ "๐Ÿ”ง Attempting automatic identity refresh for address resolution..."
878
+ )
879
+
880
+ try {
881
+ // Force a conversation sync to refresh identity state
882
+ console.log("๐Ÿ“ก Syncing conversations to refresh identity...")
883
+ await client.conversations.sync()
884
+
885
+ // Small delay before retry
886
+ console.log("โณ Waiting 2s before retry...")
887
+ await new Promise((resolve) => setTimeout(resolve, 2000))
888
+
889
+ console.log(
890
+ "โœ… Identity sync completed, retrying address resolution..."
891
+ )
892
+ } catch (refreshError) {
893
+ console.log(`โŒ Identity refresh failed:`, refreshError)
894
+ }
895
+ } else {
896
+ console.error("โŒ Failed to resolve user address after all retries")
897
+ console.error("๐Ÿ’ก Identity association issue persists")
898
+
899
+ // Run diagnostic
900
+ try {
901
+ const diagnosis = await diagnoseXMTPIdentityIssue(
902
+ client,
903
+ senderInboxId,
904
+ process.env.XMTP_ENV || "dev"
905
+ )
906
+
907
+ console.log("๐Ÿ” XMTP Identity Diagnosis:")
908
+ diagnosis.suggestions.forEach((suggestion) => {
909
+ console.error(`๐Ÿ’ก ${suggestion}`)
910
+ })
911
+ } catch (diagError) {
912
+ console.warn("โš ๏ธ Could not run XMTP identity diagnosis:", diagError)
913
+ }
914
+
915
+ return "unknown"
916
+ }
917
+ } else {
918
+ // For other errors, don't retry
919
+ console.error("โŒ Error resolving user address:", error)
920
+ return "unknown"
921
+ }
922
+ }
923
+ }
924
+
925
+ return "unknown"
926
+ }
927
+
928
+ export const startPeriodicBackup = (
929
+ dbPath: string,
930
+ description: string,
931
+ intervalMs = 300000
932
+ ) => {
933
+ return setInterval(async () => {
934
+ try {
935
+ await backupDbToPersistentStorage(dbPath, description)
936
+ } catch (error) {
937
+ console.log(`โš ๏ธ Periodic backup failed:`, error)
938
+ }
939
+ }, intervalMs)
940
+ }