@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.
@@ -0,0 +1,585 @@
1
+ import {
2
+ type Address,
3
+ PublicClient,
4
+ encodePacked,
5
+ keccak256,
6
+ namehash
7
+ } from "viem"
8
+ import { mainnet } from "viem/chains"
9
+ import { L2ResolverAbi } from "../abi/l2_resolver"
10
+
11
+ // Base L2 Resolver Address mapping by chain ID
12
+ // const BASENAME_L2_RESOLVER_ADDRESSES: Record<number, Address> = {
13
+ // [mainnet.id]: "0x0000000000000000000000000000000000000000", // Mainnet (1)
14
+ // [base.id]: "0xC6d566A56A1aFf6508b41f6c90ff131615583BCD", // Base Mainnet (8453)
15
+ // [baseSepolia.id]: "0x6533C94869D28fAA8dF77cc63f9e2b2D6Cf77eBA" // Base Sepolia (84532)
16
+ // } as const
17
+
18
+ const BASENAME_L2_RESOLVER_ADDRESS =
19
+ "0xC6d566A56A1aFf6508b41f6c90ff131615583BCD"
20
+
21
+ // Basename text record keys for metadata
22
+ export const BasenameTextRecordKeys = {
23
+ Email: "email",
24
+ Url: "url",
25
+ Avatar: "avatar",
26
+ Description: "description",
27
+ Notice: "notice",
28
+ Keywords: "keywords",
29
+ Twitter: "com.twitter",
30
+ Github: "com.github",
31
+ Discord: "com.discord",
32
+ Telegram: "org.telegram",
33
+ Snapshot: "snapshot",
34
+ Location: "location"
35
+ } as const
36
+
37
+ export type BasenameTextRecordKey =
38
+ (typeof BasenameTextRecordKeys)[keyof typeof BasenameTextRecordKeys]
39
+ export type BaseName = string
40
+
41
+ interface BasenameResolverOptions {
42
+ /**
43
+ * Maximum number of basenames to cache
44
+ * @default 500
45
+ */
46
+ maxCacheSize?: number
47
+ /**
48
+ * Cache TTL in milliseconds
49
+ * @default 3600000 (1 hour)
50
+ */
51
+ cacheTtl?: number
52
+
53
+ /**
54
+ * Public client
55
+ * @default null
56
+ */
57
+ publicClient: PublicClient
58
+ }
59
+
60
+ interface CacheEntry {
61
+ basename: string
62
+ timestamp: number
63
+ }
64
+
65
+ interface TextRecordCacheEntry {
66
+ value: string
67
+ timestamp: number
68
+ }
69
+
70
+ /**
71
+ * Convert an chainId to a coinType hex for reverse chain resolution
72
+ */
73
+ export const convertChainIdToCoinType = (chainId: number): string => {
74
+ // L1 resolvers to addr
75
+ if (chainId === mainnet.id) {
76
+ return "addr"
77
+ }
78
+
79
+ const cointype = (0x80000000 | chainId) >>> 0
80
+ return cointype.toString(16).toLocaleUpperCase()
81
+ }
82
+
83
+ /**
84
+ * Helper function to convert an address to its reverse node for ENS lookups
85
+ */
86
+ export const convertReverseNodeToBytes = (
87
+ address: Address,
88
+ chainId: number
89
+ ) => {
90
+ const addressFormatted = address.toLocaleLowerCase() as Address
91
+ const addressNode = keccak256(addressFormatted.substring(2) as Address)
92
+ const chainCoinType = convertChainIdToCoinType(chainId)
93
+ const baseReverseNode = namehash(
94
+ `${chainCoinType.toLocaleUpperCase()}.reverse`
95
+ )
96
+ const addressReverseNode = keccak256(
97
+ encodePacked(["bytes32", "bytes32"], [baseReverseNode, addressNode])
98
+ )
99
+ return addressReverseNode
100
+ }
101
+
102
+ /**
103
+ * Helper function to convert a basename to its node hash
104
+ */
105
+ function convertBasenameToNode(basename: string): `0x${string}` {
106
+ return namehash(basename)
107
+ }
108
+
109
+ /**
110
+ * Get the resolver address for a given chain ID
111
+ */
112
+ function getResolverAddress(): Address {
113
+ const resolverAddress = BASENAME_L2_RESOLVER_ADDRESS
114
+ return resolverAddress
115
+ }
116
+
117
+ export class BasenameResolver {
118
+ private cache = new Map<string, CacheEntry>()
119
+ private textRecordCache = new Map<string, Map<string, TextRecordCacheEntry>>()
120
+ private readonly maxCacheSize: number
121
+ private readonly cacheTtl: number
122
+ private readonly baseClient: PublicClient
123
+ private resolverAddress: Address | null = null
124
+ private chainId: number | null = null
125
+
126
+ constructor(options: BasenameResolverOptions) {
127
+ this.maxCacheSize = options.maxCacheSize ?? 500
128
+ this.cacheTtl = options.cacheTtl ?? 3600000 // 1 hour
129
+
130
+ // Create a public client for Base network
131
+ this.baseClient = options.publicClient
132
+
133
+ // Initialize resolver address lazily on first use
134
+ this.initializeResolver()
135
+ }
136
+
137
+ /**
138
+ * Initialize the resolver address based on the client's chain ID
139
+ */
140
+ private async initializeResolver(): Promise<void> {
141
+ if (this.resolverAddress && this.chainId) {
142
+ console.log(
143
+ `🔄 BasenameResolver already initialized for chain ${this.chainId} with resolver ${this.resolverAddress}`
144
+ )
145
+ return
146
+ }
147
+
148
+ try {
149
+ console.log("🔄 Initializing BasenameResolver...")
150
+ this.chainId = await this.baseClient.getChainId()
151
+ console.log(`🔗 Chain ID detected: ${this.chainId}`)
152
+
153
+ this.resolverAddress = getResolverAddress()
154
+ console.log(
155
+ `📍 Resolver address for chain ${this.chainId}: ${this.resolverAddress}`
156
+ )
157
+
158
+ console.log(
159
+ `✅ Initialized BasenameResolver for chain ${this.chainId} with resolver ${this.resolverAddress}`
160
+ )
161
+ } catch (error) {
162
+ console.error("❌ Failed to initialize BasenameResolver:", error)
163
+ throw error
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Get the resolver address, initializing if necessary
169
+ */
170
+ private async getResolverAddress(): Promise<Address> {
171
+ await this.initializeResolver()
172
+ if (!this.resolverAddress) {
173
+ throw new Error("Failed to initialize resolver address")
174
+ }
175
+ return this.resolverAddress
176
+ }
177
+
178
+ /**
179
+ * Resolve a basename from an Ethereum address
180
+ */
181
+ async getBasename(address: Address): Promise<string | null> {
182
+ console.log(`🔍 Starting basename resolution for address: ${address}`)
183
+
184
+ try {
185
+ // Check cache first
186
+ const cached = this.getCachedBasename(address)
187
+ if (cached) {
188
+ console.log(`✅ Resolved basename from cache: ${cached}`)
189
+ return cached
190
+ }
191
+ console.log(`📭 No cached basename found for address: ${address}`)
192
+
193
+ console.log("🔄 Getting resolver address...")
194
+ const resolverAddress = await this.getResolverAddress()
195
+ console.log(`📍 Using resolver address: ${resolverAddress}`)
196
+
197
+ console.log("🔄 Getting chain ID...")
198
+ const chainId = await this.baseClient.getChainId()
199
+ console.log(`🔗 Chain ID: ${chainId}`)
200
+
201
+ console.log("🔄 Converting address to reverse node...")
202
+ const addressReverseNode = convertReverseNodeToBytes(
203
+ // address.toUpperCase() as `0x${string}`,
204
+ address as `0x${string}`,
205
+ chainId
206
+ )
207
+ console.log(`🔗 Reverse node: ${addressReverseNode}`)
208
+
209
+ console.log("🔄 Reading contract to resolve basename...")
210
+ const basename = await this.baseClient.readContract({
211
+ abi: L2ResolverAbi,
212
+ address: resolverAddress,
213
+ functionName: "name",
214
+ args: [addressReverseNode]
215
+ })
216
+
217
+ console.log(
218
+ `📋 Contract returned basename: "${basename}" (length: ${basename?.length || 0})`
219
+ )
220
+
221
+ if (basename && basename.length > 0) {
222
+ this.setCachedBasename(address, basename)
223
+ console.log(`✅ Resolved basename: ${basename} for address: ${address}`)
224
+ return basename as BaseName
225
+ }
226
+
227
+ console.log(
228
+ `❌ No basename found for address: ${address} (empty or null response)`
229
+ )
230
+ return null
231
+ } catch (error) {
232
+ console.error(
233
+ `❌ Error resolving basename for address ${address}:`,
234
+ error
235
+ )
236
+ if (error instanceof Error) {
237
+ console.error(`❌ Error details: ${error.message}`)
238
+ console.error(`❌ Error stack:`, error.stack)
239
+ }
240
+ return null
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Get the avatar URL for a basename
246
+ */
247
+ async getBasenameAvatar(basename: BaseName): Promise<string | null> {
248
+ console.log(`🖼️ Getting avatar for basename: ${basename}`)
249
+ return this.getBasenameTextRecord(basename, BasenameTextRecordKeys.Avatar)
250
+ }
251
+
252
+ /**
253
+ * Get a text record for a basename
254
+ */
255
+ async getBasenameTextRecord(
256
+ basename: BaseName,
257
+ key: BasenameTextRecordKey
258
+ ): Promise<string | null> {
259
+ console.log(`📝 Getting text record "${key}" for basename: ${basename}`)
260
+
261
+ try {
262
+ // Check cache first
263
+ const cached = this.getCachedTextRecord(basename, key)
264
+ if (cached) {
265
+ console.log(`✅ Resolved text record from cache: ${key}=${cached}`)
266
+ return cached
267
+ }
268
+ console.log(`📭 No cached text record found for ${basename}.${key}`)
269
+
270
+ console.log("🔄 Getting resolver address...")
271
+ const resolverAddress = await this.getResolverAddress()
272
+ console.log(`📍 Using resolver address: ${resolverAddress}`)
273
+
274
+ console.log("🔄 Converting basename to node...")
275
+ const node = convertBasenameToNode(basename)
276
+ console.log(`🔗 Node hash: ${node}`)
277
+
278
+ console.log(`🔄 Reading contract for text record "${key}"...`)
279
+ const textRecord = await this.baseClient.readContract({
280
+ abi: L2ResolverAbi,
281
+ address: resolverAddress,
282
+ functionName: "text",
283
+ args: [node, key]
284
+ })
285
+
286
+ console.log(
287
+ `📋 Contract returned text record: "${textRecord}" (length: ${textRecord?.length || 0})`
288
+ )
289
+
290
+ if (textRecord && textRecord.length > 0) {
291
+ this.setCachedTextRecord(basename, key, textRecord)
292
+ console.log(`✅ Resolved text record: ${key}=${textRecord}`)
293
+ return textRecord
294
+ }
295
+
296
+ console.log(
297
+ `❌ No text record found for ${basename}.${key} (empty or null response)`
298
+ )
299
+ return null
300
+ } catch (error) {
301
+ console.error(
302
+ `❌ Error resolving text record ${key} for ${basename}:`,
303
+ error
304
+ )
305
+ if (error instanceof Error) {
306
+ console.error(`❌ Error details: ${error.message}`)
307
+ console.error(`❌ Error stack:`, error.stack)
308
+ }
309
+ return null
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Get the Ethereum address that owns a basename
315
+ */
316
+ async getBasenameAddress(basename: BaseName): Promise<Address | null> {
317
+ console.log(`🔍 Getting address for basename: ${basename}`)
318
+
319
+ try {
320
+ console.log("🔄 Getting resolver address...")
321
+ const resolverAddress = await this.getResolverAddress()
322
+ console.log(`📍 Using resolver address: ${resolverAddress}`)
323
+
324
+ console.log("🔄 Converting basename to node...")
325
+ const node = convertBasenameToNode(basename)
326
+ console.log(`🔗 Node hash: ${node}`)
327
+
328
+ console.log("🔄 Reading contract to resolve address...")
329
+ const address = await this.baseClient.readContract({
330
+ abi: L2ResolverAbi,
331
+ address: resolverAddress,
332
+ functionName: "addr",
333
+ args: [node]
334
+ })
335
+
336
+ console.log(`📋 Contract returned address: "${address}"`)
337
+
338
+ if (address && address !== "0x0000000000000000000000000000000000000000") {
339
+ console.log(`✅ Resolved address: ${address} for basename: ${basename}`)
340
+ return address as Address
341
+ }
342
+
343
+ console.log(
344
+ `❌ No address found for basename: ${basename} (zero address or null response)`
345
+ )
346
+ return null
347
+ } catch (error) {
348
+ console.error(
349
+ `❌ Error resolving address for basename ${basename}:`,
350
+ error
351
+ )
352
+ if (error instanceof Error) {
353
+ console.error(`❌ Error details: ${error.message}`)
354
+ console.error(`❌ Error stack:`, error.stack)
355
+ }
356
+ return null
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Get all basic metadata for a basename
362
+ */
363
+ async getBasenameMetadata(basename: BaseName) {
364
+ console.log(`📊 Getting metadata for basename: ${basename}`)
365
+
366
+ try {
367
+ const [avatar, description, twitter, github, url] = await Promise.all([
368
+ this.getBasenameTextRecord(basename, BasenameTextRecordKeys.Avatar),
369
+ this.getBasenameTextRecord(
370
+ basename,
371
+ BasenameTextRecordKeys.Description
372
+ ),
373
+ this.getBasenameTextRecord(basename, BasenameTextRecordKeys.Twitter),
374
+ this.getBasenameTextRecord(basename, BasenameTextRecordKeys.Github),
375
+ this.getBasenameTextRecord(basename, BasenameTextRecordKeys.Url)
376
+ ])
377
+
378
+ const metadata = {
379
+ basename,
380
+ avatar,
381
+ description,
382
+ twitter,
383
+ github,
384
+ url
385
+ }
386
+
387
+ console.log(`✅ Resolved metadata for ${basename}:`, metadata)
388
+ return metadata
389
+ } catch (error) {
390
+ console.error(
391
+ `❌ Error resolving metadata for basename ${basename}:`,
392
+ error
393
+ )
394
+ if (error instanceof Error) {
395
+ console.error(`❌ Error details: ${error.message}`)
396
+ console.error(`❌ Error stack:`, error.stack)
397
+ }
398
+ return null
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Resolve a full basename profile (name + metadata) from an address
404
+ */
405
+ async resolveBasenameProfile(address: Address) {
406
+ console.log(`👤 Resolving full basename profile for address: ${address}`)
407
+
408
+ try {
409
+ const basename = await this.getBasename(address)
410
+ if (!basename) {
411
+ console.log(`❌ No basename found for address: ${address}`)
412
+ return null
413
+ }
414
+
415
+ console.log(`🔄 Getting metadata for resolved basename: ${basename}`)
416
+ const metadata = await this.getBasenameMetadata(basename)
417
+
418
+ const profile = {
419
+ address,
420
+ ...metadata
421
+ }
422
+
423
+ console.log(`✅ Resolved full profile for ${address}:`, profile)
424
+ return profile
425
+ } catch (error) {
426
+ console.error(
427
+ `❌ Error resolving basename profile for ${address}:`,
428
+ error
429
+ )
430
+ if (error instanceof Error) {
431
+ console.error(`❌ Error details: ${error.message}`)
432
+ console.error(`❌ Error stack:`, error.stack)
433
+ }
434
+ return null
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Get cached basename if not expired
440
+ */
441
+ private getCachedBasename(address: Address): string | null {
442
+ const entry = this.cache.get(address.toLowerCase())
443
+ if (!entry) {
444
+ console.log(
445
+ `📭 No cache entry found for address: ${address.toLowerCase()}`
446
+ )
447
+ return null
448
+ }
449
+
450
+ const now = Date.now()
451
+ const age = now - entry.timestamp
452
+ console.log(`🕐 Cache entry age: ${age}ms (TTL: ${this.cacheTtl}ms)`)
453
+
454
+ if (age > this.cacheTtl) {
455
+ console.log(
456
+ `⏰ Cache entry expired for address: ${address.toLowerCase()}`
457
+ )
458
+ this.cache.delete(address.toLowerCase())
459
+ return null
460
+ }
461
+
462
+ console.log(
463
+ `✅ Valid cache entry found for ${address.toLowerCase()}: "${entry.basename}"`
464
+ )
465
+ return entry.basename
466
+ }
467
+
468
+ /**
469
+ * Cache basename with LRU eviction
470
+ */
471
+ private setCachedBasename(address: Address, basename: string): void {
472
+ // Simple LRU: if cache is full, remove oldest entry
473
+ if (this.cache.size >= this.maxCacheSize) {
474
+ const firstKey = this.cache.keys().next().value
475
+ if (firstKey) {
476
+ console.log(`🗑️ Cache full, removing oldest entry: ${firstKey}`)
477
+ this.cache.delete(firstKey)
478
+ }
479
+ }
480
+
481
+ console.log(
482
+ `💾 Caching basename "${basename}" for address: ${address.toLowerCase()}`
483
+ )
484
+ this.cache.set(address.toLowerCase(), {
485
+ basename,
486
+ timestamp: Date.now()
487
+ })
488
+ }
489
+
490
+ /**
491
+ * Get cached text record if not expired
492
+ */
493
+ private getCachedTextRecord(basename: string, key: string): string | null {
494
+ const basenameCache = this.textRecordCache.get(basename)
495
+ if (!basenameCache) {
496
+ console.log(`📭 No text record cache found for basename: ${basename}`)
497
+ return null
498
+ }
499
+
500
+ const entry = basenameCache.get(key)
501
+ if (!entry) {
502
+ console.log(`📭 No cached text record found for ${basename}.${key}`)
503
+ return null
504
+ }
505
+
506
+ const now = Date.now()
507
+ const age = now - entry.timestamp
508
+ console.log(
509
+ `🕐 Text record cache entry age: ${age}ms (TTL: ${this.cacheTtl}ms)`
510
+ )
511
+
512
+ if (age > this.cacheTtl) {
513
+ console.log(`⏰ Text record cache entry expired for ${basename}.${key}`)
514
+ basenameCache.delete(key)
515
+ return null
516
+ }
517
+
518
+ console.log(
519
+ `✅ Valid text record cache entry found for ${basename}.${key}: "${entry.value}"`
520
+ )
521
+ return entry.value
522
+ }
523
+
524
+ /**
525
+ * Cache text record
526
+ */
527
+ private setCachedTextRecord(
528
+ basename: string,
529
+ key: string,
530
+ value: string
531
+ ): void {
532
+ let basenameCache = this.textRecordCache.get(basename)
533
+ if (!basenameCache) {
534
+ console.log(`📝 Creating new text record cache for basename: ${basename}`)
535
+ basenameCache = new Map()
536
+ this.textRecordCache.set(basename, basenameCache)
537
+ }
538
+
539
+ console.log(
540
+ `💾 Caching text record "${key}" = "${value}" for basename: ${basename}`
541
+ )
542
+ basenameCache.set(key, {
543
+ value,
544
+ timestamp: Date.now()
545
+ })
546
+ }
547
+
548
+ /**
549
+ * Clear all caches
550
+ */
551
+ clearCache(): void {
552
+ const basenameCount = this.cache.size
553
+ const textRecordCount = this.textRecordCache.size
554
+
555
+ this.cache.clear()
556
+ this.textRecordCache.clear()
557
+
558
+ console.log(`🗑️ Basename cache cleared (${basenameCount} entries removed)`)
559
+ console.log(
560
+ `🗑️ Text record cache cleared (${textRecordCount} basename caches removed)`
561
+ )
562
+ }
563
+
564
+ /**
565
+ * Get cache statistics
566
+ */
567
+ getCacheStats(): {
568
+ basenameCache: { size: number; maxSize: number }
569
+ textRecordCache: { size: number }
570
+ chainId: number | null
571
+ resolverAddress: Address | null
572
+ } {
573
+ return {
574
+ basenameCache: {
575
+ size: this.cache.size,
576
+ maxSize: this.maxCacheSize
577
+ },
578
+ textRecordCache: {
579
+ size: this.textRecordCache.size
580
+ },
581
+ chainId: this.chainId,
582
+ resolverAddress: this.resolverAddress
583
+ }
584
+ }
585
+ }