sui.ski 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 (73) hide show
  1. package/AGENTS.md +311 -0
  2. package/CLAUDE.md +292 -0
  3. package/CODEBASE_GUIDE.md +217 -0
  4. package/README.md +77 -0
  5. package/biome.json +28 -0
  6. package/package.json +73 -0
  7. package/scripts/deploy-messaging-mainnet.sh +184 -0
  8. package/scripts/extract-suins-object.ts +180 -0
  9. package/scripts/full-deploy.sh +26 -0
  10. package/scripts/obsidian.ts +243 -0
  11. package/scripts/set-suins-contenthash.ts +130 -0
  12. package/scripts/setup-ika-dwallet.ts +338 -0
  13. package/scripts/transfer-upgrade-cap-from-nft.ts +86 -0
  14. package/src/durable-objects/wallet-session.ts +333 -0
  15. package/src/handlers/app.ts +1430 -0
  16. package/src/handlers/authenticated-events.ts +267 -0
  17. package/src/handlers/dashboard.ts +1659 -0
  18. package/src/handlers/landing.ts +6751 -0
  19. package/src/handlers/mcp.ts +556 -0
  20. package/src/handlers/messaging-sdk.ts +220 -0
  21. package/src/handlers/profile.css.ts +9332 -0
  22. package/src/handlers/profile.ts +12640 -0
  23. package/src/handlers/register2.ts +2811 -0
  24. package/src/handlers/ski-sign.ts +1901 -0
  25. package/src/handlers/ski.ts +314 -0
  26. package/src/handlers/thunder.ts +940 -0
  27. package/src/handlers/vault.ts +284 -0
  28. package/src/handlers/wallet-api.ts +169 -0
  29. package/src/handlers/x402-register.ts +601 -0
  30. package/src/index.test.ts +55 -0
  31. package/src/index.ts +512 -0
  32. package/src/resolvers/content.ts +231 -0
  33. package/src/resolvers/rpc.ts +222 -0
  34. package/src/resolvers/suins.ts +266 -0
  35. package/src/sdk/messaging.ts +279 -0
  36. package/src/types.ts +230 -0
  37. package/src/utils/agent-keypair.ts +40 -0
  38. package/src/utils/authenticated-events.ts +280 -0
  39. package/src/utils/cache.ts +82 -0
  40. package/src/utils/media-pack.ts +27 -0
  41. package/src/utils/mmr.ts +181 -0
  42. package/src/utils/ns-price.ts +529 -0
  43. package/src/utils/og-image.ts +141 -0
  44. package/src/utils/onchain-activity.ts +211 -0
  45. package/src/utils/onchain-listing.ts +39 -0
  46. package/src/utils/premium.ts +29 -0
  47. package/src/utils/pricing.ts +291 -0
  48. package/src/utils/pyth-price-info.ts +63 -0
  49. package/src/utils/response.ts +204 -0
  50. package/src/utils/rpc.ts +25 -0
  51. package/src/utils/shared-wallet-js.ts +166 -0
  52. package/src/utils/social.ts +152 -0
  53. package/src/utils/status.ts +39 -0
  54. package/src/utils/subdomain.ts +116 -0
  55. package/src/utils/surflux-grpc.ts +241 -0
  56. package/src/utils/swap-transactions.ts +1222 -0
  57. package/src/utils/thunder-css.ts +1341 -0
  58. package/src/utils/thunder-js.ts +5046 -0
  59. package/src/utils/transactions.ts +65 -0
  60. package/src/utils/vault.ts +18 -0
  61. package/src/utils/wallet-kit-js.ts +2312 -0
  62. package/src/utils/wallet-session-js.ts +192 -0
  63. package/src/utils/wallet-tx-js.ts +2287 -0
  64. package/src/utils/wallet-ui-js.ts +3057 -0
  65. package/src/utils/x402-middleware.ts +428 -0
  66. package/src/utils/x402-sui.ts +171 -0
  67. package/src/utils/zksend-js.ts +166 -0
  68. package/tsconfig.json +22 -0
  69. package/workers/x402-multichain/src/index.ts +237 -0
  70. package/workers/x402-multichain/src/types.ts +80 -0
  71. package/workers/x402-multichain/tsconfig.json +20 -0
  72. package/workers/x402-multichain/wrangler.toml +11 -0
  73. package/wrangler.toml +84 -0
