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,2811 @@
1
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
2
+ import type { Transaction } from '@mysten/sui/transactions'
3
+ import { SuinsClient, SuinsTransaction } from '@mysten/suins'
4
+ import type { Env } from '../types'
5
+ import { getDeepBookSuiPools } from '../utils/ns-price'
6
+ import { generateLogoSvg } from '../utils/og-image'
7
+ import { calculateRegistrationPrice } from '../utils/pricing'
8
+ import { jsonResponse } from '../utils/response'
9
+ import { getDefaultRpcUrl } from '../utils/rpc'
10
+ import { generateSharedWalletMountJs } from '../utils/shared-wallet-js'
11
+ import {
12
+ buildMultiCoinRegisterTx,
13
+ buildSuiRegisterTx,
14
+ buildSwapAndRegisterTx,
15
+ prependGasSwapIfNeeded,
16
+ } from '../utils/swap-transactions'
17
+ import { relaySignedTransaction } from '../utils/transactions'
18
+ import { generateExtensionNoiseFilter, generateWalletKitJs } from '../utils/wallet-kit-js'
19
+ import { generateWalletSessionJs } from '../utils/wallet-session-js'
20
+ import { generateWalletTxJs } from '../utils/wallet-tx-js'
21
+ import { generateWalletUiCss, generateWalletUiJs } from '../utils/wallet-ui-js'
22
+
23
+ const CORS_HEADERS = {
24
+ 'Access-Control-Allow-Origin': '*',
25
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
26
+ 'Access-Control-Allow-Headers': 'Content-Type',
27
+ }
28
+
29
+ const SUI_COIN_TYPE = '0x2::sui::SUI'
30
+ const NS_COIN_TYPE = '0x5145494a5f5100e645e4b0aa950fa6b68f614e8c59e17bc5ded3495123a79178::ns::NS'
31
+ const USDC_COIN_TYPE =
32
+ '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
33
+ const REGISTER_GAS_BUFFER_MIST = 80_000_000n
34
+ const REGISTER_PAYMENT_COIN_PAGE_LIMIT = 50
35
+ const REGISTER_PAYMENT_COIN_PAGE_MAX = 6
36
+ const STABLE_SYMBOL_PRIORITY = ['USDC', 'USDT', 'AUSD', 'USDY', 'FDUSD', 'DAI']
37
+
38
+ interface RegisterSession {
39
+ address: string | null
40
+ walletName: string | null
41
+ verified: boolean
42
+ }
43
+
44
+ interface RegistrationPageOptions {
45
+ flow?: 'register' | 'register2'
46
+ }
47
+
48
+ export function generateRegistrationPage(
49
+ name: string,
50
+ env: Env,
51
+ session?: RegisterSession,
52
+ options: RegistrationPageOptions = {},
53
+ ): string {
54
+ const cleanName = name.replace(/\.sui$/i, '').toLowerCase()
55
+ const network = env.SUI_NETWORK || 'mainnet'
56
+ const isRegisterable = cleanName.length >= 3
57
+ const registerFlow = options.flow === 'register2' ? 'register2' : 'register'
58
+ const registerBucket = registerFlow === 'register2' ? 'register-v2' : 'register-v1'
59
+ const serializeJson = (value: unknown) =>
60
+ JSON.stringify(value).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e')
61
+ const suiIconSvg =
62
+ '<svg class="price-sui-icon" viewBox="0 0 300 384" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M240.057 159.914C255.698 179.553 265.052 204.39 265.052 231.407C265.052 258.424 255.414 284.019 239.362 303.768L237.971 305.475L237.608 303.31C237.292 301.477 236.929 299.613 236.502 297.749C228.46 262.421 202.265 232.134 159.148 207.597C130.029 191.071 113.361 171.195 108.985 148.586C106.157 133.972 108.258 119.294 112.318 106.717C116.379 94.1569 122.414 83.6187 127.549 77.2831L144.328 56.7754C147.267 53.1731 152.781 53.1731 155.719 56.7754L240.073 159.914H240.057ZM266.584 139.422L154.155 1.96703C152.007 -0.655678 147.993 -0.655678 145.845 1.96703L33.4316 139.422L33.0683 139.881C12.3868 165.555 0 198.181 0 233.698C0 316.408 67.1635 383.461 150 383.461C232.837 383.461 300 316.408 300 233.698C300 198.181 287.613 165.555 266.932 139.896L266.568 139.438L266.584 139.422ZM60.3381 159.472L70.3866 147.164L70.6868 149.439C70.9237 151.24 71.2239 153.041 71.5715 154.858C78.0809 189.001 101.322 217.456 140.173 239.496C173.952 258.724 193.622 280.828 199.278 305.064C201.648 315.176 202.059 325.129 201.032 333.835L200.969 334.372L200.479 334.609C185.233 342.05 168.09 346.237 149.984 346.237C86.4546 346.237 34.9484 294.826 34.9484 231.391C34.9484 204.153 44.4439 179.142 60.3065 159.44L60.3381 159.472Z" fill="#4DA2FF"/></svg>'
63
+ const registrationCardHtml = isRegisterable
64
+ ? `<div class="nft-card">
65
+ <div class="nft-top">
66
+ <div class="nft-name-block">
67
+ <div class="nft-name-line"><button type="button" class="primary-star" id="primary-star" aria-label="Set as primary" aria-pressed="false" title="Set as primary name">\u2606</button><span class="nft-name">${escapeHtml(cleanName)}<span class="nft-name-tld">.sui</span></span></div>
68
+ </div>
69
+ </div>
70
+ <div class="nft-body">
71
+ <div class="nft-qr-col">
72
+ <div class="nft-price-stack" id="price-value">
73
+ <div class="nft-price-main payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI"><span class="price-amount">--</span></div>
74
+ <div class="price-rest">
75
+ <span class="price-rest-top payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI">${suiIconSvg}</span>
76
+ <span class="price-usd payment-choice" data-payment-mode="coin" role="button" tabindex="0" aria-label="Pay with USD coin estimate">\u2248 $--</span>
77
+ </div>
78
+ </div>
79
+ <span class="nft-chip"><span class="nft-dot"></span>Available</span>
80
+ <canvas class="nft-qr" id="nft-qr" width="140" height="140" title="${escapeHtml(cleanName)}.sui"></canvas>
81
+ </div>
82
+ <div class="nft-wallet-icons" id="nft-wallet-icons"></div>
83
+ <div class="nft-logo-col">
84
+ <div class="nft-logo-badge"><svg viewBox="0 0 512 560" fill="none" aria-hidden="true"><defs><linearGradient id="nlg" x1="0" y1="0" x2="0.5" y2="1"><stop offset="0%" stop-color="#ffffff"><animate attributeName="stop-color" values="#ffffff;#b8ffda;#49da91;#ffffff" dur="6s" repeatCount="indefinite"/></stop><stop offset="100%" stop-color="#b8ffda"><animate attributeName="stop-color" values="#b8ffda;#49da91;#ffffff;#b8ffda" dur="6s" repeatCount="indefinite"/></stop></linearGradient></defs><path d="M256 96L416 352H336L280 256L256 296L232 256L176 352H96Z" fill="url(#nlg)"/><path d="M128 384Q208 348 288 384Q368 420 432 384" stroke="url(#nlg)" stroke-width="24" fill="none" stroke-linecap="round"/><text x="256" y="480" font-family="Inter,system-ui,sans-serif" font-size="90" font-weight="800" fill="url(#nlg)" text-anchor="middle">sui.ski</text></svg></div>
85
+ <div class="nft-discount" id="price-savings"></div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ <div class="reg-form">
90
+ <div class="reg-action-row">
91
+ <button class="download-qr-btn" id="download-qr-btn" title="Download QR card"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
92
+ <button class="button" id="register-btn">Connect Wallet</button>
93
+ <div class="year-stepper-h" role="group" aria-label="Registration duration" tabindex="0">
94
+ <button type="button" class="year-btn-h" id="years-decrease" aria-label="Decrease">\u2212</button>
95
+ <div class="year-display-h"><span id="years-value">1</span><span class="year-unit">yr</span></div>
96
+ <button type="button" class="year-btn-h" id="years-increase" aria-label="Increase">+</button>
97
+ <input id="years" type="hidden" value="1">
98
+ </div>
99
+ </div>
100
+ <div class="status" id="register-status"></div>
101
+ </div>`
102
+ : `<div class="nft-card" style="aspect-ratio:1;justify-content:center;align-items:center;text-align:center;"><span style="font-size:1.6rem;font-weight:800;color:#fff;">${escapeHtml(cleanName)}<span style="color:var(--ski-green);">.sui</span></span><span style="font-size:0.85rem;color:var(--muted);margin-top:8px;">Minimum length is 3 characters.</span></div>`
103
+ const discoveryColumnHtml = ''
104
+
105
+ return `<!DOCTYPE html>
106
+ <html lang="en">
107
+ <head>
108
+ ${generateExtensionNoiseFilter()}
109
+ <meta charset="UTF-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
111
+ <meta name="sui-ski-register-flow" content="${registerFlow}">
112
+ <meta name="sui-ski-register-bucket" content="${registerBucket}">
113
+ <title>${escapeHtml(cleanName)}.sui available | sui.ski</title>
114
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
115
+ <style>
116
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
117
+ :root {
118
+ --bg-0: #010201;
119
+ --bg-1: #030806;
120
+ --card: rgba(3, 10, 8, 0.9);
121
+ --line: rgba(130, 255, 190, 0.24);
122
+ --text: #ebfff4;
123
+ --muted: #8fb9a3;
124
+ --accent: #49da91;
125
+ --listing-purple: #a855f7;
126
+ --listing-purple-light: #d8b4fe;
127
+ --ski-green: #49da91;
128
+ --ski-green-dark: #27bd74;
129
+ --ski-green-light: #b8ffda;
130
+ --ski-green-soft: #ecfff4;
131
+ --ski-green-rgb: 73, 218, 145;
132
+ --snow-rgb: 228, 255, 242;
133
+ --ok: var(--ski-green);
134
+ --warn: #fbbf24;
135
+ --err: #f87171;
136
+ }
137
+ * { box-sizing: border-box; margin: 0; padding: 0; }
138
+ html {
139
+ scrollbar-color: rgba(var(--ski-green-rgb), 0.82) rgba(7, 20, 16, 0.9);
140
+ }
141
+ ::-webkit-scrollbar {
142
+ width: 10px;
143
+ height: 10px;
144
+ }
145
+ ::-webkit-scrollbar-track {
146
+ background: rgba(7, 20, 16, 0.88);
147
+ }
148
+ ::-webkit-scrollbar-thumb {
149
+ background: linear-gradient(180deg, rgba(var(--ski-green-rgb), 0.95), rgba(var(--ski-green-rgb), 0.8));
150
+ border-radius: 999px;
151
+ border: 2px solid rgba(7, 20, 16, 0.88);
152
+ }
153
+ ::-webkit-scrollbar-thumb:hover {
154
+ background: linear-gradient(180deg, rgba(var(--ski-green-rgb), 1), rgba(var(--ski-green-rgb), 0.88));
155
+ }
156
+ body {
157
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
158
+ min-height: 100vh;
159
+ position: relative;
160
+ background:
161
+ radial-gradient(45% 35% at 50% 32%, rgba(255, 255, 255, 0.04) 0%, transparent 70%),
162
+ radial-gradient(80% 60% at 50% -8%, rgba(106, 255, 185, 0.16) 0%, transparent 52%),
163
+ radial-gradient(75% 62% at 100% 100%, rgba(47, 186, 116, 0.16) 0%, transparent 62%),
164
+ linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
165
+ color: var(--text);
166
+ padding: 20px;
167
+ padding-bottom: 52px;
168
+ overflow-x: hidden;
169
+ }
170
+ body::before,
171
+ body::after {
172
+ content: '';
173
+ position: fixed;
174
+ inset: -20vh 0 0 0;
175
+ pointer-events: none;
176
+ z-index: 0;
177
+ background-repeat: repeat;
178
+ }
179
+ body::before {
180
+ opacity: calc(0.32 + var(--snow-boost, 0));
181
+ background-image:
182
+ radial-gradient(circle, rgba(var(--snow-rgb), 0.86) 0 1px, transparent 1.8px),
183
+ radial-gradient(circle, rgba(var(--snow-rgb), 0.6) 0 1.2px, transparent 2px);
184
+ background-size: 190px 190px, 260px 260px;
185
+ background-position: 0 0, 88px 120px;
186
+ animation: snow-fall-a 23s linear infinite;
187
+ }
188
+ body::after {
189
+ opacity: calc(0.2 + var(--snow-boost, 0) * 0.7);
190
+ background-image:
191
+ radial-gradient(circle, rgba(var(--snow-rgb), 0.64) 0 0.9px, transparent 1.6px),
192
+ radial-gradient(circle, rgba(var(--snow-rgb), 0.5) 0 0.8px, transparent 1.4px);
193
+ background-size: 120px 120px, 170px 170px;
194
+ background-position: 20px 40px, 72px 96px;
195
+ animation: snow-fall-b 31s linear infinite;
196
+ }
197
+ @keyframes snow-fall-a {
198
+ from { transform: translateY(-12%); }
199
+ to { transform: translateY(12%); }
200
+ }
201
+ @keyframes snow-fall-b {
202
+ from { transform: translateY(-8%); }
203
+ to { transform: translateY(10%); }
204
+ }
205
+ .container {
206
+ max-width: 600px;
207
+ margin: 0 auto;
208
+ display: flex;
209
+ flex-direction: column;
210
+ gap: 12px;
211
+ position: relative;
212
+ z-index: 1;
213
+ }
214
+ .layout-grid {
215
+ display: grid;
216
+ grid-template-columns: minmax(0, 1fr);
217
+ gap: 12px;
218
+ align-items: start;
219
+ }
220
+ .nft-brand-link {
221
+ display: inline-flex;
222
+ align-items: center;
223
+ gap: 4px;
224
+ text-decoration: none;
225
+ flex-shrink: 0;
226
+ }
227
+ .nft-brand-name {
228
+ font-size: 0.84rem;
229
+ font-weight: 800;
230
+ color: var(--text);
231
+ }
232
+ .nft-brand-tagline {
233
+ font-size: 0.62rem;
234
+ font-weight: 500;
235
+ color: var(--muted);
236
+ line-height: 1.3;
237
+ opacity: 0.75;
238
+ white-space: nowrap;
239
+ }
240
+ @keyframes portal-ring {
241
+ 0% { opacity: 0.5; transform: scale(0.97); }
242
+ 50% { opacity: 1; transform: scale(1); }
243
+ 100% { opacity: 0.5; transform: scale(0.97); }
244
+ }
245
+ .nft-card {
246
+ color-scheme: only light;
247
+ width: 100%;
248
+ max-width: 380px;
249
+ aspect-ratio: 1;
250
+ margin: 0 auto;
251
+ padding: 22px;
252
+ border-radius: 20px;
253
+ background: linear-gradient(155deg, #0a1a13 0%, #040d09 100%);
254
+ border: 1px solid rgba(var(--ski-green-rgb), 0.18);
255
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
256
+ display: flex;
257
+ flex-direction: column;
258
+ justify-content: space-between;
259
+ gap: 8px;
260
+ position: relative;
261
+ }
262
+ .nft-card::before {
263
+ content: '';
264
+ position: absolute;
265
+ inset: -2px;
266
+ border-radius: 21px;
267
+ background: conic-gradient(from 0deg, transparent 0%, rgba(var(--portal-r, 73), var(--portal-g, 218), var(--portal-b, 145), var(--portal-a, 0.35)) 25%, transparent 50%, rgba(var(--portal-r, 73), var(--portal-g, 218), var(--portal-b, 145), calc(var(--portal-a, 0.35) * 0.6)) 75%, transparent 100%);
268
+ pointer-events: none;
269
+ z-index: -1;
270
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
271
+ mask-composite: exclude;
272
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
273
+ -webkit-mask-composite: xor;
274
+ padding: 1px;
275
+ animation: portal-ring 4s ease-in-out infinite;
276
+ }
277
+ .nft-top {
278
+ display: flex;
279
+ justify-content: space-between;
280
+ align-items: flex-start;
281
+ }
282
+ .nft-wallet-icons {
283
+ display: none;
284
+ flex-direction: column;
285
+ align-items: flex-start;
286
+ gap: 5px;
287
+ align-self: flex-end;
288
+ margin-bottom: 2px;
289
+ flex-shrink: 0;
290
+ }
291
+ .nft-wallet-icons.show { display: flex; }
292
+ .nft-wallet-icon-row {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 5px;
296
+ }
297
+ .nft-wallet-icon-row img,
298
+ .nft-wallet-icon-row > svg {
299
+ width: 20px;
300
+ height: 20px;
301
+ border-radius: 4px;
302
+ display: block;
303
+ flex-shrink: 0;
304
+ filter: drop-shadow(0 1px 3px rgba(0,0,0,0.4));
305
+ }
306
+ .nft-wallet-icon-label {
307
+ font-size: 0.6rem;
308
+ font-weight: 700;
309
+ color: rgba(255, 255, 255, 0.6);
310
+ white-space: nowrap;
311
+ max-width: 60px;
312
+ overflow: hidden;
313
+ text-overflow: ellipsis;
314
+ }
315
+ .nft-name-block { flex: 1; min-width: 0; }
316
+ .nft-name-line {
317
+ display: flex;
318
+ align-items: baseline;
319
+ gap: 6px;
320
+ flex-wrap: wrap;
321
+ }
322
+ @keyframes tld-pulse {
323
+ 0%, 100% { text-shadow: 0 0 8px rgba(73, 218, 145, 0.5), 0 0 20px rgba(73, 218, 145, 0.2); }
324
+ 50% { text-shadow: 0 0 12px rgba(73, 218, 145, 0.7), 0 0 30px rgba(73, 218, 145, 0.3), 0 0 50px rgba(73, 218, 145, 0.1); }
325
+ }
326
+ .nft-name {
327
+ font-size: clamp(2.2rem, 10vw, 3.2rem);
328
+ font-weight: 900;
329
+ letter-spacing: -0.04em;
330
+ line-height: 1.05;
331
+ color: #f0f4f8;
332
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 0 12px rgba(200, 220, 240, 0.15);
333
+ transition: color 0.4s ease, text-shadow 0.4s ease;
334
+ }
335
+ .nft-name-tld {
336
+ color: var(--ski-green);
337
+ text-shadow: 0 0 8px rgba(73, 218, 145, 0.5), 0 0 20px rgba(73, 218, 145, 0.2);
338
+ animation: tld-pulse 3s ease-in-out infinite;
339
+ transition: color 0.4s ease;
340
+ }
341
+ .primary-star {
342
+ width: 28px;
343
+ height: 28px;
344
+ border-radius: 999px;
345
+ border: 1px solid rgba(180, 195, 210, 0.4);
346
+ background: rgba(180, 195, 210, 0.1);
347
+ color: rgba(200, 210, 225, 0.7);
348
+ font-size: 0.95rem;
349
+ line-height: 1;
350
+ display: inline-flex;
351
+ align-items: center;
352
+ justify-content: center;
353
+ cursor: pointer;
354
+ flex-shrink: 0;
355
+ transition: all 0.15s ease;
356
+ margin-top: 4px;
357
+ }
358
+ .primary-star:hover { background: rgba(180, 195, 210, 0.2); border-color: rgba(200, 210, 225, 0.6); color: rgba(220, 225, 235, 0.85); }
359
+ .primary-star.active {
360
+ color: #c8d0e0;
361
+ background: rgba(120, 130, 155, 0.18);
362
+ border-color: rgba(120, 130, 155, 0.7);
363
+ box-shadow: 0 0 12px rgba(120, 130, 155, 0.2);
364
+ }
365
+ .nft-qr-col {
366
+ display: flex;
367
+ flex-direction: column;
368
+ align-items: stretch;
369
+ gap: 6px;
370
+ width: 120px;
371
+ flex-shrink: 0;
372
+ }
373
+ .nft-chip {
374
+ display: flex;
375
+ align-items: center;
376
+ justify-content: center;
377
+ gap: 6px;
378
+ padding: 5px 0;
379
+ border-radius: 6px;
380
+ background: rgba(13, 82, 48, 0.4);
381
+ border: 1px solid rgba(20, 130, 72, 0.5);
382
+ font-size: 0.78rem;
383
+ font-weight: 700;
384
+ color: #ffffff;
385
+ letter-spacing: 0.05em;
386
+ text-transform: uppercase;
387
+ transition: background 0.4s ease, border-color 0.4s ease;
388
+ }
389
+ .nft-dot {
390
+ width: 9px;
391
+ height: 9px;
392
+ border-radius: 50%;
393
+ background: #16a34a;
394
+ box-shadow: 0 0 10px rgba(22, 163, 74, 0.9);
395
+ transition: background 0.4s ease, box-shadow 0.4s ease;
396
+ }
397
+ .nft-body {
398
+ display: flex;
399
+ align-items: flex-end;
400
+ gap: 8px;
401
+ }
402
+ .nft-qr {
403
+ width: 100%;
404
+ height: auto;
405
+ aspect-ratio: 1;
406
+ border-radius: 10px;
407
+ }
408
+ .nft-logo-col {
409
+ display: flex;
410
+ flex-direction: column;
411
+ align-items: center;
412
+ gap: 0;
413
+ align-self: flex-end;
414
+ flex-shrink: 0;
415
+ margin-left: auto;
416
+ margin-right: -22px;
417
+ margin-bottom: -18px;
418
+ }
419
+ .nft-logo-badge {
420
+ width: 130px;
421
+ filter: drop-shadow(0 2px 12px rgba(var(--ski-green-rgb), 0.35));
422
+ }
423
+ .nft-logo-badge svg {
424
+ width: 100%;
425
+ height: auto;
426
+ display: block;
427
+ }
428
+ .nft-discount {
429
+ display: flex;
430
+ flex-direction: row;
431
+ align-items: center;
432
+ gap: 4px;
433
+ font-size: 0.72rem;
434
+ font-weight: 700;
435
+ color: rgba(255, 255, 255, 0.9);
436
+ line-height: 1.3;
437
+ margin-top: -14px;
438
+ position: relative;
439
+ z-index: 1;
440
+ }
441
+ .nft-discount .discount-sui-icon {
442
+ width: 0.65em;
443
+ height: 0.85em;
444
+ display: inline-block;
445
+ vertical-align: -0.1em;
446
+ margin-left: 1px;
447
+ margin-right: 0;
448
+ }
449
+ .nft-price-stack {
450
+ display: flex;
451
+ align-items: stretch;
452
+ gap: 0;
453
+ margin-bottom: -4px;
454
+ }
455
+ .nft-price-main {
456
+ display: flex;
457
+ align-items: center;
458
+ }
459
+ .nft-price-stack .price-amount {
460
+ font-size: clamp(2.4rem, 7vw, 3rem);
461
+ font-weight: 800;
462
+ color: #e9fff3;
463
+ line-height: 1;
464
+ }
465
+ .nft-price-stack .price-rest {
466
+ display: flex;
467
+ flex-direction: column;
468
+ justify-content: center;
469
+ gap: 1px;
470
+ margin-left: 2px;
471
+ }
472
+ .nft-price-stack .price-rest-top {
473
+ display: flex;
474
+ align-items: baseline;
475
+ gap: 4px;
476
+ }
477
+ .nft-price-stack .price-decimals {
478
+ font-size: 1.05rem;
479
+ font-weight: 700;
480
+ color: rgba(233, 255, 243, 0.75);
481
+ }
482
+ .nft-price-stack .price-sui-icon {
483
+ width: 14px;
484
+ height: 18px;
485
+ display: inline-block;
486
+ vertical-align: baseline;
487
+ fill: #4DA2FF;
488
+ }
489
+ .nft-price-stack .price-usd {
490
+ color: var(--muted);
491
+ font-size: 0.88rem;
492
+ font-weight: 700;
493
+ white-space: nowrap;
494
+ }
495
+ .nft-price-stack .payment-choice {
496
+ border-radius: 8px;
497
+ cursor: pointer;
498
+ transition: background 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
499
+ }
500
+ .nft-price-stack .payment-choice:hover {
501
+ background: rgba(var(--ski-green-rgb), 0.09);
502
+ }
503
+ .nft-price-stack .payment-choice.active {
504
+ background: rgba(var(--ski-green-rgb), 0.17);
505
+ box-shadow: 0 0 0 1px rgba(var(--ski-green-rgb), 0.42) inset;
506
+ }
507
+ .reg-form {
508
+ max-width: 440px;
509
+ margin: 8px auto 0;
510
+ display: grid;
511
+ gap: 10px;
512
+ width: 100%;
513
+ }
514
+ .reg-action-row {
515
+ display: flex;
516
+ align-items: stretch;
517
+ gap: 6px;
518
+ }
519
+ .reg-action-row .button {
520
+ flex: 1;
521
+ white-space: nowrap;
522
+ }
523
+ .download-qr-btn {
524
+ width: 38px;
525
+ flex-shrink: 0;
526
+ display: flex;
527
+ align-items: center;
528
+ justify-content: center;
529
+ padding: 0;
530
+ border-radius: 10px;
531
+ border: 1px solid rgba(var(--ski-green-rgb), 0.18);
532
+ background: rgba(var(--ski-green-rgb), 0.05);
533
+ color: var(--muted);
534
+ cursor: pointer;
535
+ opacity: 0.45;
536
+ transition: opacity 0.2s ease, border-color 0.2s ease, background 0.2s ease;
537
+ }
538
+ .download-qr-btn:hover { opacity: 1; border-color: rgba(var(--ski-green-rgb), 0.45); color: var(--ski-green-light); background: rgba(var(--ski-green-rgb), 0.1); }
539
+ .download-qr-btn.active { opacity: 0.9; border-color: rgba(var(--ski-green-rgb), 0.5); color: var(--ski-green); background: rgba(var(--ski-green-rgb), 0.12); }
540
+ .download-qr-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
541
+ .reg-sui-icon {
542
+ width: 0.85em;
543
+ height: 1.1em;
544
+ display: inline-block;
545
+ vertical-align: -0.15em;
546
+ margin-left: 1px;
547
+ }
548
+ .price-amount { color: #e9fff3; }
549
+ .price-sui-icon {
550
+ width: 0.6em;
551
+ height: 0.77em;
552
+ display: inline-block;
553
+ vertical-align: -0.05em;
554
+ fill: #4DA2FF;
555
+ }
556
+ .price-usd {
557
+ color: var(--muted);
558
+ font-size: 0.44em;
559
+ font-weight: 600;
560
+ white-space: nowrap;
561
+ }
562
+ .price-decimals {
563
+ font-size: 0.56em;
564
+ font-weight: 700;
565
+ opacity: 0.9;
566
+ margin-left: 2px;
567
+ }
568
+ .year-stepper-h {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 0;
572
+ border-radius: 10px;
573
+ border: 1px solid rgba(var(--ski-green-rgb), 0.28);
574
+ background: rgba(10, 34, 22, 0.55);
575
+ flex-shrink: 0;
576
+ transition: border-color 0.4s ease;
577
+ outline: none;
578
+ }
579
+ .year-stepper-h:focus-within { border-color: rgba(var(--ski-green-rgb), 0.5); }
580
+ .year-btn-h {
581
+ width: 34px;
582
+ height: 38px;
583
+ border: none;
584
+ background: rgba(var(--ski-green-rgb), 0.1);
585
+ color: var(--ski-green-light);
586
+ font-size: 1rem;
587
+ font-weight: 800;
588
+ line-height: 1;
589
+ cursor: pointer;
590
+ display: flex;
591
+ align-items: center;
592
+ justify-content: center;
593
+ }
594
+ .year-btn-h:first-child { border-radius: 9px 0 0 9px; }
595
+ .year-btn-h:last-of-type { border-radius: 0 9px 9px 0; }
596
+ .year-btn-h:hover { background: rgba(var(--ski-green-rgb), 0.22); }
597
+ .year-btn-h:disabled { opacity: 0.35; cursor: not-allowed; }
598
+ .year-display-h {
599
+ display: flex;
600
+ align-items: baseline;
601
+ gap: 2px;
602
+ font-size: 1rem;
603
+ font-weight: 800;
604
+ color: var(--text);
605
+ padding: 0 6px;
606
+ white-space: nowrap;
607
+ user-select: none;
608
+ }
609
+ .year-unit {
610
+ font-size: 0.6rem;
611
+ font-weight: 600;
612
+ color: var(--muted);
613
+ text-transform: uppercase;
614
+ letter-spacing: 0.06em;
615
+ }
616
+ .button {
617
+ width: 100%;
618
+ padding: 14px 18px;
619
+ border-radius: 14px;
620
+ border: 1px solid rgba(var(--ski-green-rgb), 0.5);
621
+ background: linear-gradient(135deg, rgba(14, 56, 36, 0.95), rgba(8, 42, 27, 0.96));
622
+ box-shadow: inset 0 1px 0 rgba(var(--ski-green-rgb), 0.2);
623
+ color: var(--ski-green-light);
624
+ font-weight: 800;
625
+ font-size: 0.92rem;
626
+ cursor: pointer;
627
+ }
628
+ .button:hover { background: linear-gradient(135deg, rgba(16, 67, 42, 0.96), rgba(9, 50, 31, 0.98)); }
629
+ .button:disabled { opacity: 0.55; cursor: not-allowed; }
630
+ .status {
631
+ display: none;
632
+ padding: 8px 10px;
633
+ border-radius: 8px;
634
+ font-size: 0.8rem;
635
+ }
636
+ .status.show { display: block; }
637
+ .status.info {
638
+ background: rgba(var(--ski-green-rgb), 0.12);
639
+ border: 1px solid rgba(var(--ski-green-rgb), 0.3);
640
+ color: var(--ski-green-light);
641
+ }
642
+ .status.ok { background: rgba(var(--ski-green-rgb),0.15); border: 1px solid rgba(var(--ski-green-rgb),0.36); color: var(--ski-green-light); }
643
+ .status.err { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #ffb0b0; }
644
+ .home-float-btn {
645
+ position: fixed;
646
+ top: calc(16px + env(safe-area-inset-top));
647
+ left: calc(16px + env(safe-area-inset-left));
648
+ z-index: 10035;
649
+ display: flex;
650
+ flex-direction: column;
651
+ align-items: center;
652
+ justify-content: center;
653
+ gap: 3px;
654
+ width: 52px;
655
+ height: 52px;
656
+ padding: 0;
657
+ border-radius: 12px;
658
+ background: rgba(7, 14, 28, 0.72);
659
+ border: 1px solid rgba(255, 255, 255, 0.62);
660
+ text-decoration: none;
661
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
662
+ backdrop-filter: blur(8px);
663
+ -webkit-backdrop-filter: blur(8px);
664
+ transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
665
+ }
666
+ .home-float-btn:hover {
667
+ background: rgba(12, 22, 40, 0.88);
668
+ border-color: rgba(255, 255, 255, 0.92);
669
+ transform: translateY(-1px);
670
+ }
671
+ .home-float-sail {
672
+ width: 28px;
673
+ height: 10px;
674
+ display: block;
675
+ }
676
+ .home-float-sail svg {
677
+ width: 100%;
678
+ height: 100%;
679
+ display: block;
680
+ }
681
+ .home-float-wordmark {
682
+ display: flex;
683
+ align-items: center;
684
+ justify-content: center;
685
+ }
686
+ .home-float-dot {
687
+ width: 12px;
688
+ height: 12px;
689
+ border-radius: 999px;
690
+ background: #40c463;
691
+ border: 2px solid #ffffff;
692
+ box-shadow: 0 0 12px rgba(64, 196, 99, 0.48);
693
+ flex-shrink: 0;
694
+ }
695
+ .wallet-widget {
696
+ position: fixed;
697
+ top: calc(16px + env(safe-area-inset-top));
698
+ right: calc(16px + env(safe-area-inset-right));
699
+ z-index: 10040;
700
+ display: flex;
701
+ align-items: center;
702
+ gap: 10px;
703
+ }
704
+ .wallet-profile-btn {
705
+ min-width: 74px;
706
+ height: 40px;
707
+ border-radius: 10px;
708
+ display: none;
709
+ align-items: center;
710
+ justify-content: center;
711
+ gap: 7px;
712
+ background: rgba(7, 14, 28, 0.72);
713
+ border: 1px solid rgba(255, 255, 255, 0.55);
714
+ padding: 0 10px;
715
+ cursor: pointer;
716
+ }
717
+ .wallet-profile-mark {
718
+ width: 14px;
719
+ height: 14px;
720
+ background: #177ec7;
721
+ border: 1.5px solid #ffffff;
722
+ border-radius: 3px;
723
+ flex-shrink: 0;
724
+ }
725
+ .wallet-profile-text {
726
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
727
+ font-size: 0.96rem;
728
+ font-weight: 700;
729
+ letter-spacing: 0.04em;
730
+ color: #ffffff;
731
+ line-height: 1;
732
+ }
733
+ .wallet-profile-btn.visible { display: inline-flex; }
734
+ .wallet-profile-btn:hover { background: rgba(12, 23, 44, 0.85); border-color: rgba(255, 255, 255, 0.82); }
735
+ .wallet-widget.has-black-diamond .wallet-profile-btn {
736
+ background: linear-gradient(135deg, rgba(10, 10, 18, 0.6), rgba(18, 18, 28, 0.7));
737
+ border-color: rgba(120, 130, 155, 0.42);
738
+ box-shadow: 0 0 24px rgba(0, 0, 0, 0.4);
739
+ }
740
+ .wallet-widget.has-black-diamond .wallet-profile-btn:hover {
741
+ background: linear-gradient(135deg, rgba(16, 16, 26, 0.7), rgba(24, 24, 36, 0.8));
742
+ border-color: rgba(140, 150, 175, 0.62);
743
+ box-shadow: 0 0 28px rgba(0, 0, 0, 0.5);
744
+ }
745
+ .wallet-widget.has-black-diamond #wk-widget .wk-widget-btn.connected,
746
+ .wallet-widget.has-black-diamond #wk-widget > div > button.connected {
747
+ background: linear-gradient(135deg, rgba(8, 8, 16, 0.95), rgba(16, 16, 30, 0.94));
748
+ border-color: rgba(198, 170, 98, 0.62);
749
+ color: #d0d4e0;
750
+ box-shadow:
751
+ 0 0 0 1px rgba(160, 120, 56, 0.24) inset,
752
+ 0 10px 24px rgba(0, 0, 0, 0.58),
753
+ 0 0 18px rgba(194, 145, 72, 0.26);
754
+ }
755
+ .wallet-widget.has-black-diamond #wk-widget .wk-widget-btn.connected:hover,
756
+ .wallet-widget.has-black-diamond #wk-widget > div > button.connected:hover {
757
+ border-color: rgba(234, 206, 128, 0.88);
758
+ box-shadow:
759
+ 0 0 0 1px rgba(196, 154, 76, 0.34) inset,
760
+ 0 12px 28px rgba(0, 0, 0, 0.62),
761
+ 0 0 24px rgba(234, 179, 8, 0.28);
762
+ }
763
+ .wallet-widget:not(.has-black-diamond) #wk-widget .wk-widget-btn,
764
+ .wallet-widget:not(.has-black-diamond) #wk-widget > div > button {
765
+ border-color: rgba(255, 255, 255, 0.78);
766
+ }
767
+ .wallet-widget:not(.has-black-diamond) #wk-widget .wk-widget-btn:hover,
768
+ .wallet-widget:not(.has-black-diamond) #wk-widget > div > button:hover {
769
+ border-color: rgba(255, 255, 255, 0.96);
770
+ }
771
+ .tracker-footer {
772
+ position: fixed;
773
+ left: 0;
774
+ right: 0;
775
+ bottom: 0;
776
+ z-index: 900;
777
+ background: rgba(2, 9, 6, 0.94);
778
+ backdrop-filter: blur(10px);
779
+ border-top: 1px solid rgba(var(--ski-green-rgb), 0.35);
780
+ display: flex;
781
+ flex-direction: column;
782
+ align-items: center;
783
+ gap: 0;
784
+ }
785
+ .tracker-brand {
786
+ display: flex;
787
+ align-items: center;
788
+ justify-content: center;
789
+ gap: 8px;
790
+ padding: 7px 16px 0;
791
+ }
792
+ .tracker-line {
793
+ display: flex;
794
+ align-items: center;
795
+ gap: 10px;
796
+ font-size: 0.84rem;
797
+ color: #8cae9a;
798
+ white-space: nowrap;
799
+ overflow-x: auto;
800
+ max-width: 100%;
801
+ scrollbar-width: none;
802
+ padding: 5px 16px 9px;
803
+ }
804
+ .tracker-line::-webkit-scrollbar { display: none; }
805
+ .tracker-price-label {
806
+ color: #deffec;
807
+ font-weight: 600;
808
+ display: inline-flex;
809
+ align-items: center;
810
+ gap: 6px;
811
+ }
812
+ .tracker-sui-icon {
813
+ width: 0.8em;
814
+ height: 1em;
815
+ display: inline-block;
816
+ vertical-align: middle;
817
+ }
818
+ #sui-price { color: var(--ski-green-light); font-weight: 700; }
819
+ .tracker-sep { color: rgba(var(--ski-green-rgb), 0.4); }
820
+ .tracker-built-on { color: #8cae9a; }
821
+ .tracker-built-on a {
822
+ color: var(--ski-green-light);
823
+ text-decoration: none;
824
+ font-weight: 600;
825
+ }
826
+ .tracker-built-on a:hover { color: var(--ski-green-soft); }
827
+ @media (max-width: 760px) {
828
+ body { padding: 14px; padding-top: 54px; padding-bottom: 74px; }
829
+ .layout-grid { grid-template-columns: 1fr; }
830
+ .home-float-btn { top: 12px; left: 12px; width: 44px; height: 44px; border-radius: 10px; }
831
+ .home-float-sail { width: 24px; height: 8px; }
832
+ .home-float-dot { width: 10px; height: 10px; border-width: 1.5px; }
833
+ .wallet-widget { top: 12px; right: 12px; }
834
+ .tracker-footer { padding: 10px 8px; }
835
+ .tracker-line { font-size: 0.78rem; }
836
+ .nft-card { padding: 16px; gap: 8px; }
837
+ .nft-qr-col { width: 90px; }
838
+ .nft-logo-badge { width: 100px; }
839
+ .year-btn-h { width: 28px; height: 34px; font-size: 0.9rem; }
840
+ .year-display-h { font-size: 0.85rem; padding: 0 4px; }
841
+ }
842
+ @media (max-width: 1020px) {
843
+ .layout-grid { grid-template-columns: 1fr; }
844
+ }
845
+ ${generateWalletUiCss()}
846
+ </style>
847
+ </head>
848
+ <body data-register-flow="${registerFlow}">
849
+ <div id="wk-modal"></div>
850
+ <a class="home-float-btn" href="https://sui.ski" title="Go to sui.ski" aria-label="Go to sui.ski home">
851
+ <span class="home-float-sail" aria-hidden="true">
852
+ <svg viewBox="0 0 244 72" fill="none" xmlns="http://www.w3.org/2000/svg">
853
+ <path d="M6 66Q92 66 146 30Q181 8 238 6L226 24L220 66H6Z" fill="#ffffff"/>
854
+ </svg>
855
+ </span>
856
+ <span class="home-float-wordmark">
857
+ <span class="home-float-dot" aria-hidden="true"></span>
858
+ </span>
859
+ </a>
860
+ <div class="wallet-widget" id="wallet-widget">
861
+ <div id="wk-widget"></div>
862
+ <button class="wallet-profile-btn" id="wallet-profile-btn" title="Go to sui.ski" aria-label="Open wallet profile">
863
+ <span class="wallet-profile-mark" aria-hidden="true"></span>
864
+ <span class="wallet-profile-text">SKI</span>
865
+ </button>
866
+ </div>
867
+
868
+ <div class="container">
869
+ <div class="layout-grid">
870
+ ${registrationCardHtml}
871
+ ${discoveryColumnHtml}
872
+ </div>
873
+ </div>
874
+
875
+ <div class="tracker-footer">
876
+ <div class="tracker-brand">
877
+ <a class="nft-brand-link" href="https://sui.ski">${generateLogoSvg(14)}<span class="nft-brand-name">.ski</span></a>
878
+ <span class="nft-brand-tagline">Lift every 0xAddr to human-readability at scale</span>
879
+ </div>
880
+ <span class="tracker-line">
881
+ <span class="tracker-price-label"><img class="tracker-sui-icon" src="/media-pack/SuiIcon.svg" alt="SUI"><span id="sui-price">$--</span></span>
882
+ <span class="tracker-sep">\u00b7</span>
883
+ <span class="tracker-built-on">
884
+ Built on
885
+ <a href="https://docs.sui.io" target="_blank" rel="noopener">Sui</a>
886
+ <span class="tracker-sep">\u00b7</span>
887
+ <a href="https://docs.suins.io" target="_blank" rel="noopener">SuiNS</a>
888
+ <span class="tracker-sep">\u00b7</span>
889
+ <a href="https://moveregistry.com/docs" target="_blank" rel="noopener">MVR</a>
890
+ <span class="tracker-sep">\u00b7</span>
891
+ <a href="https://docs.sui.io/standards/deepbook" target="_blank" rel="noopener">DeepBook</a>
892
+ <span class="tracker-sep">\u00b7</span>
893
+ <a href="https://www.tradeport.xyz/docs" target="_blank" rel="noopener">Tradeport</a>
894
+ <span class="tracker-sep">\u00b7</span>
895
+ <a href="https://docs.wal.app" target="_blank" rel="noopener">Walrus</a>
896
+ <span class="tracker-sep">\u00b7</span>
897
+ <a href="https://seal-docs.wal.app" target="_blank" rel="noopener">Seal</a>
898
+ <span class="tracker-sep">\u00b7</span>
899
+ <a href="https://docs.waap.xyz/category/guides-sui" target="_blank" rel="noopener">WaaP</a>
900
+ </span>
901
+ </span>
902
+ </div>
903
+
904
+ <script type="module">
905
+ let SuiJsonRpcClient, Transaction, SuinsClient, SuinsTransaction
906
+ {
907
+ const pickExport = (mod, name) => {
908
+ if (!mod || typeof mod !== 'object') return undefined
909
+ if (name in mod) return mod[name]
910
+ if (mod.default && typeof mod.default === 'object' && name in mod.default) return mod.default[name]
911
+ return undefined
912
+ }
913
+ const SDK_TIMEOUT = 15000
914
+ const timedImport = (url) => Promise.race([
915
+ import(url),
916
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: ' + url)), SDK_TIMEOUT)),
917
+ ])
918
+ const results = await Promise.allSettled([
919
+ timedImport('https://esm.sh/@wallet-standard/app@1.1.0'),
920
+ timedImport('https://esm.sh/@mysten/sui@2.4.0/jsonRpc?bundle'),
921
+ timedImport('https://esm.sh/@mysten/sui@2.4.0/transactions?bundle'),
922
+ timedImport('https://esm.sh/@mysten/suins@1.0.0?bundle'),
923
+ ])
924
+ if (results[1].status === 'fulfilled') ({ SuiJsonRpcClient } = results[1].value)
925
+ if (results[2].status === 'fulfilled') ({ Transaction } = results[2].value)
926
+ if (results[3].status === 'fulfilled') {
927
+ const suinsModule = results[3].value
928
+ SuinsClient = pickExport(suinsModule, 'SuinsClient')
929
+ SuinsTransaction = pickExport(suinsModule, 'SuinsTransaction')
930
+ }
931
+ }
932
+ if (typeof SuiJsonRpcClient === 'function' && typeof window !== 'undefined') {
933
+ window.SuiJsonRpcClient = SuiJsonRpcClient
934
+ }
935
+
936
+ ${generateWalletSessionJs()}
937
+ ${generateWalletKitJs({ network: env.SUI_NETWORK, autoConnect: true })}
938
+ ${generateWalletTxJs()}
939
+ ${generateWalletUiJs({
940
+ showPrimaryName: true,
941
+ onConnect: 'onRegisterWalletConnected',
942
+ onDisconnect: 'onRegisterWalletDisconnected',
943
+ })}
944
+
945
+ const NAME = ${serializeJson(cleanName)}
946
+ const NETWORK = ${serializeJson(network)}
947
+ const REGISTER_FLOW = ${serializeJson(registerFlow)}
948
+ const REGISTER_BUCKET = ${serializeJson(registerBucket)}
949
+ const IS_REGISTERABLE = ${isRegisterable ? 'true' : 'false'}
950
+ const WAAP_REFERRAL_ADDRESS = '0x53f1e3d5f1e3f5aefa47fd3d5a47c9b8cc87e26a2c7bf39e26c870ded4eca7df'
951
+
952
+ function isValidSuiAddress(addr) {
953
+ return typeof addr === 'string' && /^0x[0-9a-fA-F]{64}$/.test(addr)
954
+ }
955
+
956
+ const urlParams = new URLSearchParams(window.location.search)
957
+ const referrerAddress = isValidSuiAddress(urlParams.get('ref')) ? urlParams.get('ref') : null
958
+ const waapReferralAddress = isValidSuiAddress(urlParams.get('rw')) ? urlParams.get('rw') : WAAP_REFERRAL_ADDRESS
959
+ let referralFeeMist = 0n
960
+ let waapFeeMist = 0n
961
+
962
+ const yearsEl = document.getElementById('years')
963
+ const yearsValueEl = document.getElementById('years-value')
964
+ const yearsDecreaseBtn = document.getElementById('years-decrease')
965
+ const yearsIncreaseBtn = document.getElementById('years-increase')
966
+ const primaryStarEl = document.getElementById('primary-star')
967
+ const registerBtn = document.getElementById('register-btn')
968
+ const registerStatus = document.getElementById('register-status')
969
+ const priceValue = document.getElementById('price-value')
970
+ const suiPriceEl = document.getElementById('sui-price')
971
+ const walletWidget = document.getElementById('wallet-widget')
972
+ const walletProfileBtn = document.getElementById('wallet-profile-btn')
973
+ const downloadQrBtn = document.getElementById('download-qr-btn')
974
+ const x402PriceEl = document.getElementById('x402-price')
975
+ const x402LinkEl = document.getElementById('x402-link')
976
+ const suggestionsGrid = document.getElementById('suggestions-grid')
977
+ const refreshSuggestionsBtn = document.getElementById('refresh-suggestions')
978
+
979
+ let pricingData = null
980
+ let selectedPaymentMode = 'auto'
981
+ let paymentModeManualOverride = false
982
+ const suggestionStatusCache = new Map()
983
+ let wantsPrimaryName = false
984
+ let primaryStarManualOverride = null
985
+ let primaryStarAddress = ''
986
+ let primaryStarSyncNonce = 0
987
+ const MIN_YEARS = 1
988
+ const MAX_YEARS = 5
989
+ const RPC_URLS = {
990
+ mainnet: 'https://fullnode.mainnet.sui.io:443',
991
+ testnet: 'https://fullnode.testnet.sui.io:443',
992
+ devnet: 'https://fullnode.devnet.sui.io:443',
993
+ }
994
+
995
+ let cachedSuiClient = null
996
+ const SUI_TYPE_FULL = '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI'
997
+ const NS_TYPE_FULL = '0x5145494a5f5100e645e4b0aa950fa6b68f614e8c59e17bc5ded3495123a79178::ns::NS'
998
+ const USDC_TYPE_FULL = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
999
+ const USD_SYMBOL_PRIORITY = ['USDC', 'USDT', 'AUSD', 'USDY', 'FDUSD', 'DAI']
1000
+ const COIN_PAGE_LIMIT = 50
1001
+ const COIN_PAGE_MAX = 6
1002
+ function getSuiClient() {
1003
+ if (!cachedSuiClient && typeof SuiJsonRpcClient === 'function') {
1004
+ cachedSuiClient = new SuiJsonRpcClient({ url: RPC_URLS[NETWORK] || RPC_URLS.mainnet })
1005
+ }
1006
+ return cachedSuiClient
1007
+ }
1008
+
1009
+ function parseMistToBigInt(value) {
1010
+ if (typeof value === 'bigint') return value
1011
+ if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.floor(value))
1012
+ if (typeof value === 'string' && value.trim()) {
1013
+ try { return BigInt(value) } catch {}
1014
+ }
1015
+ return 0n
1016
+ }
1017
+
1018
+ function usdSymbolRank(name) {
1019
+ const symbol = String(name || '').toUpperCase()
1020
+ for (let i = 0; i < USD_SYMBOL_PRIORITY.length; i++) {
1021
+ if (symbol === USD_SYMBOL_PRIORITY[i]) return i
1022
+ }
1023
+ return 999
1024
+ }
1025
+
1026
+ function isUsdStableName(name) {
1027
+ const symbol = String(name || '').toUpperCase()
1028
+ if (!symbol) return false
1029
+ return symbol.includes('USD') || usdSymbolRank(symbol) !== 999
1030
+ }
1031
+
1032
+ function estimateTokenMistNeeded(requiredSuiMist, suiPerToken, decimals) {
1033
+ if (!Number.isFinite(suiPerToken) || suiPerToken <= 0) return 0n
1034
+ const suiNeeded = Number(requiredSuiMist) / 1e9
1035
+ if (!Number.isFinite(suiNeeded) || suiNeeded <= 0) return 0n
1036
+ const tokensNeeded = suiNeeded / suiPerToken
1037
+ if (!Number.isFinite(tokensNeeded) || tokensNeeded <= 0) return 0n
1038
+ const bufferedTokens = tokensNeeded * 1.12
1039
+ const scaled = Math.ceil(bufferedTokens * Math.pow(10, Number(decimals || 0)))
1040
+ if (!Number.isFinite(scaled) || scaled <= 0) return 0n
1041
+ return BigInt(scaled)
1042
+ }
1043
+
1044
+ async function collectCoinObjectIdsForAmountClient(owner, coinType, targetAmount) {
1045
+ const client = getSuiClient()
1046
+ if (!client) return { coinObjectIds: [], total: 0n }
1047
+ const coinObjectIds = []
1048
+ let total = 0n
1049
+ let cursor = null
1050
+ for (let i = 0; i < COIN_PAGE_MAX; i++) {
1051
+ const page = await client.getCoins({
1052
+ owner,
1053
+ coinType,
1054
+ cursor: cursor || undefined,
1055
+ limit: COIN_PAGE_LIMIT,
1056
+ })
1057
+ const rows = Array.isArray(page && page.data) ? page.data : []
1058
+ for (const coin of rows) {
1059
+ if (!coin || typeof coin.coinObjectId !== 'string') continue
1060
+ const coinBalance = parseMistToBigInt(coin.balance)
1061
+ if (coinBalance <= 0n) continue
1062
+ coinObjectIds.push(coin.coinObjectId)
1063
+ total += coinBalance
1064
+ if (total >= targetAmount) return { coinObjectIds, total }
1065
+ }
1066
+ if (!page || !page.hasNextPage || !page.nextCursor) break
1067
+ cursor = page.nextCursor
1068
+ }
1069
+ return { coinObjectIds, total }
1070
+ }
1071
+
1072
+ async function resolveRegisterUsdCoinPayment(address, years) {
1073
+ const client = getSuiClient()
1074
+ if (!client) throw new Error('Wallet RPC client unavailable')
1075
+ let requiredSuiMist = parseMistToBigInt(pricingData && (pricingData.discountedSuiMist || pricingData.directSuiMist || 0))
1076
+ if (requiredSuiMist <= 0n) {
1077
+ const pricingRes = await fetch('/api/pricing?domain=' + encodeURIComponent(NAME) + '&years=' + years)
1078
+ const nextPricing = await pricingRes.json().catch(() => null)
1079
+ if (pricingRes.ok && nextPricing) pricingData = nextPricing
1080
+ requiredSuiMist = parseMistToBigInt(nextPricing && (nextPricing.discountedSuiMist || nextPricing.directSuiMist || 0))
1081
+ }
1082
+ if (requiredSuiMist <= 0n) throw new Error('Unable to price registration for USD payment')
1083
+
1084
+ const pools = await fetch('/api/deepbook-pools').then((r) => r.json()).catch(() => [])
1085
+ if (!Array.isArray(pools) || pools.length === 0) {
1086
+ throw new Error('DeepBook pools unavailable')
1087
+ }
1088
+
1089
+ const usdcPool = pools.find((pool) => pool && pool.coinType === USDC_TYPE_FULL)
1090
+ let requiredUsdcMist = 0n
1091
+ if (usdcPool) {
1092
+ requiredUsdcMist = estimateTokenMistNeeded(
1093
+ requiredSuiMist,
1094
+ Number(usdcPool.suiPerToken || 0),
1095
+ Number(usdcPool.decimals || 0),
1096
+ )
1097
+ }
1098
+ if (requiredUsdcMist <= 0n) {
1099
+ const discountedUsd = Number(pricingData?.breakdown?.discountedPriceUsd || 0)
1100
+ if (Number.isFinite(discountedUsd) && discountedUsd > 0) {
1101
+ requiredUsdcMist = BigInt(Math.ceil(discountedUsd * 1_000_000 * 1.12))
1102
+ }
1103
+ }
1104
+ if (requiredUsdcMist <= 0n) throw new Error('Unable to estimate required USDC amount')
1105
+
1106
+ const usdcBalance = await client
1107
+ .getBalance({ owner: address, coinType: USDC_TYPE_FULL })
1108
+ .catch(() => ({ totalBalance: '0' }))
1109
+ const totalUsdcMist = parseMistToBigInt(usdcBalance && usdcBalance.totalBalance)
1110
+ if (totalUsdcMist < requiredUsdcMist) return null
1111
+
1112
+ const picked = await collectCoinObjectIdsForAmountClient(address, USDC_TYPE_FULL, requiredUsdcMist)
1113
+ if (!picked.coinObjectIds.length || picked.total < requiredUsdcMist) return null
1114
+
1115
+ return {
1116
+ sourceCoinType: USDC_TYPE_FULL,
1117
+ coinObjectIds: picked.coinObjectIds,
1118
+ }
1119
+ }
1120
+
1121
+ function formatPrimaryPriceParts(sui) {
1122
+ if (!Number.isFinite(sui) || sui <= 0) return { whole: '--', decimals: '' }
1123
+ const whole = Math.trunc(sui)
1124
+ const fraction = sui - whole
1125
+ if (fraction > 0.95) {
1126
+ return { whole: String(whole + 1), decimals: '' }
1127
+ }
1128
+ const dec = Math.floor(fraction * 100)
1129
+ if (dec < 5) {
1130
+ return { whole: String(whole), decimals: '' }
1131
+ }
1132
+ const decText = String(dec).padStart(2, '0')
1133
+ const normalized = decText.endsWith('0') ? decText.slice(0, 1) : decText
1134
+ return { whole: String(whole), decimals: '.' + normalized }
1135
+ }
1136
+
1137
+ function formatUsdAmount(usdValue) {
1138
+ if (!Number.isFinite(usdValue) || usdValue <= 0) return null
1139
+ return new Intl.NumberFormat('en-US', {
1140
+ minimumFractionDigits: 0,
1141
+ maximumFractionDigits: 0,
1142
+ }).format(Math.round(usdValue))
1143
+ }
1144
+
1145
+ function normalizePaymentMode(value) {
1146
+ if (value === 'coin' || value === 'sui' || value === 'auto') return value
1147
+ return 'auto'
1148
+ }
1149
+
1150
+ function updatePaymentChoiceUi() {
1151
+ if (!priceValue) return
1152
+ const activeMode = normalizePaymentMode(selectedPaymentMode)
1153
+ const choices = priceValue.querySelectorAll('[data-payment-mode]')
1154
+ for (const choice of choices) {
1155
+ const mode = choice.getAttribute('data-payment-mode')
1156
+ const isActive = activeMode !== 'auto' && mode === activeMode
1157
+ choice.classList.toggle('active', isActive)
1158
+ choice.setAttribute('aria-pressed', isActive ? 'true' : 'false')
1159
+ }
1160
+ }
1161
+
1162
+ function setSelectedPaymentMode(mode) {
1163
+ selectedPaymentMode = normalizePaymentMode(mode)
1164
+ updatePaymentChoiceUi()
1165
+ updateRegisterButton()
1166
+ }
1167
+
1168
+ function getWalletRouteLabel() {
1169
+ const walletName = getConnectedWalletName().trim()
1170
+ if (!walletName) return 'Connected wallet'
1171
+ return walletName === 'WaaP' ? 'WaaP wallet' : walletName + ' wallet'
1172
+ }
1173
+
1174
+ function updatePrimaryStarUi() {
1175
+ if (!primaryStarEl) return
1176
+ primaryStarEl.classList.toggle('active', wantsPrimaryName)
1177
+ primaryStarEl.textContent = wantsPrimaryName ? '★' : '☆'
1178
+ primaryStarEl.setAttribute('aria-pressed', wantsPrimaryName ? 'true' : 'false')
1179
+ }
1180
+
1181
+ function showStatus(message, type = 'info', html = false) {
1182
+ if (!registerStatus) return
1183
+ registerStatus.className = 'status show ' + type
1184
+ if (html) registerStatus.innerHTML = message
1185
+ else registerStatus.textContent = message
1186
+ }
1187
+
1188
+ function hideStatus() {
1189
+ if (!registerStatus) return
1190
+ registerStatus.className = 'status'
1191
+ registerStatus.textContent = ''
1192
+ }
1193
+
1194
+ function getSelectedYears() {
1195
+ const years = Number(yearsEl && yearsEl.value ? yearsEl.value : '1')
1196
+ if (!Number.isFinite(years)) return MIN_YEARS
1197
+ return Math.min(MAX_YEARS, Math.max(MIN_YEARS, Math.floor(years)))
1198
+ }
1199
+
1200
+ function yearProgress(years) {
1201
+ return Math.min(1, Math.max(0, (years - MIN_YEARS) / (MAX_YEARS - MIN_YEARS)))
1202
+ }
1203
+
1204
+ function lerpRgb(r0, g0, b0, r1, g1, b1, t) {
1205
+ return 'rgb(' + Math.round(r0 + (r1 - r0) * t) + ',' + Math.round(g0 + (g1 - g0) * t) + ',' + Math.round(b0 + (b1 - b0) * t) + ')'
1206
+ }
1207
+
1208
+ function yearColor(years) {
1209
+ return lerpRgb(26, 153, 96, 255, 255, 255, yearProgress(years))
1210
+ }
1211
+
1212
+ function updateYearTheme(years) {
1213
+ const t = yearProgress(years)
1214
+
1215
+ const nameEl = document.querySelector('.nft-name')
1216
+ if (nameEl) {
1217
+ nameEl.style.color = lerpRgb(240, 244, 248, 255, 255, 255, t)
1218
+ const ng = (0.15 + t * 0.25).toFixed(2)
1219
+ nameEl.style.textShadow = '0 1px 3px rgba(0,0,0,' + (0.4 - t * 0.2).toFixed(2) + '), 0 0 ' + (12 + t * 18) + 'px rgba(200,220,240,' + ng + ')'
1220
+ }
1221
+
1222
+ const tldEl = document.querySelector('.nft-name-tld')
1223
+ if (tldEl) {
1224
+ tldEl.style.color = lerpRgb(73, 218, 145, 240, 248, 255, t)
1225
+ tldEl.style.animation = 'none'
1226
+ const ga = (0.5 + t * 0.4).toFixed(2)
1227
+ const gb = (0.2 + t * 0.4).toFixed(2)
1228
+ const gc = (t * 0.25).toFixed(2)
1229
+ tldEl.style.textShadow = '0 0 ' + (8 + t * 12) + 'px rgba(73,218,145,' + ga + '), 0 0 ' + (20 + t * 30) + 'px rgba(73,218,145,' + gb + '), 0 0 ' + (50 + t * 30) + 'px rgba(73,218,145,' + gc + ')'
1230
+ }
1231
+
1232
+ const borderColor = lerpRgb(73, 218, 145, 200, 210, 225, t)
1233
+ const borderA = (0.18 + t * 0.3).toFixed(2)
1234
+ const cardEl = document.querySelector('.nft-card')
1235
+ if (cardEl) {
1236
+ cardEl.style.borderColor = borderColor.replace('rgb(', 'rgba(').replace(')', ',' + borderA + ')')
1237
+ }
1238
+
1239
+ const pr = Math.round(73 + 127 * t)
1240
+ const pg = Math.round(218 - 8 * t)
1241
+ const pb = Math.round(145 + 80 * t)
1242
+ document.documentElement.style.setProperty('--portal-r', String(pr))
1243
+ document.documentElement.style.setProperty('--portal-g', String(pg))
1244
+ document.documentElement.style.setProperty('--portal-b', String(pb))
1245
+ document.documentElement.style.setProperty('--portal-a', (0.35 + t * 0.5).toFixed(2))
1246
+
1247
+ const chipEl = document.querySelector('.nft-chip')
1248
+ if (chipEl) {
1249
+ chipEl.style.color = '#ffffff'
1250
+ const cbg = lerpRgb(13, 82, 48, 80, 90, 100, t)
1251
+ chipEl.style.background = cbg.replace('rgb(', 'rgba(').replace(')', ',' + (0.4 - t * 0.1).toFixed(2) + ')')
1252
+ const cbd = lerpRgb(20, 130, 72, 180, 195, 210, t)
1253
+ chipEl.style.borderColor = cbd.replace('rgb(', 'rgba(').replace(')', ',' + (0.5 + t * 0.2).toFixed(2) + ')')
1254
+ }
1255
+
1256
+ const dotEl = document.querySelector('.nft-dot')
1257
+ if (dotEl) {
1258
+ const dotColor = lerpRgb(22, 163, 74, 220, 235, 245, t)
1259
+ dotEl.style.background = dotColor
1260
+ dotEl.style.boxShadow = '0 0 ' + (8 + t * 6) + 'px ' + dotColor
1261
+ }
1262
+
1263
+ const stepperEl = document.querySelector('.year-stepper-h')
1264
+ if (stepperEl) {
1265
+ const sb = lerpRgb(73, 218, 145, 200, 210, 225, t)
1266
+ stepperEl.style.borderColor = sb.replace('rgb(', 'rgba(').replace(')', ',' + (0.28 + t * 0.3).toFixed(2) + ')')
1267
+ }
1268
+
1269
+ document.body.style.setProperty('--snow-boost', (t * 0.5).toFixed(3))
1270
+ }
1271
+
1272
+ function setSelectedYears(nextYears) {
1273
+ const normalized = Math.min(MAX_YEARS, Math.max(MIN_YEARS, Math.floor(Number(nextYears) || MIN_YEARS)))
1274
+ if (yearsEl) yearsEl.value = String(normalized)
1275
+ if (yearsValueEl) {
1276
+ yearsValueEl.textContent = String(normalized)
1277
+ yearsValueEl.style.color = yearColor(normalized)
1278
+ }
1279
+ if (yearsDecreaseBtn) yearsDecreaseBtn.disabled = normalized <= MIN_YEARS
1280
+ if (yearsIncreaseBtn) yearsIncreaseBtn.disabled = normalized >= MAX_YEARS
1281
+ updateYearTheme(normalized)
1282
+ }
1283
+
1284
+ function getConnectedAddress() {
1285
+ const conn = SuiWalletKit.$connection.value
1286
+ if (!conn) return null
1287
+ if (conn.status !== 'connected' && conn.status !== 'session') return null
1288
+ return conn.address || null
1289
+ }
1290
+
1291
+ function shouldForceSignBridge() {
1292
+ const conn = SuiWalletKit.$connection.value || {}
1293
+ return !(conn.status === 'session' && !conn.wallet)
1294
+ }
1295
+
1296
+ function getConnectedPrimaryName() {
1297
+ const conn = SuiWalletKit.$connection.value
1298
+ if (!conn) return null
1299
+ if (conn.status !== 'connected' && conn.status !== 'session') return null
1300
+ if (!conn.primaryName || typeof conn.primaryName !== 'string') return null
1301
+ const normalized = conn.primaryName.trim().replace(/\\.sui$/i, '')
1302
+ return normalized || null
1303
+ }
1304
+
1305
+ function getRpcUrlForNetwork() {
1306
+ return RPC_URLS[NETWORK] || RPC_URLS.mainnet
1307
+ }
1308
+
1309
+ async function fetchPrimaryNameForAddress(address) {
1310
+ if (!address || typeof SuiJsonRpcClient !== 'function') return { resolved: false, name: null }
1311
+ try {
1312
+ const client = new SuiJsonRpcClient({ url: getRpcUrlForNetwork() })
1313
+ const result = await client.resolveNameServiceNames({ address })
1314
+ const name = result?.data?.[0]
1315
+ if (!name || typeof name !== 'string') return { resolved: true, name: null }
1316
+ const normalized = name.replace(/\\.sui$/i, '')
1317
+ return { resolved: true, name: normalized || null }
1318
+ } catch {
1319
+ return { resolved: false, name: null }
1320
+ }
1321
+ }
1322
+
1323
+ async function syncPrimaryStarState() {
1324
+ const address = getConnectedAddress()
1325
+ if (!address) {
1326
+ primaryStarAddress = ''
1327
+ primaryStarManualOverride = null
1328
+ wantsPrimaryName = false
1329
+ updatePrimaryStarUi()
1330
+ return
1331
+ }
1332
+
1333
+ if (primaryStarAddress !== address) {
1334
+ primaryStarAddress = address
1335
+ primaryStarManualOverride = null
1336
+ }
1337
+
1338
+ if (typeof primaryStarManualOverride === 'boolean') {
1339
+ wantsPrimaryName = primaryStarManualOverride
1340
+ updatePrimaryStarUi()
1341
+ return
1342
+ }
1343
+
1344
+ const connectedPrimaryName = getConnectedPrimaryName()
1345
+ if (connectedPrimaryName) {
1346
+ wantsPrimaryName = false
1347
+ updatePrimaryStarUi()
1348
+ return
1349
+ }
1350
+
1351
+ const syncNonce = ++primaryStarSyncNonce
1352
+ const primaryResolution = await fetchPrimaryNameForAddress(address)
1353
+ if (syncNonce !== primaryStarSyncNonce) return
1354
+ if (getConnectedAddress() !== address) return
1355
+ if (!primaryResolution.resolved) return
1356
+ wantsPrimaryName = primaryResolution.name === null
1357
+ updatePrimaryStarUi()
1358
+ }
1359
+
1360
+ function parsePriceMist(value) {
1361
+ if (typeof value === 'bigint') return value
1362
+ if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.floor(value))
1363
+ if (typeof value === 'string' && value.trim()) {
1364
+ try {
1365
+ return BigInt(value)
1366
+ } catch {
1367
+ return null
1368
+ }
1369
+ }
1370
+ return null
1371
+ }
1372
+
1373
+ function getSuiCoinConfig(suinsClient) {
1374
+ const coins = suinsClient?.config?.coins || {}
1375
+ if (coins.SUI) return coins.SUI
1376
+ if (coins.sui) return coins.sui
1377
+ const values = Object.values(coins)
1378
+ for (const cfg of values) {
1379
+ const coinType = String(cfg?.type || cfg?.coinType || '')
1380
+ if (coinType.endsWith('::sui::SUI')) return cfg
1381
+ }
1382
+ return null
1383
+ }
1384
+
1385
+ function updateWalletProfileButton() {
1386
+ if (!walletProfileBtn) return
1387
+ const address = getConnectedAddress()
1388
+ const primaryName = getConnectedPrimaryName()
1389
+ walletProfileBtn.classList.toggle('visible', !!address)
1390
+ if (walletWidget) {
1391
+ walletWidget.classList.toggle('has-black-diamond', !!primaryName)
1392
+ }
1393
+ if (primaryName) {
1394
+ walletProfileBtn.dataset.href = 'https://' + encodeURIComponent(primaryName) + '.sui.ski'
1395
+ walletProfileBtn.title = 'Go to ' + primaryName + '.sui.ski'
1396
+ } else {
1397
+ walletProfileBtn.dataset.href = 'https://sui.ski'
1398
+ walletProfileBtn.title = 'Go to sui.ski'
1399
+ }
1400
+ }
1401
+
1402
+ const REG_SUI_ICON = '<svg class="reg-sui-icon" viewBox="0 0 300 384" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M240.057 159.914C255.698 179.553 265.052 204.39 265.052 231.407C265.052 258.424 255.414 284.019 239.362 303.768L237.971 305.475L237.608 303.31C237.292 301.477 236.929 299.613 236.502 297.749C228.46 262.421 202.265 232.134 159.148 207.597C130.029 191.071 113.361 171.195 108.985 148.586C106.157 133.972 108.258 119.294 112.318 106.717C116.379 94.1569 122.414 83.6187 127.549 77.2831L144.328 56.7754C147.267 53.1731 152.781 53.1731 155.719 56.7754L240.073 159.914H240.057ZM266.584 139.422L154.155 1.96703C152.007 -0.655678 147.993 -0.655678 145.845 1.96703L33.4316 139.422L33.0683 139.881C12.3868 165.555 0 198.181 0 233.698C0 316.408 67.1635 383.461 150 383.461C232.837 383.461 300 316.408 300 233.698C300 198.181 287.613 165.555 266.932 139.896L266.568 139.438L266.584 139.422ZM60.3381 159.472L70.3866 147.164L70.6868 149.439C70.9237 151.24 71.2239 153.041 71.5715 154.858C78.0809 189.001 101.322 217.456 140.173 239.496C173.952 258.724 193.622 280.828 199.278 305.064C201.648 315.176 202.059 325.129 201.032 333.835L200.969 334.372L200.479 334.609C185.233 342.05 168.09 346.237 149.984 346.237C86.4546 346.237 34.9484 294.826 34.9484 231.391C34.9484 204.153 44.4439 179.142 60.3065 159.44L60.3381 159.472Z" fill="#4DA2FF"/></svg>'
1403
+
1404
+ function updateRegisterButton() {
1405
+ if (!registerBtn) return
1406
+ const address = getConnectedAddress()
1407
+ updateWalletProfileButton()
1408
+ const stepperEl = document.querySelector('.year-stepper-h')
1409
+ if (!address) {
1410
+ registerBtn.style.display = 'none'
1411
+ if (downloadQrBtn) downloadQrBtn.style.display = 'none'
1412
+ if (stepperEl) stepperEl.style.display = 'none'
1413
+ return
1414
+ }
1415
+ registerBtn.style.display = ''
1416
+ if (downloadQrBtn) downloadQrBtn.style.display = ''
1417
+ if (stepperEl) stepperEl.style.display = ''
1418
+ if (selectedPaymentMode === 'coin') {
1419
+ const usd = Number(pricingData?.breakdown?.discountedPriceUsd || 0)
1420
+ const usdText = formatUsdAmount(usd)
1421
+ registerBtn.textContent = usdText ? 'Accept $' + usdText + ' USDC' : 'Accept via USDC'
1422
+ return
1423
+ }
1424
+ if (pricingData && pricingData.discountedSuiMist) {
1425
+ const sui = Number(pricingData.discountedSuiMist) / 1e9
1426
+ if (Number.isFinite(sui) && sui > 0) {
1427
+ registerBtn.innerHTML = 'Accept ' + Math.ceil(sui) + ' ' + REG_SUI_ICON
1428
+ return
1429
+ }
1430
+ }
1431
+ registerBtn.innerHTML = 'Accept ' + NAME + '.sui'
1432
+ }
1433
+
1434
+ async function fetchPricing() {
1435
+ if (!IS_REGISTERABLE) return
1436
+ const years = getSelectedYears()
1437
+ try {
1438
+ const response = await fetch('/api/pricing?domain=' + encodeURIComponent(NAME) + '&years=' + years)
1439
+ if (!response.ok) throw new Error('Pricing request failed')
1440
+ pricingData = await response.json()
1441
+ const mist = Number(pricingData?.discountedSuiMist || pricingData?.directSuiMist || 0)
1442
+ const sui = mist / 1e9
1443
+ const discountedUsdRaw = Number(pricingData?.breakdown?.discountedPriceUsd || 0)
1444
+ const discountedUsdText = formatUsdAmount(discountedUsdRaw)
1445
+ const savingsMist = Number(pricingData?.savingsMist || 0)
1446
+ const savingsSui = savingsMist / 1e9
1447
+ const directUsd = Number(pricingData?.breakdown?.basePriceUsd || 0)
1448
+ const savingsUsd = directUsd - discountedUsdRaw
1449
+ if (referrerAddress && savingsMist > 0) {
1450
+ referralFeeMist = BigInt(Math.floor(savingsMist * 10 / 100))
1451
+ waapFeeMist = BigInt(Math.floor(savingsMist * 5 / 100))
1452
+ } else {
1453
+ referralFeeMist = 0n
1454
+ waapFeeMist = 0n
1455
+ }
1456
+ if (priceValue) {
1457
+ const priceParts = formatPrimaryPriceParts(sui)
1458
+ const perYearUsd = years > 0 ? discountedUsdRaw / years : discountedUsdRaw
1459
+ const perYearUsdText = formatUsdAmount(perYearUsd)
1460
+ const usdLabel = perYearUsdText ? '= $' + perYearUsdText + '/yr' : '= $--'
1461
+ const suiIcon = '<svg class="price-sui-icon" viewBox="0 0 300 384" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M240.057 159.914C255.698 179.553 265.052 204.39 265.052 231.407C265.052 258.424 255.414 284.019 239.362 303.768L237.971 305.475L237.608 303.31C237.292 301.477 236.929 299.613 236.502 297.749C228.46 262.421 202.265 232.134 159.148 207.597C130.029 191.071 113.361 171.195 108.985 148.586C106.157 133.972 108.258 119.294 112.318 106.717C116.379 94.1569 122.414 83.6187 127.549 77.2831L144.328 56.7754C147.267 53.1731 152.781 53.1731 155.719 56.7754L240.073 159.914H240.057ZM266.584 139.422L154.155 1.96703C152.007 -0.655678 147.993 -0.655678 145.845 1.96703L33.4316 139.422L33.0683 139.881C12.3868 165.555 0 198.181 0 233.698C0 316.408 67.1635 383.461 150 383.461C232.837 383.461 300 316.408 300 233.698C300 198.181 287.613 165.555 266.932 139.896L266.568 139.438L266.584 139.422ZM60.3381 159.472L70.3866 147.164L70.6868 149.439C70.9237 151.24 71.2239 153.041 71.5715 154.858C78.0809 189.001 101.322 217.456 140.173 239.496C173.952 258.724 193.622 280.828 199.278 305.064C201.648 315.176 202.059 325.129 201.032 333.835L200.969 334.372L200.479 334.609C185.233 342.05 168.09 346.237 149.984 346.237C86.4546 346.237 34.9484 294.826 34.9484 231.391C34.9484 204.153 44.4439 179.142 60.3065 159.44L60.3381 159.472Z" fill="#4DA2FF"/></svg>'
1462
+ const discountEl = document.getElementById('price-savings')
1463
+ if (savingsSui > 0.5 && savingsUsd > 0.5 && discountEl) {
1464
+ const savingsUsdText = formatUsdAmount(savingsUsd)
1465
+ const discountSuiIcon = '<svg class="discount-sui-icon" viewBox="0 0 300 384" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M240.057 159.914C255.698 179.553 265.052 204.39 265.052 231.407C265.052 258.424 255.414 284.019 239.362 303.768L237.971 305.475L237.608 303.31C237.292 301.477 236.929 299.613 236.502 297.749C228.46 262.421 202.265 232.134 159.148 207.597C130.029 191.071 113.361 171.195 108.985 148.586C106.157 133.972 108.258 119.294 112.318 106.717C116.379 94.1569 122.414 83.6187 127.549 77.2831L144.328 56.7754C147.267 53.1731 152.781 53.1731 155.719 56.7754L240.073 159.914H240.057ZM266.584 139.422L154.155 1.96703C152.007 -0.655678 147.993 -0.655678 145.845 1.96703L33.4316 139.422L33.0683 139.881C12.3868 165.555 0 198.181 0 233.698C0 316.408 67.1635 383.461 150 383.461C232.837 383.461 300 316.408 300 233.698C300 198.181 287.613 165.555 266.932 139.896L266.568 139.438L266.584 139.422ZM60.3381 159.472L70.3866 147.164L70.6868 149.439C70.9237 151.24 71.2239 153.041 71.5715 154.858C78.0809 189.001 101.322 217.456 140.173 239.496C173.952 258.724 193.622 280.828 199.278 305.064C201.648 315.176 202.059 325.129 201.032 333.835L200.969 334.372L200.479 334.609C185.233 342.05 168.09 346.237 149.984 346.237C86.4546 346.237 34.9484 294.826 34.9484 231.391C34.9484 204.153 44.4439 179.142 60.3065 159.44L60.3381 159.472Z" fill="#4DA2FF"/></svg>'
1466
+ discountEl.innerHTML =
1467
+ '<span>-' + Math.round(savingsSui) + discountSuiIcon + '</span>' +
1468
+ '<span>($' + (savingsUsdText || '--') + ')</span>'
1469
+ }
1470
+ const usdColor = yearColor(years)
1471
+ const decimalsHtml = priceParts.decimals ? '<span class="price-decimals">' + priceParts.decimals + '</span>' : ''
1472
+ priceValue.innerHTML =
1473
+ '<div class="nft-price-main payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI"><span class="price-amount">' + priceParts.whole + '</span></div>' +
1474
+ '<div class="price-rest">' +
1475
+ '<span class="price-rest-top payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI">' + decimalsHtml + suiIcon + '</span>' +
1476
+ '<span class="price-usd payment-choice" data-payment-mode="coin" role="button" tabindex="0" aria-label="Pay with USD coin estimate" style="color:' + usdColor + '">' + usdLabel + '</span>' +
1477
+ '</div>'
1478
+ updatePaymentChoiceUi()
1479
+ }
1480
+ updateRegisterButton()
1481
+ checkAutoPaymentRoute()
1482
+ } catch (error) {
1483
+ if (priceValue) {
1484
+ priceValue.innerHTML =
1485
+ '<div class="nft-price-main payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI"><span class="price-amount">--</span></div>' +
1486
+ '<div class="price-rest">' +
1487
+ '<span class="price-rest-top payment-choice" data-payment-mode="sui" role="button" tabindex="0" aria-label="Pay with SUI"><svg class="price-sui-icon" viewBox="0 0 300 384" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M240.057 159.914C255.698 179.553 265.052 204.39 265.052 231.407C265.052 258.424 255.414 284.019 239.362 303.768L237.971 305.475L237.608 303.31C237.292 301.477 236.929 299.613 236.502 297.749C228.46 262.421 202.265 232.134 159.148 207.597C130.029 191.071 113.361 171.195 108.985 148.586C106.157 133.972 108.258 119.294 112.318 106.717C116.379 94.1569 122.414 83.6187 127.549 77.2831L144.328 56.7754C147.267 53.1731 152.781 53.1731 155.719 56.7754L240.073 159.914H240.057ZM266.584 139.422L154.155 1.96703C152.007 -0.655678 147.993 -0.655678 145.845 1.96703L33.4316 139.422L33.0683 139.881C12.3868 165.555 0 198.181 0 233.698C0 316.408 67.1635 383.461 150 383.461C232.837 383.461 300 316.408 300 233.698C300 198.181 287.613 165.555 266.932 139.896L266.568 139.438L266.584 139.422ZM60.3381 159.472L70.3866 147.164L70.6868 149.439C70.9237 151.24 71.2239 153.041 71.5715 154.858C78.0809 189.001 101.322 217.456 140.173 239.496C173.952 258.724 193.622 280.828 199.278 305.064C201.648 315.176 202.059 325.129 201.032 333.835L200.969 334.372L200.479 334.609C185.233 342.05 168.09 346.237 149.984 346.237C86.4546 346.237 34.9484 294.826 34.9484 231.391C34.9484 204.153 44.4439 179.142 60.3065 159.44L60.3381 159.472Z" fill="#4DA2FF"/></svg></span>' +
1488
+ '<span class="price-usd payment-choice" data-payment-mode="coin" role="button" tabindex="0" aria-label="Pay with USD coin estimate">\u2248 $--</span>' +
1489
+ '</div>'
1490
+ updatePaymentChoiceUi()
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ async function updateSuiPrice() {
1496
+ if (!suiPriceEl) return
1497
+ try {
1498
+ const res = await fetch('/api/sui-price')
1499
+ if (!res.ok) throw new Error('Price request failed')
1500
+ const data = await res.json()
1501
+ if (data && typeof data.price === 'number' && Number.isFinite(data.price)) {
1502
+ suiPriceEl.textContent = '$' + data.price.toFixed(2)
1503
+ return
1504
+ }
1505
+ suiPriceEl.textContent = '$--'
1506
+ } catch {
1507
+ suiPriceEl.textContent = '$--'
1508
+ }
1509
+ }
1510
+
1511
+ function getConnectedWalletName() {
1512
+ const conn = SuiWalletKit.$connection.value
1513
+ if (conn && conn.wallet && conn.wallet.name) return String(conn.wallet.name)
1514
+ const session = typeof getWalletSession === 'function' ? getWalletSession() : null
1515
+ return session && session.walletName ? String(session.walletName) : ''
1516
+ }
1517
+
1518
+ async function checkAutoPaymentRoute() {
1519
+ if (!IS_REGISTERABLE || paymentModeManualOverride) return
1520
+ const address = getConnectedAddress()
1521
+ if (!address) return
1522
+ const client = getSuiClient()
1523
+ if (!client) return
1524
+ const requiredSuiMist = parseMistToBigInt(
1525
+ pricingData && (pricingData.discountedSuiMist || pricingData.directSuiMist || 0)
1526
+ )
1527
+ if (requiredSuiMist <= 0n) return
1528
+ try {
1529
+ const suiBal = await client.getBalance({ owner: address, coinType: SUI_TYPE_FULL })
1530
+ const totalSuiMist = parseMistToBigInt(suiBal && suiBal.totalBalance)
1531
+ const overhead = (requiredSuiMist * 50n) / 100n + 80000000n
1532
+ if (totalSuiMist >= requiredSuiMist + overhead) return
1533
+ const usdcBal = await client.getBalance({ owner: address, coinType: USDC_TYPE_FULL })
1534
+ const totalUsdcMist = parseMistToBigInt(usdcBal && usdcBal.totalBalance)
1535
+ const discountedUsd = Number(pricingData?.breakdown?.discountedPriceUsd || 0)
1536
+ if (!Number.isFinite(discountedUsd) || discountedUsd <= 0) return
1537
+ const requiredUsdcMist = BigInt(Math.ceil(discountedUsd * 1_000_000 * 1.12))
1538
+ if (totalUsdcMist >= requiredUsdcMist) {
1539
+ setSelectedPaymentMode('coin')
1540
+ }
1541
+ } catch {}
1542
+ }
1543
+
1544
+ function getConnectedWalletIcon() {
1545
+ const conn = SuiWalletKit.$connection.value
1546
+ if (conn && conn.wallet && conn.wallet.icon) return String(conn.wallet.icon)
1547
+ return ''
1548
+ }
1549
+
1550
+ function getConnectedAccountLabel() {
1551
+ const conn = SuiWalletKit.$connection.value
1552
+ if (!conn || !conn.account) return ''
1553
+ return typeof conn.account.label === 'string' ? conn.account.label.trim() : ''
1554
+ }
1555
+
1556
+ function updateCardWalletInfo() {
1557
+ const el = document.getElementById('nft-wallet-icons')
1558
+ if (!el) return
1559
+ const address = getConnectedAddress()
1560
+ if (!address) {
1561
+ el.className = 'nft-wallet-icons'
1562
+ el.innerHTML = ''
1563
+ return
1564
+ }
1565
+ const walletName = getConnectedWalletName()
1566
+ const isWaaP = walletName.toLowerCase() === 'waap'
1567
+ let accountLabel = getConnectedAccountLabel()
1568
+ if (!accountLabel && isWaaP && typeof __wkGetWaaPLabelForAddress === 'function') {
1569
+ accountLabel = __wkGetWaaPLabelForAddress(address)
1570
+ }
1571
+ if (!accountLabel && isWaaP) {
1572
+ const pName = getConnectedPrimaryName()
1573
+ if (pName) accountLabel = pName
1574
+ }
1575
+ let html = ''
1576
+ if (isWaaP && typeof __wkWaaPIcon === 'string' && __wkWaaPIcon) {
1577
+ html += '<div class="nft-wallet-icon-row">' +
1578
+ '<img src="' + __wkWaaPIcon + '" alt="WaaP" onerror="this.style.display=\\'none\\'">' +
1579
+ '<span class="nft-wallet-icon-label">WaaP</span>' +
1580
+ '</div>'
1581
+ }
1582
+ const waapMethod = isWaaP && typeof __wkGetWaaPMethodForAddress === 'function'
1583
+ ? __wkGetWaaPMethodForAddress(address)
1584
+ : (isWaaP && typeof __wkPendingWaaPMethod === 'string' ? __wkPendingWaaPMethod : '')
1585
+ if (waapMethod && typeof __wkSocialIcons === 'object' && __wkSocialIcons[waapMethod]) {
1586
+ const methodSvg = __wkSocialIcons[waapMethod]
1587
+ .replace(/width="\\d+"/, 'width="20"')
1588
+ .replace(/height="\\d+"/, 'height="20"')
1589
+ .replace(/fill="[^"]*"/, 'fill="#e2e8f0"')
1590
+ let methodLabel = accountLabel || ''
1591
+ if (!methodLabel) {
1592
+ const fallbackLabels = { x: 'X', google: 'Google', discord: 'Discord', apple: 'Apple', email: 'Email', phone: 'Phone' }
1593
+ methodLabel = fallbackLabels[waapMethod] || ''
1594
+ }
1595
+ html += '<div class="nft-wallet-icon-row">' + methodSvg +
1596
+ (methodLabel ? '<span class="nft-wallet-icon-label">' + methodLabel + '</span>' : '') +
1597
+ '</div>'
1598
+ }
1599
+ if (!isWaaP && walletName) {
1600
+ html += '<div class="nft-wallet-icon-row"><span class="nft-wallet-icon-label">' + walletName + '</span></div>'
1601
+ }
1602
+ if (html) {
1603
+ el.className = 'nft-wallet-icons show'
1604
+ el.innerHTML = html
1605
+ } else {
1606
+ el.className = 'nft-wallet-icons'
1607
+ el.innerHTML = ''
1608
+ }
1609
+ }
1610
+
1611
+ function formatCompactSuiPrice(suiAmount) {
1612
+ if (!Number.isFinite(suiAmount) || suiAmount <= 0) return null
1613
+ const units = [
1614
+ { value: 1e9, suffix: 'B' },
1615
+ { value: 1e6, suffix: 'M' },
1616
+ { value: 1e3, suffix: 'K' },
1617
+ ]
1618
+ let scaled = suiAmount
1619
+ let suffix = ''
1620
+ for (const unit of units) {
1621
+ if (suiAmount >= unit.value) {
1622
+ scaled = suiAmount / unit.value
1623
+ suffix = unit.suffix
1624
+ break
1625
+ }
1626
+ }
1627
+ if (scaled >= 100) return String(Math.trunc(scaled)) + suffix + ' SUI'
1628
+ const truncatedTenths = Math.floor(scaled * 10) / 10
1629
+ return truncatedTenths.toFixed(1) + suffix + ' SUI'
1630
+ }
1631
+
1632
+ async function updateX402Listing() {
1633
+ if (!x402PriceEl) return
1634
+ x402PriceEl.textContent = 'Loading...'
1635
+ x402PriceEl.classList.remove('listed')
1636
+ try {
1637
+ const response = await fetch('/api/marketplace/x402')
1638
+ if (!response.ok) throw new Error('Listing request failed')
1639
+ const data = await response.json()
1640
+ const listingMist = Number(data?.bestListing?.price || 0)
1641
+ const tradeportUrl = typeof data?.bestListing?.tradeportUrl === 'string' && data.bestListing.tradeportUrl
1642
+ ? data.bestListing.tradeportUrl
1643
+ : 'https://www.tradeport.xyz/sui/collection/suins?search=x402'
1644
+ if (x402LinkEl) x402LinkEl.href = tradeportUrl
1645
+ if (Number.isFinite(listingMist) && listingMist > 0) {
1646
+ const listingSui = listingMist / 1e9
1647
+ const compactPrice = formatCompactSuiPrice(listingSui)
1648
+ x402PriceEl.textContent = compactPrice || 'No listing'
1649
+ x402PriceEl.classList.add('listed')
1650
+ return
1651
+ }
1652
+ x402PriceEl.textContent = 'No listing'
1653
+ } catch {
1654
+ x402PriceEl.textContent = 'Unavailable'
1655
+ }
1656
+ }
1657
+
1658
+ function trackEvent(eventName, payload) {
1659
+ try {
1660
+ window.dispatchEvent(new CustomEvent(eventName, { detail: payload }))
1661
+ if (Array.isArray(window.dataLayer)) {
1662
+ window.dataLayer.push({ event: eventName, ...payload })
1663
+ }
1664
+ if (typeof window.plausible === 'function') {
1665
+ window.plausible(eventName, { props: payload })
1666
+ }
1667
+ } catch {}
1668
+ }
1669
+
1670
+ function markRegisterFlowImpression() {
1671
+ trackEvent('sui_ski_register_impression', {
1672
+ registerFlow: REGISTER_FLOW,
1673
+ registerBucket: REGISTER_BUCKET,
1674
+ registerName: NAME,
1675
+ registerNetwork: NETWORK,
1676
+ })
1677
+ if (REGISTER_FLOW === 'register2') {
1678
+ const currentUrl = new URL(window.location.href)
1679
+ if (!currentUrl.searchParams.has('rf')) {
1680
+ currentUrl.searchParams.set('rf', '2')
1681
+ window.history.replaceState(window.history.state, '', currentUrl.toString())
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ function sanitizeCandidate(value) {
1687
+ const cleaned = String(value || '').toLowerCase().replace(/\\.sui$/i, '').replace(/[^a-z0-9-]/g, '')
1688
+ if (!cleaned || cleaned.length < 3 || cleaned === NAME) return null
1689
+ return cleaned
1690
+ }
1691
+
1692
+ function buildCandidates(baseName, suggestions) {
1693
+ const suffixes = ['ai', 'app', 'hub', 'pro', 'xyz', 'dao', 'labs', 'agent']
1694
+ const avoidX402 = !String(baseName || '').toLowerCase().includes('x402')
1695
+ const seen = new Set()
1696
+ const list = []
1697
+ const push = (candidate) => {
1698
+ const clean = sanitizeCandidate(candidate)
1699
+ if (!clean || seen.has(clean)) return
1700
+ if (avoidX402 && clean.includes('x402')) return
1701
+ seen.add(clean)
1702
+ list.push(clean)
1703
+ }
1704
+ for (const suggestion of suggestions || []) push(suggestion)
1705
+ for (const suffix of suffixes) push(baseName + suffix)
1706
+ return list.slice(0, 12)
1707
+ }
1708
+
1709
+ async function fetchSuggestions(baseName) {
1710
+ try {
1711
+ const response = await fetch('/api/suggest-names?q=' + encodeURIComponent(baseName) + '&mode=related')
1712
+ if (!response.ok) return [baseName]
1713
+ const body = await response.json()
1714
+ return Array.isArray(body?.suggestions) ? body.suggestions : [baseName]
1715
+ } catch {
1716
+ return [baseName]
1717
+ }
1718
+ }
1719
+
1720
+ async function checkCandidate(name) {
1721
+ if (suggestionStatusCache.has(name)) return suggestionStatusCache.get(name)
1722
+ try {
1723
+ const response = await fetch('/api/resolve?name=' + encodeURIComponent(name))
1724
+ if (!response.ok) throw new Error('resolve failed')
1725
+ const body = await response.json()
1726
+ const status = body?.found ? 'taken' : 'available'
1727
+ suggestionStatusCache.set(name, status)
1728
+ return status
1729
+ } catch {
1730
+ suggestionStatusCache.set(name, 'error')
1731
+ return 'error'
1732
+ }
1733
+ }
1734
+
1735
+ function suggestionAction(name, status) {
1736
+ const href = 'https://' + encodeURIComponent(name) + '.sui.ski'
1737
+ if (status === 'available') return '<a class="suggestion-link available" href="' + href + '">Register</a>'
1738
+ return '<a class="suggestion-link" href="' + href + '">View</a>'
1739
+ }
1740
+
1741
+ async function loadSuggestions(force = false) {
1742
+ if (!suggestionsGrid || !IS_REGISTERABLE) return
1743
+ if (force) suggestionStatusCache.clear()
1744
+ suggestionsGrid.innerHTML = '<div class="empty">Generating suggestions...</div>'
1745
+ const suggested = await fetchSuggestions(NAME)
1746
+ const candidates = buildCandidates(NAME, suggested)
1747
+ if (candidates.length === 0) {
1748
+ suggestionsGrid.innerHTML = '<div class="empty">No suggestions right now.</div>'
1749
+ return
1750
+ }
1751
+ const states = await Promise.all(candidates.map((candidate) => checkCandidate(candidate)))
1752
+ let html = ''
1753
+ for (let i = 0; i < candidates.length; i++) {
1754
+ const candidate = candidates[i]
1755
+ const state = states[i]
1756
+ const stateLabel = state === 'available' ? 'available' : state === 'taken' ? 'registered' : 'check failed'
1757
+ html += '<div class="suggestion">' +
1758
+ '<div class="suggestion-name">' + candidate + '.sui</div>' +
1759
+ '<div class="suggestion-row">' +
1760
+ '<span class="suggestion-state ' + state + '">' + stateLabel + '</span>' +
1761
+ suggestionAction(candidate, state) +
1762
+ '</div>' +
1763
+ '</div>'
1764
+ }
1765
+ suggestionsGrid.innerHTML = html
1766
+ }
1767
+
1768
+ async function registerName() {
1769
+ if (!IS_REGISTERABLE || !registerBtn) return
1770
+ const address = getConnectedAddress()
1771
+ if (!address) {
1772
+ SuiWalletKit.openModal()
1773
+ return
1774
+ }
1775
+
1776
+ registerBtn.disabled = true
1777
+ hideStatus()
1778
+ showStatus('Building transaction...', 'info')
1779
+
1780
+ try {
1781
+ const years = getSelectedYears()
1782
+ const buildPayload = {
1783
+ domain: NAME,
1784
+ years,
1785
+ sender: address,
1786
+ paymentMode: selectedPaymentMode,
1787
+ referrer: referrerAddress || undefined,
1788
+ waapReferral: waapReferralAddress || undefined,
1789
+ wantsPrimary: wantsPrimaryName,
1790
+ }
1791
+
1792
+ if (selectedPaymentMode === 'coin') {
1793
+ showStatus('Resolving USD route...', 'info')
1794
+ const coinPayment = await resolveRegisterUsdCoinPayment(address, years).catch(() => null)
1795
+ if (coinPayment && coinPayment.sourceCoinType && coinPayment.coinObjectIds.length) {
1796
+ buildPayload.paymentMethod = 'coin'
1797
+ buildPayload.sourceCoinType = coinPayment.sourceCoinType
1798
+ buildPayload.coinObjectIds = coinPayment.coinObjectIds
1799
+ } else {
1800
+ showStatus('Building USD route with live pool balances...', 'info')
1801
+ }
1802
+ }
1803
+
1804
+ const buildRes = await fetch('/api/register/build-tx', {
1805
+ method: 'POST',
1806
+ headers: { 'Content-Type': 'application/json' },
1807
+ body: JSON.stringify(buildPayload),
1808
+ })
1809
+ if (!buildRes.ok) {
1810
+ const errBody = await buildRes.json().catch(() => ({}))
1811
+ throw new Error(errBody.error || 'Failed to build transaction')
1812
+ }
1813
+ const buildBody = await buildRes.json()
1814
+ const txBytesBase64 = buildBody?.txBytes
1815
+ const method = typeof buildBody?.method === 'string' ? buildBody.method : ''
1816
+ if (!txBytesBase64 || typeof txBytesBase64 !== 'string') {
1817
+ throw new Error('Build transaction response missing tx bytes')
1818
+ }
1819
+ const raw = atob(txBytesBase64)
1820
+ const txBytes = new Uint8Array(raw.length)
1821
+ for (let i = 0; i < raw.length; i++) txBytes[i] = raw.charCodeAt(i)
1822
+
1823
+ if (method === 'coin-swap') {
1824
+ showStatus('USDC route ready. Approve in ' + getWalletRouteLabel() + '...', 'info')
1825
+ } else {
1826
+ showStatus('Approve in ' + getWalletRouteLabel() + '...', 'info')
1827
+ }
1828
+ const signingChain = NETWORK === 'testnet'
1829
+ ? 'sui:testnet'
1830
+ : NETWORK === 'devnet'
1831
+ ? 'sui:devnet'
1832
+ : 'sui:mainnet'
1833
+ const result = await SuiWalletKit.signAndExecute(txBytes, {
1834
+ account: { address, chains: [signingChain] },
1835
+ chain: signingChain,
1836
+ txOptions: { showEffects: true, showObjectChanges: true },
1837
+ preferTransactionBlock: true,
1838
+ singleAttempt: true,
1839
+ forceSignBridge: shouldForceSignBridge(),
1840
+ })
1841
+ const digest = result?.digest ? String(result.digest) : ''
1842
+
1843
+ const effectsStatus = result?.effects?.status
1844
+ if (effectsStatus?.status === 'failure') {
1845
+ throw new Error(effectsStatus.error || 'Transaction failed on-chain')
1846
+ }
1847
+
1848
+ let createdNft = false
1849
+ if (result?.effects?.created) {
1850
+ for (const c of result.effects.created) {
1851
+ const ref = c.reference || c
1852
+ if (ref?.objectId) { createdNft = true; break }
1853
+ }
1854
+ }
1855
+ if (!createdNft && digest) {
1856
+ showStatus('Verifying registration...', 'info')
1857
+ try {
1858
+ const txCheck = await client.getTransactionBlock({ digest, options: { showEffects: true, showObjectChanges: true } })
1859
+ if (txCheck?.effects?.status?.status === 'failure') {
1860
+ throw new Error(txCheck.effects.status.error || 'Transaction failed on-chain')
1861
+ }
1862
+ if (txCheck?.objectChanges) {
1863
+ for (const oc of txCheck.objectChanges) {
1864
+ if (oc.type === 'created') { createdNft = true; break }
1865
+ }
1866
+ }
1867
+ } catch (verifyErr) {
1868
+ if (verifyErr?.message?.includes('failed on-chain')) throw verifyErr
1869
+ }
1870
+ }
1871
+
1872
+ if (!createdNft) {
1873
+ throw new Error('Registration transaction did not create a SuiNS NFT. Check your balance and try again.')
1874
+ }
1875
+
1876
+ trackEvent('sui_ski_register_success', {
1877
+ registerFlow: REGISTER_FLOW,
1878
+ registerBucket: REGISTER_BUCKET,
1879
+ registerName: NAME,
1880
+ registerNetwork: NETWORK,
1881
+ txDigest: digest,
1882
+ method: method || 'unknown',
1883
+ referrer: referrerAddress || '',
1884
+ })
1885
+
1886
+ const profileUrl = 'https://' + NAME + '.sui.ski?nocache'
1887
+ const links = digest
1888
+ ? '<a href="https://suiscan.xyz/' + NETWORK + '/tx/' + encodeURIComponent(digest) + '" target="_blank" rel="noopener noreferrer">Suiscan</a> · ' +
1889
+ '<a href="https://suiexplorer.com/txblock/' + encodeURIComponent(digest) + '?network=' + NETWORK + '" target="_blank" rel="noopener noreferrer">Explorer</a>'
1890
+ : ''
1891
+ showStatus(
1892
+ '<strong>Registered!</strong> Redirecting to profile...' +
1893
+ (digest ? ' · ' + links : ''),
1894
+ 'ok',
1895
+ true,
1896
+ )
1897
+ if (wantsPrimaryName && typeof SuiWalletKit.setPrimaryName === 'function') {
1898
+ SuiWalletKit.setPrimaryName(NAME)
1899
+ }
1900
+ registerBtn.textContent = 'Registered'
1901
+ registerBtn.disabled = true
1902
+
1903
+ setTimeout(() => { window.location.href = profileUrl }, 2000)
1904
+ } catch (error) {
1905
+ const msg = error && error.message ? error.message : 'Registration failed'
1906
+ trackEvent('sui_ski_register_error', {
1907
+ registerFlow: REGISTER_FLOW,
1908
+ registerBucket: REGISTER_BUCKET,
1909
+ registerName: NAME,
1910
+ registerNetwork: NETWORK,
1911
+ error: String(msg),
1912
+ })
1913
+ const normalizedMsg = String(msg).toLowerCase()
1914
+ if (normalizedMsg.includes('no usable usdc balance was found for this wallet')) {
1915
+ showStatus(
1916
+ 'No usable USDC balance was found for this wallet. <a href="https://sui.ski" style="color:#b8ffda;text-decoration:underline;">Open .ski</a> and swap to SUI, then try again.',
1917
+ 'err',
1918
+ true,
1919
+ )
1920
+ } else {
1921
+ showStatus(msg, 'err')
1922
+ }
1923
+ registerBtn.disabled = false
1924
+ updateRegisterButton()
1925
+ }
1926
+ }
1927
+
1928
+ window.onRegisterWalletConnected = function() {
1929
+ updateRegisterButton()
1930
+ updateWalletProfileButton()
1931
+ syncPrimaryStarState()
1932
+ updateCardWalletInfo()
1933
+ renderNftQr()
1934
+ checkAutoPaymentRoute()
1935
+ }
1936
+
1937
+ window.onRegisterWalletDisconnected = function() {
1938
+ selectedPaymentMode = 'auto'
1939
+ paymentModeManualOverride = false
1940
+ updateRegisterButton()
1941
+ updateWalletProfileButton()
1942
+ syncPrimaryStarState()
1943
+ updateCardWalletInfo()
1944
+ renderNftQr()
1945
+ }
1946
+
1947
+ ${generateSharedWalletMountJs({
1948
+ network: env.SUI_NETWORK,
1949
+ session,
1950
+ onConnect: 'onRegisterWalletConnected',
1951
+ onDisconnect: 'onRegisterWalletDisconnected',
1952
+ profileButtonId: 'wallet-profile-btn',
1953
+ profileFallbackHref: 'https://sui.ski',
1954
+ })}
1955
+
1956
+ ;(function fitNameSize() {
1957
+ const nameEl = document.querySelector('.nft-name')
1958
+ if (!nameEl) return
1959
+ const fullName = NAME + '.sui'
1960
+ const len = fullName.length
1961
+ if (len > 12) {
1962
+ nameEl.style.fontSize = 'clamp(1.4rem, 6vw, 2rem)'
1963
+ } else if (len > 9) {
1964
+ nameEl.style.fontSize = 'clamp(1.7rem, 7.5vw, 2.4rem)'
1965
+ } else if (len > 7) {
1966
+ nameEl.style.fontSize = 'clamp(2rem, 8.5vw, 2.8rem)'
1967
+ }
1968
+ })()
1969
+
1970
+ markRegisterFlowImpression()
1971
+ updatePrimaryStarUi()
1972
+ syncPrimaryStarState()
1973
+ setSelectedYears(getSelectedYears())
1974
+ updatePaymentChoiceUi()
1975
+ updateRegisterButton()
1976
+ fetchPricing()
1977
+ updateSuiPrice()
1978
+ updateX402Listing()
1979
+ updateCardWalletInfo()
1980
+ setInterval(updateSuiPrice, 60000)
1981
+ setInterval(updateX402Listing, 120000)
1982
+ loadSuggestions()
1983
+
1984
+ let __qrCreatorMod = null
1985
+ async function renderNftQr() {
1986
+ const canvas = document.getElementById('nft-qr')
1987
+ if (!canvas || !canvas.getContext) return
1988
+ try {
1989
+ if (!__qrCreatorMod) __qrCreatorMod = await import('https://esm.sh/qr-creator@1.0.0')
1990
+ const QrCreator = __qrCreatorMod.default || __qrCreatorMod
1991
+ const qrUrl = 'https://' + NAME + '.sui.ski'
1992
+ const ctx = canvas.getContext('2d')
1993
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
1994
+ QrCreator.render({
1995
+ text: qrUrl,
1996
+ radius: 0.4,
1997
+ ecLevel: 'M',
1998
+ fill: '#49da91',
1999
+ background: 'transparent',
2000
+ size: 200,
2001
+ }, canvas)
2002
+ } catch {
2003
+ canvas.style.display = 'none'
2004
+ }
2005
+ }
2006
+ renderNftQr()
2007
+
2008
+ if (yearsDecreaseBtn) {
2009
+ yearsDecreaseBtn.addEventListener('click', () => {
2010
+ setSelectedYears(getSelectedYears() - 1)
2011
+ fetchPricing()
2012
+ })
2013
+ }
2014
+ if (yearsIncreaseBtn) {
2015
+ yearsIncreaseBtn.addEventListener('click', () => {
2016
+ setSelectedYears(getSelectedYears() + 1)
2017
+ fetchPricing()
2018
+ })
2019
+ }
2020
+ if (walletProfileBtn && !walletProfileBtn.dataset.registerBound) {
2021
+ walletProfileBtn.dataset.registerBound = '1'
2022
+ walletProfileBtn.addEventListener('click', (e) => {
2023
+ e.stopPropagation()
2024
+ window.location.href = walletProfileBtn.dataset.href || 'https://sui.ski'
2025
+ })
2026
+ }
2027
+ if (primaryStarEl) {
2028
+ primaryStarEl.addEventListener('click', () => {
2029
+ wantsPrimaryName = !wantsPrimaryName
2030
+ primaryStarManualOverride = wantsPrimaryName
2031
+ updatePrimaryStarUi()
2032
+ })
2033
+ }
2034
+ document.addEventListener('keydown', (e) => {
2035
+ if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return
2036
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
2037
+ e.preventDefault()
2038
+ setSelectedYears(getSelectedYears() + 1)
2039
+ fetchPricing()
2040
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
2041
+ e.preventDefault()
2042
+ setSelectedYears(getSelectedYears() - 1)
2043
+ fetchPricing()
2044
+ }
2045
+ })
2046
+ if (refreshSuggestionsBtn) refreshSuggestionsBtn.addEventListener('click', () => loadSuggestions(true))
2047
+ function getPaymentModeFromEventTarget(target) {
2048
+ if (!target) return ''
2049
+ const element = typeof target.closest === 'function' ? target : target.parentElement
2050
+ if (!element || typeof element.closest !== 'function') return ''
2051
+ const trigger = element.closest('[data-payment-mode]')
2052
+ if (!trigger || !priceValue || !priceValue.contains(trigger)) return ''
2053
+ const mode = trigger.getAttribute('data-payment-mode')
2054
+ return mode === 'coin' || mode === 'sui' ? mode : ''
2055
+ }
2056
+
2057
+ if (priceValue) {
2058
+ priceValue.addEventListener('click', function(event) {
2059
+ const mode = getPaymentModeFromEventTarget(event.target)
2060
+ if (mode) {
2061
+ paymentModeManualOverride = true
2062
+ setSelectedPaymentMode(mode)
2063
+ }
2064
+ })
2065
+ priceValue.addEventListener('keydown', function(event) {
2066
+ const key = event && event.key ? event.key : ''
2067
+ if (key !== 'Enter' && key !== ' ') return
2068
+ const mode = getPaymentModeFromEventTarget(event.target)
2069
+ if (!mode) return
2070
+ event.preventDefault()
2071
+ paymentModeManualOverride = true
2072
+ setSelectedPaymentMode(mode)
2073
+ })
2074
+ }
2075
+ if (registerBtn) registerBtn.addEventListener('click', registerName)
2076
+ let __html2canvasMod = null
2077
+ if (downloadQrBtn) {
2078
+ downloadQrBtn.addEventListener('click', async function() {
2079
+ const card = document.querySelector('body > div.container > div > div.nft-card')
2080
+ if (!card) return
2081
+ try {
2082
+ if (!__html2canvasMod) __html2canvasMod = await import('https://esm.sh/html2canvas@1.4.1')
2083
+ const html2canvas = __html2canvasMod.default || __html2canvasMod
2084
+ const canvas = await html2canvas(card, {
2085
+ backgroundColor: '#040d09',
2086
+ scale: 2,
2087
+ useCORS: true,
2088
+ logging: false,
2089
+ })
2090
+ const link = document.createElement('a')
2091
+ link.download = NAME + '-sui-ski.png'
2092
+ link.href = canvas.toDataURL('image/png')
2093
+ link.click()
2094
+ } catch {}
2095
+ })
2096
+ }
2097
+ </script>
2098
+ </body>
2099
+ </html>`
2100
+ }
2101
+
2102
+ function parseMistAmount(value: unknown): bigint {
2103
+ if (typeof value === 'bigint') return value
2104
+ if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.floor(value))
2105
+ if (typeof value === 'string' && value.trim()) {
2106
+ try {
2107
+ return BigInt(value)
2108
+ } catch {
2109
+ return 0n
2110
+ }
2111
+ }
2112
+ return 0n
2113
+ }
2114
+
2115
+ function createRpcClient(env: Env): SuiJsonRpcClient {
2116
+ return new SuiJsonRpcClient({
2117
+ url: env.SUI_RPC_URL || getDefaultRpcUrl(env.SUI_NETWORK),
2118
+ network: env.SUI_NETWORK,
2119
+ })
2120
+ }
2121
+
2122
+ async function getRpcClientWithFallback(env: Env, probeAddress: string): Promise<SuiJsonRpcClient> {
2123
+ const primaryClient = createRpcClient(env)
2124
+ try {
2125
+ await primaryClient.getBalance({ owner: probeAddress, coinType: SUI_COIN_TYPE })
2126
+ return primaryClient
2127
+ } catch (primaryError) {
2128
+ const fallbackUrl = getDefaultRpcUrl(env.SUI_NETWORK)
2129
+ const configuredUrl = (env.SUI_RPC_URL || '').trim()
2130
+ if (!configuredUrl || configuredUrl === fallbackUrl) throw primaryError
2131
+ const fallbackClient = new SuiJsonRpcClient({
2132
+ url: fallbackUrl,
2133
+ network: env.SUI_NETWORK,
2134
+ })
2135
+ await fallbackClient.getBalance({ owner: probeAddress, coinType: SUI_COIN_TYPE })
2136
+ return fallbackClient
2137
+ }
2138
+ }
2139
+
2140
+ async function collectCoinObjectIdsForAmount(
2141
+ client: SuiJsonRpcClient,
2142
+ owner: string,
2143
+ coinType: string,
2144
+ targetAmount: bigint,
2145
+ ): Promise<{ coinObjectIds: string[]; total: bigint }> {
2146
+ const coinObjectIds: string[] = []
2147
+ let total = 0n
2148
+ let cursor: string | null = null
2149
+ for (let i = 0; i < REGISTER_PAYMENT_COIN_PAGE_MAX; i++) {
2150
+ const page = await client.getCoins({
2151
+ owner,
2152
+ coinType,
2153
+ cursor: cursor || undefined,
2154
+ limit: REGISTER_PAYMENT_COIN_PAGE_LIMIT,
2155
+ })
2156
+ const rows = Array.isArray(page?.data) ? page.data : []
2157
+ for (const coin of rows) {
2158
+ if (!coin || typeof coin.coinObjectId !== 'string') continue
2159
+ const coinBalance = parseMistAmount(coin.balance)
2160
+ if (coinBalance <= 0n) continue
2161
+ coinObjectIds.push(coin.coinObjectId)
2162
+ total += coinBalance
2163
+ if (total >= targetAmount) return { coinObjectIds, total }
2164
+ }
2165
+ if (!page?.hasNextPage || !page?.nextCursor) break
2166
+ cursor = page.nextCursor
2167
+ }
2168
+ return { coinObjectIds, total }
2169
+ }
2170
+
2171
+ function stableSymbolRank(name: string): number {
2172
+ const symbol = String(name || '').toUpperCase()
2173
+ for (let i = 0; i < STABLE_SYMBOL_PRIORITY.length; i++) {
2174
+ if (symbol === STABLE_SYMBOL_PRIORITY[i]) return i
2175
+ }
2176
+ return 999
2177
+ }
2178
+
2179
+ function isStableSymbol(name: string): boolean {
2180
+ const symbol = String(name || '').toUpperCase()
2181
+ if (!symbol) return false
2182
+ return symbol.includes('USD') || stableSymbolRank(symbol) !== 999
2183
+ }
2184
+
2185
+ function estimateTokenMistNeeded(
2186
+ requiredSuiMist: bigint,
2187
+ suiPerToken: number,
2188
+ decimals: number,
2189
+ ): bigint {
2190
+ if (!Number.isFinite(suiPerToken) || suiPerToken <= 0) return 0n
2191
+ const suiNeeded = Number(requiredSuiMist) / 1e9
2192
+ if (!Number.isFinite(suiNeeded) || suiNeeded <= 0) return 0n
2193
+ const tokensNeeded = suiNeeded / suiPerToken
2194
+ if (!Number.isFinite(tokensNeeded) || tokensNeeded <= 0) return 0n
2195
+ const bufferedTokens = tokensNeeded * 1.12
2196
+ const scaled = Math.ceil(bufferedTokens * 10 ** decimals)
2197
+ if (!Number.isFinite(scaled) || scaled <= 0) return 0n
2198
+ return BigInt(scaled)
2199
+ }
2200
+
2201
+ async function resolveCoinPayment(
2202
+ env: Env,
2203
+ sender: string,
2204
+ domain: string,
2205
+ years: number,
2206
+ forceCoin = false,
2207
+ usdcOnly = false,
2208
+ ): Promise<{ sourceCoinType: string; coinObjectIds: string[] } | null> {
2209
+ const pricing = await calculateRegistrationPrice({ domain, years, env })
2210
+ const requiredSuiMist =
2211
+ pricing.discountedSuiMist > 0n ? pricing.discountedSuiMist : pricing.directSuiMist
2212
+ if (requiredSuiMist <= 0n) return null
2213
+
2214
+ const client = await getRpcClientWithFallback(env, sender)
2215
+ const [suiBalance, pools] = await Promise.all([
2216
+ client.getBalance({ owner: sender, coinType: SUI_COIN_TYPE }),
2217
+ getDeepBookSuiPools(env),
2218
+ ])
2219
+
2220
+ const totalSuiMist = parseMistAmount(suiBalance?.totalBalance)
2221
+ const swapOverheadMist = (requiredSuiMist * 50n) / 100n + REGISTER_GAS_BUFFER_MIST
2222
+ if (!forceCoin && totalSuiMist >= requiredSuiMist + swapOverheadMist) return null
2223
+
2224
+ let usdcRequiredMist = 0n
2225
+ for (const pool of pools) {
2226
+ if (!pool || pool.coinType !== USDC_COIN_TYPE) continue
2227
+ const estimate = estimateTokenMistNeeded(requiredSuiMist, pool.suiPerToken, pool.decimals)
2228
+ if (estimate <= 0n) continue
2229
+ if (usdcRequiredMist === 0n || estimate < usdcRequiredMist) {
2230
+ usdcRequiredMist = estimate
2231
+ }
2232
+ }
2233
+ if (usdcRequiredMist <= 0n) {
2234
+ const discountedUsd = Number(pricing.breakdown?.discountedPriceUsd || 0)
2235
+ if (Number.isFinite(discountedUsd) && discountedUsd > 0) {
2236
+ usdcRequiredMist = BigInt(Math.ceil(discountedUsd * 1_000_000 * 1.12))
2237
+ }
2238
+ }
2239
+
2240
+ if (usdcRequiredMist > 0n) {
2241
+ const usdcBalance = await client
2242
+ .getBalance({ owner: sender, coinType: USDC_COIN_TYPE })
2243
+ .catch(() => ({ totalBalance: '0' }))
2244
+ const totalUsdcMist = parseMistAmount(usdcBalance?.totalBalance)
2245
+ if (usdcOnly && totalUsdcMist > 0n) {
2246
+ try {
2247
+ const allUsdcCoins = await collectCoinObjectIdsForAmount(
2248
+ client,
2249
+ sender,
2250
+ USDC_COIN_TYPE,
2251
+ totalUsdcMist,
2252
+ )
2253
+ if (allUsdcCoins.coinObjectIds.length > 0) {
2254
+ return {
2255
+ sourceCoinType: USDC_COIN_TYPE,
2256
+ coinObjectIds: allUsdcCoins.coinObjectIds,
2257
+ }
2258
+ }
2259
+ } catch {
2260
+ // Continue to estimated-amount selection.
2261
+ }
2262
+ }
2263
+ try {
2264
+ const usdcCoins = await collectCoinObjectIdsForAmount(
2265
+ client,
2266
+ sender,
2267
+ USDC_COIN_TYPE,
2268
+ usdcRequiredMist,
2269
+ )
2270
+ if (usdcOnly && usdcCoins.coinObjectIds.length > 0) {
2271
+ return {
2272
+ sourceCoinType: USDC_COIN_TYPE,
2273
+ coinObjectIds: usdcCoins.coinObjectIds,
2274
+ }
2275
+ }
2276
+ if (
2277
+ totalUsdcMist >= usdcRequiredMist &&
2278
+ usdcCoins.total >= usdcRequiredMist &&
2279
+ usdcCoins.coinObjectIds.length > 0
2280
+ ) {
2281
+ return {
2282
+ sourceCoinType: USDC_COIN_TYPE,
2283
+ coinObjectIds: usdcCoins.coinObjectIds,
2284
+ }
2285
+ }
2286
+ } catch {
2287
+ // Fall through to generic pool selection.
2288
+ }
2289
+ }
2290
+ if (usdcOnly) {
2291
+ const usdcBalance = await client
2292
+ .getBalance({ owner: sender, coinType: USDC_COIN_TYPE })
2293
+ .catch(() => ({ totalBalance: '0' }))
2294
+ const totalUsdcMist = parseMistAmount(usdcBalance?.totalBalance)
2295
+ if (totalUsdcMist <= 0n) return null
2296
+ const allUsdcCoins = await collectCoinObjectIdsForAmount(
2297
+ client,
2298
+ sender,
2299
+ USDC_COIN_TYPE,
2300
+ totalUsdcMist,
2301
+ ).catch(() => ({ coinObjectIds: [] as string[], total: 0n }))
2302
+ if (allUsdcCoins.coinObjectIds.length === 0) return null
2303
+ return {
2304
+ sourceCoinType: USDC_COIN_TYPE,
2305
+ coinObjectIds: allUsdcCoins.coinObjectIds,
2306
+ }
2307
+ }
2308
+
2309
+ const poolByCoin = new Map<string, (typeof pools)[number]>()
2310
+ for (const pool of pools) {
2311
+ if (!pool || !pool.coinType) continue
2312
+ if (pool.coinType === SUI_COIN_TYPE || pool.coinType === NS_COIN_TYPE) continue
2313
+ const existing = poolByCoin.get(pool.coinType)
2314
+ if (!existing) {
2315
+ poolByCoin.set(pool.coinType, pool)
2316
+ continue
2317
+ }
2318
+ if (!existing.isDirect && pool.isDirect) {
2319
+ poolByCoin.set(pool.coinType, pool)
2320
+ continue
2321
+ }
2322
+ if (pool.suiPerToken > existing.suiPerToken) {
2323
+ poolByCoin.set(pool.coinType, pool)
2324
+ }
2325
+ }
2326
+
2327
+ const uniquePools = Array.from(poolByCoin.values())
2328
+ if (!uniquePools.length) return null
2329
+
2330
+ const balances = await Promise.all(
2331
+ uniquePools.map((pool) =>
2332
+ client
2333
+ .getBalance({ owner: sender, coinType: pool.coinType })
2334
+ .catch(() => ({ totalBalance: '0' })),
2335
+ ),
2336
+ )
2337
+
2338
+ const candidates: Array<{
2339
+ sourceCoinType: string
2340
+ coinObjectIds: string[]
2341
+ isUsdc: boolean
2342
+ isStable: boolean
2343
+ stableRank: number
2344
+ isDirect: boolean
2345
+ }> = []
2346
+
2347
+ for (let i = 0; i < uniquePools.length; i++) {
2348
+ const pool = uniquePools[i]
2349
+ const totalBalance = parseMistAmount(balances[i]?.totalBalance)
2350
+ if (totalBalance <= 0n) continue
2351
+
2352
+ const requiredTokenMist = estimateTokenMistNeeded(
2353
+ requiredSuiMist,
2354
+ pool.suiPerToken,
2355
+ pool.decimals,
2356
+ )
2357
+ if (requiredTokenMist <= 0n || totalBalance < requiredTokenMist) continue
2358
+
2359
+ const selected = await collectCoinObjectIdsForAmount(
2360
+ client,
2361
+ sender,
2362
+ pool.coinType,
2363
+ requiredTokenMist,
2364
+ ).catch(() => ({ coinObjectIds: [] as string[], total: 0n }))
2365
+ if (selected.total < requiredTokenMist || selected.coinObjectIds.length === 0) continue
2366
+
2367
+ const rank = stableSymbolRank(pool.name)
2368
+ candidates.push({
2369
+ sourceCoinType: pool.coinType,
2370
+ coinObjectIds: selected.coinObjectIds,
2371
+ isUsdc: pool.coinType === USDC_COIN_TYPE,
2372
+ isStable: isStableSymbol(pool.name),
2373
+ stableRank: rank,
2374
+ isDirect: !!pool.isDirect,
2375
+ })
2376
+ }
2377
+ if (!candidates.length) return null
2378
+
2379
+ candidates.sort((a, b) => {
2380
+ if (a.isUsdc && !b.isUsdc) return -1
2381
+ if (!a.isUsdc && b.isUsdc) return 1
2382
+ if (a.isStable && !b.isStable) return -1
2383
+ if (!a.isStable && b.isStable) return 1
2384
+ if (a.stableRank !== b.stableRank) return a.stableRank - b.stableRank
2385
+ if (a.isDirect && !b.isDirect) return -1
2386
+ if (!a.isDirect && b.isDirect) return 1
2387
+ return 0
2388
+ })
2389
+
2390
+ return {
2391
+ sourceCoinType: candidates[0].sourceCoinType,
2392
+ coinObjectIds: candidates[0].coinObjectIds,
2393
+ }
2394
+ }
2395
+
2396
+ export async function handleBuildRegisterTx(request: Request, env: Env): Promise<Response> {
2397
+ if (request.method === 'OPTIONS') {
2398
+ return new Response(null, { headers: CORS_HEADERS })
2399
+ }
2400
+ if (request.method !== 'POST') {
2401
+ return jsonResponse({ error: 'Method not allowed' }, 405, CORS_HEADERS)
2402
+ }
2403
+
2404
+ let body: {
2405
+ domain?: string
2406
+ years?: number
2407
+ sender?: string
2408
+ referrer?: string
2409
+ waapReferral?: string
2410
+ wantsPrimary?: boolean
2411
+ paymentMode?: 'auto' | 'coin' | 'sui'
2412
+ paymentMethod?: 'coin' | 'ns'
2413
+ sourceCoinType?: string
2414
+ coinObjectIds?: string[]
2415
+ }
2416
+ try {
2417
+ body = (await request.json()) as typeof body
2418
+ } catch {
2419
+ return jsonResponse({ error: 'Invalid JSON body' }, 400, CORS_HEADERS)
2420
+ }
2421
+
2422
+ const domain =
2423
+ typeof body.domain === 'string'
2424
+ ? body.domain
2425
+ .trim()
2426
+ .toLowerCase()
2427
+ .replace(/\.sui$/i, '')
2428
+ : ''
2429
+ const years =
2430
+ typeof body.years === 'number' && Number.isFinite(body.years)
2431
+ ? Math.max(1, Math.min(5, Math.floor(body.years)))
2432
+ : 1
2433
+ const sender = typeof body.sender === 'string' ? body.sender.trim() : ''
2434
+ const referrer =
2435
+ typeof body.referrer === 'string' && /^0x[0-9a-fA-F]{64}$/.test(body.referrer)
2436
+ ? body.referrer
2437
+ : null
2438
+ const waapReferral =
2439
+ typeof body.waapReferral === 'string' && /^0x[0-9a-fA-F]{64}$/.test(body.waapReferral)
2440
+ ? body.waapReferral
2441
+ : null
2442
+ const wantsPrimary = body.wantsPrimary === true
2443
+ const paymentMode =
2444
+ body.paymentMode === 'coin' || body.paymentMode === 'sui' ? body.paymentMode : 'auto'
2445
+ const paymentMethod = body.paymentMethod === 'coin' ? 'coin' : 'ns'
2446
+ const sourceCoinType = typeof body.sourceCoinType === 'string' ? body.sourceCoinType.trim() : ''
2447
+ const coinObjectIds = Array.isArray(body.coinObjectIds)
2448
+ ? body.coinObjectIds.filter(
2449
+ (id): id is string => typeof id === 'string' && /^0x[0-9a-fA-F]+$/.test(id),
2450
+ )
2451
+ : []
2452
+
2453
+ if (!domain || domain.length < 3) {
2454
+ return jsonResponse({ error: 'Invalid domain (minimum 3 characters)' }, 400, CORS_HEADERS)
2455
+ }
2456
+ if (!sender || !/^0x[0-9a-fA-F]{64}$/.test(sender)) {
2457
+ return jsonResponse({ error: 'Invalid sender address' }, 400, CORS_HEADERS)
2458
+ }
2459
+
2460
+ const fullDomain = `${domain}.sui`
2461
+
2462
+ try {
2463
+ let tx: Transaction | null = null
2464
+ let method: 'ns-swap' | 'sui-direct' | 'coin-swap' | null = null
2465
+ let breakdownInfo: Record<string, unknown> = {}
2466
+ let selectedPaymentMethod: 'coin' | 'ns' =
2467
+ paymentMode === 'coin' ? 'coin' : paymentMode === 'sui' ? 'ns' : paymentMethod
2468
+ let selectedSourceCoinType = sourceCoinType
2469
+ let selectedCoinObjectIds = coinObjectIds
2470
+
2471
+ let feePricing: { savingsMist: bigint } | null = null
2472
+ let extraSuiForFeesMist = 0n
2473
+ if (referrer || waapReferral) {
2474
+ try {
2475
+ feePricing = await calculateRegistrationPrice({ domain: fullDomain, years, env })
2476
+ if (feePricing?.savingsMist > 0n) {
2477
+ if (referrer) extraSuiForFeesMist += (feePricing.savingsMist * 10n) / 100n
2478
+ if (waapReferral) extraSuiForFeesMist += (feePricing.savingsMist * 5n) / 100n
2479
+ }
2480
+ } catch {
2481
+ // Ignore pricing errors; fees will be skipped if pricing unavailable
2482
+ }
2483
+ }
2484
+
2485
+ if (selectedPaymentMethod !== 'coin' && paymentMode === 'auto') {
2486
+ try {
2487
+ const coinPayment = await resolveCoinPayment(env, sender, fullDomain, years)
2488
+ if (coinPayment) {
2489
+ selectedPaymentMethod = 'coin'
2490
+ selectedSourceCoinType = coinPayment.sourceCoinType
2491
+ selectedCoinObjectIds = coinPayment.coinObjectIds
2492
+ }
2493
+ } catch {
2494
+ // Fall back to NS/SUI paths when automatic coin payment resolution is unavailable.
2495
+ }
2496
+ }
2497
+
2498
+ if (selectedPaymentMethod === 'coin') {
2499
+ if (
2500
+ paymentMode === 'coin' &&
2501
+ selectedSourceCoinType &&
2502
+ selectedSourceCoinType !== USDC_COIN_TYPE
2503
+ ) {
2504
+ return jsonResponse(
2505
+ {
2506
+ error:
2507
+ 'USD mode currently supports USDC only. Click the SUI price to build a SUI transaction.',
2508
+ },
2509
+ 400,
2510
+ CORS_HEADERS,
2511
+ )
2512
+ }
2513
+ if (paymentMode === 'coin') {
2514
+ try {
2515
+ const freshUsdcPayment = await resolveCoinPayment(
2516
+ env,
2517
+ sender,
2518
+ fullDomain,
2519
+ years,
2520
+ true,
2521
+ true,
2522
+ )
2523
+ if (freshUsdcPayment) {
2524
+ selectedSourceCoinType = freshUsdcPayment.sourceCoinType
2525
+ selectedCoinObjectIds = freshUsdcPayment.coinObjectIds
2526
+ }
2527
+ } catch {}
2528
+ }
2529
+ if (!selectedSourceCoinType || selectedCoinObjectIds.length === 0) {
2530
+ try {
2531
+ const coinPayment = await resolveCoinPayment(
2532
+ env,
2533
+ sender,
2534
+ fullDomain,
2535
+ years,
2536
+ true,
2537
+ paymentMode === 'coin',
2538
+ )
2539
+ if (coinPayment) {
2540
+ selectedSourceCoinType = coinPayment.sourceCoinType
2541
+ selectedCoinObjectIds = coinPayment.coinObjectIds
2542
+ }
2543
+ } catch {}
2544
+ }
2545
+ if (!selectedSourceCoinType || selectedCoinObjectIds.length === 0) {
2546
+ return jsonResponse(
2547
+ {
2548
+ error:
2549
+ paymentMode === 'coin'
2550
+ ? 'No usable USDC balance was found for this wallet. Click the SUI price to build a SUI transaction.'
2551
+ : 'paymentMethod "coin" requires sourceCoinType and coinObjectIds',
2552
+ },
2553
+ 400,
2554
+ CORS_HEADERS,
2555
+ )
2556
+ }
2557
+ try {
2558
+ const swapResult = await buildMultiCoinRegisterTx(
2559
+ {
2560
+ domain: fullDomain,
2561
+ years,
2562
+ senderAddress: sender,
2563
+ sourceCoinType: selectedSourceCoinType,
2564
+ coinObjectIds: selectedCoinObjectIds,
2565
+ slippageBps: 100,
2566
+ extraSuiForFeesMist: extraSuiForFeesMist > 0n ? extraSuiForFeesMist : undefined,
2567
+ },
2568
+ env,
2569
+ )
2570
+ tx = swapResult.tx
2571
+ method = 'coin-swap'
2572
+ breakdownInfo = {
2573
+ suiInputMist: String(swapResult.breakdown.suiInputMist),
2574
+ nsOutputEstimate: String(swapResult.breakdown.nsOutputEstimate),
2575
+ registrationCostNsMist: String(swapResult.breakdown.registrationCostNsMist),
2576
+ slippageBps: swapResult.breakdown.slippageBps,
2577
+ source: swapResult.breakdown.source,
2578
+ priceImpactBps: swapResult.breakdown.priceImpactBps,
2579
+ sourceCoinType: swapResult.breakdown.sourceCoinType,
2580
+ sourceTokensNeeded: swapResult.breakdown.sourceTokensNeeded,
2581
+ }
2582
+ } catch (coinBuildError) {
2583
+ const message =
2584
+ coinBuildError instanceof Error && coinBuildError.message
2585
+ ? coinBuildError.message
2586
+ : 'Failed to build coin-swap registration transaction'
2587
+ if (paymentMode === 'coin') {
2588
+ return jsonResponse({ error: `USD payment build failed: ${message}` }, 400, CORS_HEADERS)
2589
+ }
2590
+ selectedPaymentMethod = 'ns'
2591
+ }
2592
+ }
2593
+
2594
+ if (selectedPaymentMethod !== 'coin') {
2595
+ try {
2596
+ const swapResult = await buildSwapAndRegisterTx(
2597
+ { domain: fullDomain, years, senderAddress: sender },
2598
+ env,
2599
+ )
2600
+ tx = swapResult.tx
2601
+ method = 'ns-swap'
2602
+ breakdownInfo = {
2603
+ suiInputMist: String(swapResult.breakdown.suiInputMist),
2604
+ nsOutputEstimate: String(swapResult.breakdown.nsOutputEstimate),
2605
+ registrationCostNsMist: String(swapResult.breakdown.registrationCostNsMist),
2606
+ slippageBps: swapResult.breakdown.slippageBps,
2607
+ source: swapResult.breakdown.source,
2608
+ }
2609
+ } catch {
2610
+ const coinFallback = await resolveCoinPayment(env, sender, fullDomain, years, true).catch(
2611
+ () => null,
2612
+ )
2613
+ if (coinFallback) {
2614
+ try {
2615
+ const coinResult = await buildMultiCoinRegisterTx(
2616
+ {
2617
+ domain: fullDomain,
2618
+ years,
2619
+ senderAddress: sender,
2620
+ sourceCoinType: coinFallback.sourceCoinType,
2621
+ coinObjectIds: coinFallback.coinObjectIds,
2622
+ slippageBps: 100,
2623
+ extraSuiForFeesMist: extraSuiForFeesMist > 0n ? extraSuiForFeesMist : undefined,
2624
+ },
2625
+ env,
2626
+ )
2627
+ tx = coinResult.tx
2628
+ method = 'coin-swap'
2629
+ breakdownInfo = {
2630
+ suiInputMist: String(coinResult.breakdown.suiInputMist),
2631
+ nsOutputEstimate: String(coinResult.breakdown.nsOutputEstimate),
2632
+ registrationCostNsMist: String(coinResult.breakdown.registrationCostNsMist),
2633
+ slippageBps: coinResult.breakdown.slippageBps,
2634
+ source: coinResult.breakdown.source,
2635
+ priceImpactBps: coinResult.breakdown.priceImpactBps,
2636
+ sourceCoinType: coinResult.breakdown.sourceCoinType,
2637
+ sourceTokensNeeded: coinResult.breakdown.sourceTokensNeeded,
2638
+ }
2639
+ } catch {
2640
+ tx = await buildSuiRegisterTx({ domain: fullDomain, years, senderAddress: sender }, env)
2641
+ method = 'sui-direct'
2642
+ }
2643
+ } else {
2644
+ tx = await buildSuiRegisterTx({ domain: fullDomain, years, senderAddress: sender }, env)
2645
+ method = 'sui-direct'
2646
+ }
2647
+ }
2648
+ }
2649
+
2650
+ if (!tx || !method) {
2651
+ throw new Error('Unable to prepare registration transaction')
2652
+ }
2653
+ const buildClient = await getRpcClientWithFallback(env, sender)
2654
+
2655
+ if (wantsPrimary) {
2656
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
2657
+ const suinsClient = new SuinsClient({ client: buildClient as never, network })
2658
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
2659
+ suinsTx.setDefault(fullDomain)
2660
+ }
2661
+
2662
+ const GAS_BUDGET_MIST = 100_000_000n
2663
+ let totalSuiNeededMist = GAS_BUDGET_MIST
2664
+ if (feePricing && feePricing.savingsMist > 0n) {
2665
+ if (referrer) {
2666
+ const refFeeMist = (feePricing.savingsMist * 10n) / 100n
2667
+ if (refFeeMist > 0n) {
2668
+ totalSuiNeededMist += refFeeMist
2669
+ const [refCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(refFeeMist)])
2670
+ tx.transferObjects([refCoin], referrer)
2671
+ }
2672
+ }
2673
+ if (waapReferral) {
2674
+ const waapFeeMist = (feePricing.savingsMist * 5n) / 100n
2675
+ if (waapFeeMist > 0n) {
2676
+ totalSuiNeededMist += waapFeeMist
2677
+ const [waapCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(waapFeeMist)])
2678
+ tx.transferObjects([waapCoin], waapReferral)
2679
+ }
2680
+ }
2681
+ }
2682
+
2683
+ let txToBuild = tx
2684
+ if (method === 'coin-swap') {
2685
+ const suiBal = await buildClient.getBalance({ owner: sender, coinType: SUI_COIN_TYPE })
2686
+ const availSuiMist = parseMistAmount(suiBal?.totalBalance)
2687
+ const MIN_COIN_SWAP_GAS_MIST = 20_000_000n
2688
+ if (availSuiMist < GAS_BUDGET_MIST) {
2689
+ if (availSuiMist < MIN_COIN_SWAP_GAS_MIST) {
2690
+ throw new Error(
2691
+ 'Insufficient SUI for gas. Send at least 0.02 SUI to this wallet, or use a wallet with SUI.',
2692
+ )
2693
+ }
2694
+ tx.setGasBudget(Number(availSuiMist))
2695
+ }
2696
+ } else {
2697
+ txToBuild = await prependGasSwapIfNeeded(tx, buildClient, sender, totalSuiNeededMist, env)
2698
+ }
2699
+ const txBytes = await txToBuild.build({ client: buildClient })
2700
+
2701
+ const txBytesBase64 = uint8ArrayToBase64(txBytes)
2702
+
2703
+ return jsonResponse(
2704
+ {
2705
+ txBytes: txBytesBase64,
2706
+ method,
2707
+ breakdown: breakdownInfo,
2708
+ payment: {
2709
+ mode: paymentMode,
2710
+ method: selectedPaymentMethod,
2711
+ sourceCoinType:
2712
+ selectedSourceCoinType ||
2713
+ (typeof (breakdownInfo as { sourceCoinType?: unknown }).sourceCoinType === 'string'
2714
+ ? String((breakdownInfo as { sourceCoinType?: unknown }).sourceCoinType)
2715
+ : undefined),
2716
+ },
2717
+ },
2718
+ 200,
2719
+ CORS_HEADERS,
2720
+ )
2721
+ } catch (error) {
2722
+ const message =
2723
+ error instanceof Error && error.message
2724
+ ? error.message
2725
+ : 'Failed to build registration transaction'
2726
+ return jsonResponse({ error: message }, 400, CORS_HEADERS)
2727
+ }
2728
+ }
2729
+
2730
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
2731
+ let binary = ''
2732
+ for (let i = 0; i < bytes.length; i++) {
2733
+ binary += String.fromCharCode(bytes[i])
2734
+ }
2735
+ return btoa(binary)
2736
+ }
2737
+
2738
+ export async function handleRegistrationSubmission(request: Request, env: Env): Promise<Response> {
2739
+ if (request.method === 'OPTIONS') {
2740
+ return new Response(null, { headers: CORS_HEADERS })
2741
+ }
2742
+
2743
+ if (request.method !== 'POST') {
2744
+ return jsonResponse({ error: 'Method not allowed' }, 405, CORS_HEADERS)
2745
+ }
2746
+
2747
+ let payload: {
2748
+ txBytes?: string
2749
+ signatures?: unknown
2750
+ options?: Record<string, unknown>
2751
+ requestType?: string
2752
+ }
2753
+ try {
2754
+ payload = (await request.json()) as typeof payload
2755
+ } catch {
2756
+ return jsonResponse({ error: 'Invalid JSON body' }, 400, CORS_HEADERS)
2757
+ }
2758
+
2759
+ const txBytes = typeof payload.txBytes === 'string' ? payload.txBytes.trim() : ''
2760
+ const rawSignatures = Array.isArray(payload.signatures) ? payload.signatures : []
2761
+ const signatures = rawSignatures
2762
+ .filter((sig): sig is string => typeof sig === 'string' && sig.trim().length > 0)
2763
+ .map((sig) => sig.trim())
2764
+
2765
+ if (!txBytes) {
2766
+ return jsonResponse({ error: 'txBytes is required' }, 400, CORS_HEADERS)
2767
+ }
2768
+ if (signatures.length === 0) {
2769
+ return jsonResponse({ error: 'At least one signature is required' }, 400, CORS_HEADERS)
2770
+ }
2771
+
2772
+ const relay = await relaySignedTransaction(
2773
+ env,
2774
+ txBytes,
2775
+ signatures,
2776
+ (payload.options as Record<string, unknown>) || {},
2777
+ payload.requestType || 'WaitForLocalExecution',
2778
+ )
2779
+ const status = relay.ok ? 200 : relay.status || 502
2780
+ const body =
2781
+ typeof relay.response === 'undefined'
2782
+ ? relay.ok
2783
+ ? { ok: true }
2784
+ : { error: relay.error || 'Relay failed' }
2785
+ : relay.response
2786
+
2787
+ if (!relay.ok && relay.error && body && typeof body === 'object' && !('error' in body)) {
2788
+ Object.assign(body as Record<string, unknown>, { error: relay.error })
2789
+ }
2790
+
2791
+ return jsonResponse(body, status, CORS_HEADERS)
2792
+ }
2793
+
2794
+ function escapeHtml(value: string): string {
2795
+ return value.replace(/[&<>"']/g, (char) => {
2796
+ switch (char) {
2797
+ case '&':
2798
+ return '&amp;'
2799
+ case '<':
2800
+ return '&lt;'
2801
+ case '>':
2802
+ return '&gt;'
2803
+ case '"':
2804
+ return '&quot;'
2805
+ case "'":
2806
+ return '&#39;'
2807
+ default:
2808
+ return char
2809
+ }
2810
+ })
2811
+ }