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.
- package/AGENTS.md +311 -0
- package/CLAUDE.md +292 -0
- package/CODEBASE_GUIDE.md +217 -0
- package/README.md +77 -0
- package/biome.json +28 -0
- package/package.json +73 -0
- package/scripts/deploy-messaging-mainnet.sh +184 -0
- package/scripts/extract-suins-object.ts +180 -0
- package/scripts/full-deploy.sh +26 -0
- package/scripts/obsidian.ts +243 -0
- package/scripts/set-suins-contenthash.ts +130 -0
- package/scripts/setup-ika-dwallet.ts +338 -0
- package/scripts/transfer-upgrade-cap-from-nft.ts +86 -0
- package/src/durable-objects/wallet-session.ts +333 -0
- package/src/handlers/app.ts +1430 -0
- package/src/handlers/authenticated-events.ts +267 -0
- package/src/handlers/dashboard.ts +1659 -0
- package/src/handlers/landing.ts +6751 -0
- package/src/handlers/mcp.ts +556 -0
- package/src/handlers/messaging-sdk.ts +220 -0
- package/src/handlers/profile.css.ts +9332 -0
- package/src/handlers/profile.ts +12640 -0
- package/src/handlers/register2.ts +2811 -0
- package/src/handlers/ski-sign.ts +1901 -0
- package/src/handlers/ski.ts +314 -0
- package/src/handlers/thunder.ts +940 -0
- package/src/handlers/vault.ts +284 -0
- package/src/handlers/wallet-api.ts +169 -0
- package/src/handlers/x402-register.ts +601 -0
- package/src/index.test.ts +55 -0
- package/src/index.ts +512 -0
- package/src/resolvers/content.ts +231 -0
- package/src/resolvers/rpc.ts +222 -0
- package/src/resolvers/suins.ts +266 -0
- package/src/sdk/messaging.ts +279 -0
- package/src/types.ts +230 -0
- package/src/utils/agent-keypair.ts +40 -0
- package/src/utils/authenticated-events.ts +280 -0
- package/src/utils/cache.ts +82 -0
- package/src/utils/media-pack.ts +27 -0
- package/src/utils/mmr.ts +181 -0
- package/src/utils/ns-price.ts +529 -0
- package/src/utils/og-image.ts +141 -0
- package/src/utils/onchain-activity.ts +211 -0
- package/src/utils/onchain-listing.ts +39 -0
- package/src/utils/premium.ts +29 -0
- package/src/utils/pricing.ts +291 -0
- package/src/utils/pyth-price-info.ts +63 -0
- package/src/utils/response.ts +204 -0
- package/src/utils/rpc.ts +25 -0
- package/src/utils/shared-wallet-js.ts +166 -0
- package/src/utils/social.ts +152 -0
- package/src/utils/status.ts +39 -0
- package/src/utils/subdomain.ts +116 -0
- package/src/utils/surflux-grpc.ts +241 -0
- package/src/utils/swap-transactions.ts +1222 -0
- package/src/utils/thunder-css.ts +1341 -0
- package/src/utils/thunder-js.ts +5046 -0
- package/src/utils/transactions.ts +65 -0
- package/src/utils/vault.ts +18 -0
- package/src/utils/wallet-kit-js.ts +2312 -0
- package/src/utils/wallet-session-js.ts +192 -0
- package/src/utils/wallet-tx-js.ts +2287 -0
- package/src/utils/wallet-ui-js.ts +3057 -0
- package/src/utils/x402-middleware.ts +428 -0
- package/src/utils/x402-sui.ts +171 -0
- package/src/utils/zksend-js.ts +166 -0
- package/tsconfig.json +22 -0
- package/workers/x402-multichain/src/index.ts +237 -0
- package/workers/x402-multichain/src/types.ts +80 -0
- package/workers/x402-multichain/tsconfig.json +20 -0
- package/workers/x402-multichain/wrangler.toml +11 -0
- 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
|
+
}
|