@@ -0,0 +1,231 @@
1
+ import type { Env, ResolverResult, SuiNSRecord } from '../types'
2
+ import { proxyResponse } from '../utils/response'
3
+
4
+ // Public IPFS gateways for fallback
5
+ const IPFS_GATEWAYS = [
6
+ 'https://cloudflare-ipfs.com/ipfs/',
7
+ 'https://ipfs.io/ipfs/',
8
+ 'https://dweb.link/ipfs/',
9
+ ]
10
+
11
+ // Walrus aggregator endpoints by network
12
+ export const WALRUS_AGGREGATORS: Record<string, string[]> = {
13
+ mainnet: [
14
+ 'https://aggregator.wal.app/v1/blobs/',
15
+ 'https://walrus-mainnet-aggregator.nodes.guru/v1/blobs/',
16
+ ],
17
+ testnet: [
18
+ 'https://aggregator.walrus-testnet.walrus.space/v1/blobs/',
19
+ 'https://wal-aggregator-testnet.staketab.org/v1/blobs/',
20
+ ],
21
+ }
22
+
23
+ /**
24
+ * Resolve and fetch content from decentralized storage
25
+ */
26
+ export async function resolveContent(
27
+ content: NonNullable<SuiNSRecord['content']>,
28
+ env: Env,
29
+ ): Promise<Response> {
30
+ switch (content.type) {
31
+ case 'ipfs':
32
+ return fetchIPFSContent(content.value)
33
+ case 'walrus':
34
+ return fetchWalrusContent(content.value, env)
35
+ case 'url':
36
+ return fetchURLContent(content.value)
37
+ default:
38
+ return new Response('Unsupported content type', { status: 400 })
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Fetch content from IPFS
44
+ */
45
+ async function fetchIPFSContent(cid: string): Promise<Response> {
46
+ // Try each gateway in order
47
+ for (const gateway of IPFS_GATEWAYS) {
48
+ try {
49
+ const response = await fetch(`${gateway}${cid}`, {
50
+ headers: {
51
+ 'User-Agent': 'sui.ski-gateway/1.0',
52
+ },
53
+ })
54
+
55
+ if (response.ok) {
56
+ return proxyResponse(response)
57
+ }
58
+ } catch {}
59
+ }
60
+
61
+ return new Response(`Failed to fetch IPFS content: ${cid}`, { status: 502 })
62
+ }
63
+
64
+ /**
65
+ * Fetch content from Walrus decentralized storage via HTTP aggregators
66
+ */
67
+ async function fetchWalrusContent(blobId: string, env: Env): Promise<Response> {
68
+ const aggregators = WALRUS_AGGREGATORS[env.WALRUS_NETWORK] || WALRUS_AGGREGATORS.testnet
69
+
70
+ // Try each aggregator in order
71
+ for (const aggregator of aggregators) {
72
+ try {
73
+ const response = await fetch(`${aggregator}${blobId}`, {
74
+ headers: {
75
+ 'User-Agent': 'sui.ski-gateway/1.0',
76
+ },
77
+ })
78
+
79
+ if (response.ok) {
80
+ const blob = await response.arrayBuffer()
81
+ const contentType = detectContentType(blob)
82
+
83
+ return new Response(blob, {
84
+ status: 200,
85
+ headers: {
86
+ 'Content-Type': contentType,
87
+ 'Cache-Control': 'public, max-age=3600',
88
+ 'Access-Control-Allow-Origin': '*',
89
+ 'X-Walrus-Blob-Id': blobId,
90
+ 'X-Walrus-Network': env.WALRUS_NETWORK,
91
+ },
92
+ })
93
+ }
94
+ } catch {}
95
+ }
96
+
97
+ return new Response(`Failed to fetch Walrus content: ${blobId}`, { status: 502 })
98
+ }
99
+
100
+ /**
101
+ * Proxy content from a URL
102
+ */
103
+ async function fetchURLContent(url: string): Promise<Response> {
104
+ try {
105
+ const response = await fetch(url, {
106
+ headers: {
107
+ 'User-Agent': 'sui.ski-gateway/1.0',
108
+ },
109
+ })
110
+
111
+ return proxyResponse(response)
112
+ } catch (error) {
113
+ const message = error instanceof Error ? error.message : 'Unknown error'
114
+ return new Response(`Failed to fetch URL content: ${message}`, { status: 502 })
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Detect content type from blob data
120
+ */
121
+ export function detectContentType(data: Uint8Array | ArrayBuffer): string {
122
+ const bytes = new Uint8Array(data)
123
+
124
+ // Check magic bytes for common formats
125
+ if (bytes[0] === 0x3c) {
126
+ // < - likely HTML
127
+ return 'text/html; charset=utf-8'
128
+ }
129
+ if (bytes[0] === 0x7b) {
130
+ // { - likely JSON
131
+ return 'application/json'
132
+ }
133
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
134
+ // PNG
135
+ return 'image/png'
136
+ }
137
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
138
+ // JPEG
139
+ return 'image/jpeg'
140
+ }
141
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
142
+ // GIF
143
+ return 'image/gif'
144
+ }
145
+ if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
146
+ // PDF
147
+ return 'application/pdf'
148
+ }
149
+
150
+ // MP3 - MPEG audio Layer III sync bytes
151
+ if (
152
+ bytes[0] === 0xff &&
153
+ (bytes[1] === 0xfb || bytes[1] === 0xfa || bytes[1] === 0xf3 || bytes[1] === 0xf2)
154
+ ) {
155
+ return 'audio/mpeg'
156
+ }
157
+
158
+ // MP3 with ID3 tag
159
+ if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
160
+ // ID3 tag header
161
+ return 'audio/mpeg'
162
+ }
163
+
164
+ // MP4/M4A/MOV - check for 'ftyp' box at offset 4
165
+ if (
166
+ bytes.length > 8 &&
167
+ bytes[4] === 0x66 &&
168
+ bytes[5] === 0x74 &&
169
+ bytes[6] === 0x79 &&
170
+ bytes[7] === 0x70
171
+ ) {
172
+ // Check the brand to distinguish video vs audio
173
+ const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11])
174
+ if (brand === 'M4A ' || brand === 'M4B ') {
175
+ return 'audio/mp4'
176
+ }
177
+ return 'video/mp4'
178
+ }
179
+
180
+ // WebM - EBML header
181
+ if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
182
+ return 'video/webm'
183
+ }
184
+
185
+ // OGG - 'OggS' magic
186
+ if (bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) {
187
+ return 'audio/ogg'
188
+ }
189
+
190
+ // WAV - 'RIFF....WAVE'
191
+ if (
192
+ bytes[0] === 0x52 &&
193
+ bytes[1] === 0x49 &&
194
+ bytes[2] === 0x46 &&
195
+ bytes[3] === 0x46 &&
196
+ bytes[8] === 0x57 &&
197
+ bytes[9] === 0x41 &&
198
+ bytes[10] === 0x56 &&
199
+ bytes[11] === 0x45
200
+ ) {
201
+ return 'audio/wav'
202
+ }
203
+
204
+ // FLAC
205
+ if (bytes[0] === 0x66 && bytes[1] === 0x4c && bytes[2] === 0x61 && bytes[3] === 0x43) {
206
+ return 'audio/flac'
207
+ }
208
+
209
+ return 'application/octet-stream'
210
+ }
211
+
212
+ /**
213
+ * Resolve direct content from subdomain pattern
214
+ * - ipfs-{cid}.sui.ski -> IPFS content
215
+ * - walrus-{blobId}.sui.ski -> Walrus content
216
+ */
217
+ export async function resolveDirectContent(subdomain: string, env: Env): Promise<ResolverResult> {
218
+ if (subdomain.startsWith('ipfs-')) {
219
+ const cid = subdomain.slice(5)
220
+ const response = await fetchIPFSContent(cid)
221
+ return { found: response.ok, data: response }
222
+ }
223
+
224
+ if (subdomain.startsWith('walrus-')) {
225
+ const blobId = subdomain.slice(7)
226
+ const response = await fetchWalrusContent(blobId, env)
227
+ return { found: response.ok, data: response }
228
+ }
229
+
230
+ return { found: false, error: 'Invalid content subdomain pattern' }
231
+ }
@@ -0,0 +1,222 @@
1
+ import type { Env } from '../types'
2
+ import { errorResponse, jsonResponse } from '../utils/response'
3
+
4
+ // Allowed RPC methods - limit to read-only operations for public gateway
5
+ const ALLOWED_METHODS = new Set([
6
+ // Read methods (sui_ prefix - legacy)
7
+ 'sui_getObject',
8
+ 'sui_multiGetObjects',
9
+ 'sui_getOwnedObjects',
10
+ 'sui_getTotalTransactionBlocks',
11
+ 'sui_getTransactionBlock',
12
+ 'sui_multiGetTransactionBlocks',
13
+ 'sui_getEvents',
14
+ 'sui_getLatestCheckpointSequenceNumber',
15
+ 'sui_getCheckpoint',
16
+ 'sui_getCheckpoints',
17
+ 'sui_getProtocolConfig',
18
+ 'sui_getChainIdentifier',
19
+ // Read methods (suix_ prefix - newer SDK)
20
+ 'suix_getOwnedObjects',
21
+ 'suix_queryTransactionBlocks',
22
+ 'suix_queryEvents',
23
+ // Coin queries
24
+ 'suix_getBalance',
25
+ 'suix_getAllBalances',
26
+ 'suix_getCoins',
27
+ 'suix_getAllCoins',
28
+ 'suix_getTotalSupply',
29
+ 'suix_getCoinMetadata',
30
+ // Name service
31
+ 'suix_resolveNameServiceAddress',
32
+ 'suix_resolveNameServiceNames',
33
+ // Move
34
+ 'sui_getNormalizedMoveModulesByPackage',
35
+ 'sui_getNormalizedMoveModule',
36
+ 'sui_getNormalizedMoveFunction',
37
+ 'sui_getNormalizedMoveStruct',
38
+ 'sui_getMoveFunctionArgTypes',
39
+ // Dynamic fields
40
+ 'suix_getDynamicFields',
41
+ 'suix_getDynamicFieldObject',
42
+ // Dry run (no state changes)
43
+ 'sui_dryRunTransactionBlock',
44
+ 'sui_devInspectTransactionBlock',
45
+ // Gas price (read-only)
46
+ 'suix_getReferenceGasPrice',
47
+ // Authenticated events (read-only)
48
+ 'sui_listAuthenticatedEvents',
49
+ 'sui_getEventStreamHead',
50
+ 'sui_getObjectInclusionProof',
51
+ ])
52
+
53
+ // Rate limiting - simple in-memory counter (use Durable Objects for production)
54
+ const requestCounts = new Map<string, { count: number; resetAt: number }>()
55
+ const RATE_LIMIT = 100 // requests per minute
56
+ const RATE_WINDOW = 60000 // 1 minute in ms
57
+
58
+ /**
59
+ * Handle RPC proxy requests
60
+ */
61
+ export async function handleRPCRequest(request: Request, env: Env): Promise<Response> {
62
+ // Only allow POST for JSON-RPC
63
+ if (request.method !== 'POST') {
64
+ return errorResponse(
65
+ 'Method not allowed. Use POST for JSON-RPC requests.',
66
+ 'METHOD_NOT_ALLOWED',
67
+ 405,
68
+ )
69
+ }
70
+
71
+ // Rate limiting by IP
72
+ const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'
73
+ if (!checkRateLimit(clientIP)) {
74
+ return errorResponse('Rate limit exceeded. Please try again later.', 'RATE_LIMITED', 429)
75
+ }
76
+
77
+ // Parse the JSON-RPC request
78
+ let rpcRequest: JsonRpcRequest | JsonRpcRequest[]
79
+ try {
80
+ rpcRequest = await request.json()
81
+ } catch {
82
+ return errorResponse('Invalid JSON', 'PARSE_ERROR', 400)
83
+ }
84
+
85
+ // Handle batch requests
86
+ if (Array.isArray(rpcRequest)) {
87
+ const results = await Promise.all(rpcRequest.map((req) => processRPCRequest(req, env)))
88
+ return jsonResponse(results)
89
+ }
90
+
91
+ // Single request
92
+ const result = await processRPCRequest(rpcRequest, env)
93
+ return jsonResponse(result)
94
+ }
95
+
96
+ interface JsonRpcRequest {
97
+ jsonrpc: string
98
+ id: string | number | null
99
+ method: string
100
+ params?: unknown[]
101
+ }
102
+
103
+ interface JsonRpcResponse {
104
+ jsonrpc: '2.0'
105
+ id: string | number | null
106
+ result?: unknown
107
+ error?: {
108
+ code: number
109
+ message: string
110
+ data?: unknown
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Process a single RPC request
116
+ */
117
+ async function processRPCRequest(rpcRequest: JsonRpcRequest, env: Env): Promise<JsonRpcResponse> {
118
+ const { jsonrpc, id, method, params } = rpcRequest
119
+
120
+ // Validate JSON-RPC version
121
+ if (jsonrpc !== '2.0') {
122
+ return {
123
+ jsonrpc: '2.0',
124
+ id,
125
+ error: { code: -32600, message: 'Invalid Request: must use JSON-RPC 2.0' },
126
+ }
127
+ }
128
+
129
+ // Check if method is allowed
130
+ if (!ALLOWED_METHODS.has(method)) {
131
+ return {
132
+ jsonrpc: '2.0',
133
+ id,
134
+ error: {
135
+ code: -32601,
136
+ message: `Method not allowed: ${method}. Only read-only methods are permitted.`,
137
+ },
138
+ }
139
+ }
140
+
141
+ // Forward to Sui RPC
142
+ try {
143
+ const response = await fetch(env.SUI_RPC_URL, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ },
148
+ body: JSON.stringify({ jsonrpc, id, method, params }),
149
+ })
150
+
151
+ const result = (await response.json()) as JsonRpcResponse
152
+ return result
153
+ } catch (error) {
154
+ const message = error instanceof Error ? error.message : 'Unknown error'
155
+ return {
156
+ jsonrpc: '2.0',
157
+ id,
158
+ error: { code: -32603, message: `Internal error: ${message}` },
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Simple rate limiting check
165
+ */
166
+ function checkRateLimit(clientIP: string): boolean {
167
+ const now = Date.now()
168
+ const record = requestCounts.get(clientIP)
169
+
170
+ if (!record || now > record.resetAt) {
171
+ requestCounts.set(clientIP, { count: 1, resetAt: now + RATE_WINDOW })
172
+ return true
173
+ }
174
+
175
+ if (record.count >= RATE_LIMIT) {
176
+ return false
177
+ }
178
+
179
+ record.count++
180
+ return true
181
+ }
182
+
183
+ /**
184
+ * Get current network status
185
+ */
186
+ export async function getNetworkStatus(env: Env): Promise<{
187
+ network: string
188
+ chainId: string
189
+ latestCheckpoint: string
190
+ }> {
191
+ const [chainIdResponse, checkpointResponse] = await Promise.all([
192
+ fetch(env.SUI_RPC_URL, {
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json' },
195
+ body: JSON.stringify({
196
+ jsonrpc: '2.0',
197
+ id: 1,
198
+ method: 'sui_getChainIdentifier',
199
+ params: [],
200
+ }),
201
+ }),
202
+ fetch(env.SUI_RPC_URL, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({
206
+ jsonrpc: '2.0',
207
+ id: 2,
208
+ method: 'sui_getLatestCheckpointSequenceNumber',
209
+ params: [],
210
+ }),
211
+ }),
212
+ ])
213
+
214
+ const chainIdResult = (await chainIdResponse.json()) as JsonRpcResponse
215
+ const checkpointResult = (await checkpointResponse.json()) as JsonRpcResponse
216
+
217
+ return {
218
+ network: env.SUI_NETWORK,
219
+ chainId: String(chainIdResult.result || 'unknown'),
220
+ latestCheckpoint: String(checkpointResult.result || 'unknown'),
221
+ }
222
+ }
@@ -0,0 +1,266 @@
1
+ import { SuiJsonRpcClient as SuiClient } from '@mysten/sui/jsonRpc'
2
+ import { SuinsClient } from '@mysten/suins'
3
+ import type { Env, ResolverResult, SuiNSRecord } from '../types'
4
+ import { cacheKey } from '../utils/cache'
5
+ import { getDefaultRpcUrl } from '../utils/rpc'
6
+ import { toSuiNSName } from '../utils/subdomain'
7
+ import { lookupName as surfluxLookupName } from '../utils/surflux-grpc'
8
+
9
+ const CACHE_TTL = 600
10
+ const GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
11
+
12
+ const suinsMemCache = new Map<
13
+ string,
14
+ { data: SuiNSRecord & { expirationTimestampMs?: string }; exp: number }
15
+ >()
16
+
17
+ export async function resolveSuiNS(
18
+ name: string,
19
+ env: Env,
20
+ skipCache = false,
21
+ ): Promise<ResolverResult> {
22
+ const suinsName = toSuiNSName(name)
23
+ const key = cacheKey('suins-v2', env.SUI_NETWORK, suinsName)
24
+
25
+ // KV caching disabled - use in-memory only to reduce KV operations
26
+ if (!skipCache) {
27
+ const mem = suinsMemCache.get(key)
28
+ if (mem && mem.exp > Date.now()) {
29
+ return processRecord(mem.data, suinsName)
30
+ }
31
+ }
32
+
33
+ function processRecord(
34
+ record: SuiNSRecord & { expirationTimestampMs?: string },
35
+ name: string,
36
+ ): ResolverResult {
37
+ if (record.expirationTimestampMs) {
38
+ const expirationTime = Number(record.expirationTimestampMs)
39
+ const now = Date.now()
40
+ const gracePeriodEnd = expirationTime + GRACE_PERIOD_MS
41
+
42
+ if (now >= gracePeriodEnd) {
43
+ return {
44
+ found: false,
45
+ error: `Name "${name}" has expired and is available`,
46
+ expired: true,
47
+ available: true,
48
+ }
49
+ }
50
+ if (now >= expirationTime) {
51
+ return {
52
+ found: true,
53
+ data: record,
54
+ cacheTtl: CACHE_TTL,
55
+ expired: true,
56
+ inGracePeriod: true,
57
+ }
58
+ }
59
+ }
60
+ return { found: true, data: record, cacheTtl: CACHE_TTL }
61
+ }
62
+
63
+ try {
64
+ // Surflux gRPC-Web (faster, with proper protobuf encoding)
65
+ if (env.SURFLUX_API_KEY) {
66
+ const surfluxRecord = await surfluxLookupName(suinsName, env)
67
+ if (surfluxRecord) {
68
+ // Fetch NFT owner if we have the NFT ID (Surflux doesn't return owner)
69
+ if (surfluxRecord.nftId && !surfluxRecord.ownerAddress) {
70
+ try {
71
+ const suiClient = new SuiClient({
72
+ url: getDefaultRpcUrl(env.SUI_NETWORK),
73
+ network: env.SUI_NETWORK,
74
+ })
75
+ const nftObject = await suiClient.getObject({
76
+ id: surfluxRecord.nftId,
77
+ options: { showOwner: true },
78
+ })
79
+ if (nftObject.data?.owner) {
80
+ const owner = nftObject.data.owner
81
+ if (typeof owner === 'string') {
82
+ surfluxRecord.ownerAddress = owner
83
+ } else if ('AddressOwner' in owner) {
84
+ surfluxRecord.ownerAddress = owner.AddressOwner
85
+ } else if ('ObjectOwner' in owner) {
86
+ surfluxRecord.ownerAddress = owner.ObjectOwner
87
+ }
88
+ }
89
+ } catch (e) {
90
+ console.log('Could not fetch NFT owner for Surflux record:', e)
91
+ }
92
+ }
93
+ const result = processRecord(surfluxRecord, suinsName)
94
+ if (result.found && !result.expired) {
95
+ suinsMemCache.set(key, { data: surfluxRecord, exp: Date.now() + 300000 })
96
+ }
97
+ return result
98
+ }
99
+ }
100
+
101
+ // Fallback: SuiNS SDK (JSON-RPC)
102
+ const suiClient = new SuiClient({
103
+ url: getDefaultRpcUrl(env.SUI_NETWORK),
104
+ network: env.SUI_NETWORK,
105
+ })
106
+ const suinsClient = new SuinsClient({
107
+ client: suiClient as never,
108
+ network: env.SUI_NETWORK as 'mainnet' | 'testnet',
109
+ })
110
+
111
+ // Get the name record
112
+ const nameRecord = await suinsClient.getNameRecord(suinsName)
113
+ if (!nameRecord) {
114
+ // Name genuinely not found - available for registration
115
+ return { found: false, error: `Name "${suinsName}" not found`, available: true }
116
+ }
117
+
118
+ let ownerAddress: string | undefined
119
+ if (nameRecord.nftId) {
120
+ try {
121
+ const nftObject = await suiClient.getObject({
122
+ id: nameRecord.nftId,
123
+ options: { showOwner: true },
124
+ })
125
+ if (nftObject.data?.owner) {
126
+ const owner = nftObject.data.owner
127
+ if (typeof owner === 'string') {
128
+ ownerAddress = owner
129
+ } else if ('AddressOwner' in owner) {
130
+ ownerAddress = owner.AddressOwner
131
+ } else if ('ObjectOwner' in owner) {
132
+ ownerAddress = owner.ObjectOwner
133
+ }
134
+ }
135
+ } catch (e) {
136
+ console.log('Could not fetch NFT owner:', e)
137
+ }
138
+ }
139
+
140
+ // Check expiration status
141
+ let expired = false
142
+ let inGracePeriod = false
143
+ if (nameRecord.expirationTimestampMs) {
144
+ const expirationTime = Number(nameRecord.expirationTimestampMs)
145
+ const now = Date.now()
146
+ const gracePeriodEnd = expirationTime + GRACE_PERIOD_MS
147
+
148
+ if (now >= gracePeriodEnd) {
149
+ // Past grace period - name is available for registration
150
+ return {
151
+ found: false,
152
+ error: `Name "${suinsName}" has expired and is available`,
153
+ expired: true,
154
+ available: true,
155
+ }
156
+ }
157
+ if (now >= expirationTime) {
158
+ // In grace period - continue to fetch data but mark as expired
159
+ expired = true
160
+ inGracePeriod = true
161
+ }
162
+ }
163
+
164
+ // Get the resolved address
165
+ const address = nameRecord.targetAddress
166
+
167
+ // Fetch additional data if available
168
+ const record: SuiNSRecord = {
169
+ address: address || '',
170
+ ownerAddress: ownerAddress,
171
+ records: nameRecord.data || {},
172
+ }
173
+
174
+ // Store expiration for cache validation
175
+ if (nameRecord.expirationTimestampMs) {
176
+ record.expirationTimestampMs = String(nameRecord.expirationTimestampMs)
177
+ }
178
+
179
+ if (nameRecord.nftId) {
180
+ record.nftId = nameRecord.nftId
181
+ }
182
+
183
+ // Try to get avatar and content hash from name record data
184
+ if (nameRecord.avatar) {
185
+ record.avatar = nameRecord.avatar
186
+ }
187
+
188
+ if (nameRecord.contentHash) {
189
+ record.contentHash = nameRecord.contentHash
190
+ // Parse content hash to determine type
191
+ record.content = parseContentHash(nameRecord.contentHash)
192
+ }
193
+
194
+ if (nameRecord.walrusSiteId) {
195
+ record.walrusSiteId = nameRecord.walrusSiteId
196
+ // Prefer contentHash when present; fallback to walrusSiteId
197
+ if (!record.content) {
198
+ record.content = { type: 'walrus', value: nameRecord.walrusSiteId }
199
+ }
200
+ }
201
+
202
+ if (!expired) {
203
+ if (suinsMemCache.size > 100) {
204
+ const first = suinsMemCache.keys().next().value
205
+ if (first) suinsMemCache.delete(first)
206
+ }
207
+ suinsMemCache.set(key, { data: record, exp: Date.now() + 300000 })
208
+ }
209
+
210
+ return { found: true, data: record, cacheTtl: CACHE_TTL, expired, inGracePeriod }
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : 'Unknown error'
213
+ // SuinsClient throws ObjectError with "does not exist" for genuinely non-existent names
214
+ if (message.includes('does not exist')) {
215
+ return { found: false, error: `Name "${suinsName}" not found`, available: true }
216
+ }
217
+ // Other resolution errors - do NOT mark as available, this could be a registered name
218
+ // that we failed to resolve due to network/RPC issues
219
+ return { found: false, error: message, available: false }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse content hash to determine storage type
225
+ */
226
+ function parseContentHash(hash: string): SuiNSRecord['content'] {
227
+ // IPFS CID v0 starts with Qm, v1 starts with bafy
228
+ if (hash.startsWith('Qm') || hash.startsWith('bafy')) {
229
+ return { type: 'ipfs', value: hash }
230
+ }
231
+
232
+ // Walrus blob IDs are typically base64 encoded
233
+ if (hash.startsWith('walrus://') || hash.startsWith('0x')) {
234
+ return { type: 'walrus', value: hash.replace('walrus://', '') }
235
+ }
236
+
237
+ // Check if it's a URL
238
+ if (hash.startsWith('http://') || hash.startsWith('https://')) {
239
+ return { type: 'url', value: hash }
240
+ }
241
+
242
+ // Default to Walrus for unrecognized formats (common on Sui)
243
+ return { type: 'walrus', value: hash }
244
+ }
245
+
246
+ /**
247
+ * Get the owner address for a SuiNS name
248
+ */
249
+ export async function getSuiNSOwner(name: string, env: Env): Promise<string | null> {
250
+ try {
251
+ const suiClient = new SuiClient({
252
+ url: getDefaultRpcUrl(env.SUI_NETWORK),
253
+ network: env.SUI_NETWORK,
254
+ })
255
+ const suinsClient = new SuinsClient({
256
+ client: suiClient as never,
257
+ network: env.SUI_NETWORK as 'mainnet' | 'testnet',
258
+ })
259
+
260
+ const suinsName = toSuiNSName(name)
261
+ const nameRecord = await suinsClient.getNameRecord(suinsName)
262
+ return nameRecord?.targetAddress || null
263
+ } catch {
264
+ return null
265
+ }
266
+ }