sui.ski 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +311 -0
- package/CLAUDE.md +292 -0
- package/CODEBASE_GUIDE.md +217 -0
- package/README.md +77 -0
- package/biome.json +28 -0
- package/package.json +73 -0
- package/scripts/deploy-messaging-mainnet.sh +184 -0
- package/scripts/extract-suins-object.ts +180 -0
- package/scripts/full-deploy.sh +26 -0
- package/scripts/obsidian.ts +243 -0
- package/scripts/set-suins-contenthash.ts +130 -0
- package/scripts/setup-ika-dwallet.ts +338 -0
- package/scripts/transfer-upgrade-cap-from-nft.ts +86 -0
- package/src/durable-objects/wallet-session.ts +333 -0
- package/src/handlers/app.ts +1430 -0
- package/src/handlers/authenticated-events.ts +267 -0
- package/src/handlers/dashboard.ts +1659 -0
- package/src/handlers/landing.ts +6751 -0
- package/src/handlers/mcp.ts +556 -0
- package/src/handlers/messaging-sdk.ts +220 -0
- package/src/handlers/profile.css.ts +9332 -0
- package/src/handlers/profile.ts +12640 -0
- package/src/handlers/register2.ts +2811 -0
- package/src/handlers/ski-sign.ts +1901 -0
- package/src/handlers/ski.ts +314 -0
- package/src/handlers/thunder.ts +940 -0
- package/src/handlers/vault.ts +284 -0
- package/src/handlers/wallet-api.ts +169 -0
- package/src/handlers/x402-register.ts +601 -0
- package/src/index.test.ts +55 -0
- package/src/index.ts +512 -0
- package/src/resolvers/content.ts +231 -0
- package/src/resolvers/rpc.ts +222 -0
- package/src/resolvers/suins.ts +266 -0
- package/src/sdk/messaging.ts +279 -0
- package/src/types.ts +230 -0
- package/src/utils/agent-keypair.ts +40 -0
- package/src/utils/authenticated-events.ts +280 -0
- package/src/utils/cache.ts +82 -0
- package/src/utils/media-pack.ts +27 -0
- package/src/utils/mmr.ts +181 -0
- package/src/utils/ns-price.ts +529 -0
- package/src/utils/og-image.ts +141 -0
- package/src/utils/onchain-activity.ts +211 -0
- package/src/utils/onchain-listing.ts +39 -0
- package/src/utils/premium.ts +29 -0
- package/src/utils/pricing.ts +291 -0
- package/src/utils/pyth-price-info.ts +63 -0
- package/src/utils/response.ts +204 -0
- package/src/utils/rpc.ts +25 -0
- package/src/utils/shared-wallet-js.ts +166 -0
- package/src/utils/social.ts +152 -0
- package/src/utils/status.ts +39 -0
- package/src/utils/subdomain.ts +116 -0
- package/src/utils/surflux-grpc.ts +241 -0
- package/src/utils/swap-transactions.ts +1222 -0
- package/src/utils/thunder-css.ts +1341 -0
- package/src/utils/thunder-js.ts +5046 -0
- package/src/utils/transactions.ts +65 -0
- package/src/utils/vault.ts +18 -0
- package/src/utils/wallet-kit-js.ts +2312 -0
- package/src/utils/wallet-session-js.ts +192 -0
- package/src/utils/wallet-tx-js.ts +2287 -0
- package/src/utils/wallet-ui-js.ts +3057 -0
- package/src/utils/x402-middleware.ts +428 -0
- package/src/utils/x402-sui.ts +171 -0
- package/src/utils/zksend-js.ts +166 -0
- package/tsconfig.json +22 -0
- package/workers/x402-multichain/src/index.ts +237 -0
- package/workers/x402-multichain/src/types.ts +80 -0
- package/workers/x402-multichain/tsconfig.json +20 -0
- package/workers/x402-multichain/wrangler.toml +11 -0
- package/wrangler.toml +84 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { SuiJsonRpcClient as SuiClient } from '@mysten/sui/jsonRpc'
|
|
2
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
3
|
+
import { SuinsClient, SuinsTransaction } from '@mysten/suins'
|
|
4
|
+
import { Hono } from 'hono'
|
|
5
|
+
import type { Env, X402VerifiedPayment } from '../types'
|
|
6
|
+
import { getAgentAddress, getAgentKeypair } from '../utils/agent-keypair'
|
|
7
|
+
import {
|
|
8
|
+
calculateSuiNeededForNs,
|
|
9
|
+
DEEP_TYPE,
|
|
10
|
+
DEEPBOOK_NS_SUI_POOL,
|
|
11
|
+
DEEPBOOK_PACKAGE,
|
|
12
|
+
DEFAULT_SLIPPAGE_BPS,
|
|
13
|
+
getNSSuiPrice,
|
|
14
|
+
NS_SCALE,
|
|
15
|
+
NS_TYPE_MAINNET,
|
|
16
|
+
SUI_TYPE,
|
|
17
|
+
simulateBuyNsWithSui,
|
|
18
|
+
} from '../utils/ns-price'
|
|
19
|
+
import { calculateRegistrationPrice, formatPricingResponse } from '../utils/pricing'
|
|
20
|
+
import { jsonResponse } from '../utils/response'
|
|
21
|
+
import { getDefaultRpcUrl } from '../utils/rpc'
|
|
22
|
+
import { fetchMultichainPaymentRequirements, resolveX402Providers, x402PaymentMiddleware } from '../utils/x402-middleware'
|
|
23
|
+
import { resolveX402Recipient } from '../utils/x402-sui'
|
|
24
|
+
|
|
25
|
+
type X402RegisterEnv = {
|
|
26
|
+
Bindings: Env
|
|
27
|
+
Variables: {
|
|
28
|
+
env: Env
|
|
29
|
+
x402Payment?: X402VerifiedPayment
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CLOCK_OBJECT = '0x6'
|
|
34
|
+
const X402_FEE_PERCENT = 20
|
|
35
|
+
const ESTIMATED_GAS_MIST = 150_000_000
|
|
36
|
+
const DEFAULT_AGENT_FEE_MIST = '1000000000'
|
|
37
|
+
const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{64}$/
|
|
38
|
+
|
|
39
|
+
function generateRequestId(): string {
|
|
40
|
+
return `x402reg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getAgentFeeMist(env: Env): string {
|
|
44
|
+
const configured = env.X402_AGENT_FEE_MIST?.trim()
|
|
45
|
+
if (!configured || !/^\d+$/.test(configured)) return DEFAULT_AGENT_FEE_MIST
|
|
46
|
+
return configured
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveFeeRecipient(
|
|
50
|
+
client: SuiClient,
|
|
51
|
+
suinsClient: SuinsClient,
|
|
52
|
+
feeName: string | undefined,
|
|
53
|
+
fallback: string,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
if (!feeName) return fallback
|
|
56
|
+
const normalizedName = feeName.replace(/\.sui$/i, '') + '.sui'
|
|
57
|
+
try {
|
|
58
|
+
const record = await suinsClient.getNameRecord(normalizedName)
|
|
59
|
+
if (record?.targetAddress && ADDRESS_PATTERN.test(record.targetAddress)) {
|
|
60
|
+
return record.targetAddress
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// fall through
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const resolved = await client.resolveNameServiceAddress({ name: normalizedName })
|
|
67
|
+
if (resolved && ADDRESS_PATTERN.test(resolved)) {
|
|
68
|
+
return resolved
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// fall through
|
|
72
|
+
}
|
|
73
|
+
return fallback
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function storeRequest(
|
|
77
|
+
env: Env,
|
|
78
|
+
requestId: string,
|
|
79
|
+
payload: Record<string, unknown>,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await env.CACHE.put(`x402-register:${requestId}`, JSON.stringify(payload), {
|
|
82
|
+
expirationTtl: 86400,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const x402RegisterRoutes = new Hono<X402RegisterEnv>()
|
|
87
|
+
|
|
88
|
+
x402RegisterRoutes.use('*', async (c, next) => {
|
|
89
|
+
if (!c.get('env')) c.set('env', c.env)
|
|
90
|
+
await next()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
x402RegisterRoutes.get('/info', async (c) => {
|
|
94
|
+
const env = c.get('env')
|
|
95
|
+
const providers = resolveX402Providers(env)
|
|
96
|
+
const feeMist = getAgentFeeMist(env)
|
|
97
|
+
|
|
98
|
+
const [payTo, multichainAccepts, agentAddress] = await Promise.all([
|
|
99
|
+
resolveX402Recipient(env),
|
|
100
|
+
fetchMultichainPaymentRequirements(env, feeMist),
|
|
101
|
+
Promise.resolve<string | null>((() => {
|
|
102
|
+
try { return getAgentAddress(env) } catch { return null }
|
|
103
|
+
})()),
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
const suiAccept = {
|
|
107
|
+
scheme: 'exact-sui' as const,
|
|
108
|
+
network: `sui:${env.SUI_NETWORK}`,
|
|
109
|
+
asset: '0x2::sui::SUI',
|
|
110
|
+
amount: feeMist,
|
|
111
|
+
payTo: payTo || '',
|
|
112
|
+
maxTimeoutSeconds: 120,
|
|
113
|
+
extra: { verificationMethod: 'pre-executed' },
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const accepts = [suiAccept, ...multichainAccepts]
|
|
117
|
+
|
|
118
|
+
return jsonResponse({
|
|
119
|
+
service: 'x402 Auto-Registration Agent',
|
|
120
|
+
description:
|
|
121
|
+
'Pay with x402, agent registers .sui names with 25% NS discount and transfers NFT to you',
|
|
122
|
+
agentAddress,
|
|
123
|
+
pricing: {
|
|
124
|
+
amountMist: feeMist,
|
|
125
|
+
asset: '0x2::sui::SUI',
|
|
126
|
+
network: `sui:${env.SUI_NETWORK}`,
|
|
127
|
+
},
|
|
128
|
+
features: {
|
|
129
|
+
nsDiscount: '25%',
|
|
130
|
+
x402FeePercent: X402_FEE_PERCENT,
|
|
131
|
+
autoSwap: 'SUI → NS via DeepBook',
|
|
132
|
+
nftTransfer: 'To payer address',
|
|
133
|
+
},
|
|
134
|
+
paymentMethods: ['x402'],
|
|
135
|
+
x402: {
|
|
136
|
+
version: 2,
|
|
137
|
+
accepts,
|
|
138
|
+
verificationProviders: providers,
|
|
139
|
+
},
|
|
140
|
+
endpoints: {
|
|
141
|
+
info: 'GET /api/agents/x402-register/info',
|
|
142
|
+
quote: 'GET /api/agents/x402-register/quote?domain=example&years=1',
|
|
143
|
+
register: 'POST /api/agents/x402-register/register',
|
|
144
|
+
status: 'GET /api/agents/x402-register/status/:id',
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
x402RegisterRoutes.get('/quote', async (c) => {
|
|
150
|
+
const env = c.get('env')
|
|
151
|
+
const domain = c.req.query('domain')?.trim()
|
|
152
|
+
const yearsParam = c.req.query('years')
|
|
153
|
+
|
|
154
|
+
if (!domain) {
|
|
155
|
+
return jsonResponse({ error: 'domain query parameter is required' }, 400)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const years = yearsParam ? Number.parseInt(yearsParam, 10) : 1
|
|
159
|
+
if (!Number.isInteger(years) || years < 1 || years > 5) {
|
|
160
|
+
return jsonResponse({ error: 'years must be an integer between 1 and 5' }, 400)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const pricing = await calculateRegistrationPrice({ domain: cleanDomain, years, env })
|
|
167
|
+
const feeMist = getAgentFeeMist(env)
|
|
168
|
+
const x402FeeMist = (pricing.discountedSuiMist * BigInt(X402_FEE_PERCENT)) / 100n
|
|
169
|
+
const totalMist = pricing.discountedSuiMist + x402FeeMist + BigInt(ESTIMATED_GAS_MIST)
|
|
170
|
+
|
|
171
|
+
return jsonResponse({
|
|
172
|
+
domain: cleanDomain,
|
|
173
|
+
years,
|
|
174
|
+
pricing: formatPricingResponse(pricing),
|
|
175
|
+
agentFees: {
|
|
176
|
+
x402PaymentMist: feeMist,
|
|
177
|
+
x402FeeMist: String(x402FeeMist),
|
|
178
|
+
x402FeePercent: X402_FEE_PERCENT,
|
|
179
|
+
estimatedGasMist: String(ESTIMATED_GAS_MIST),
|
|
180
|
+
},
|
|
181
|
+
totalEstimatedMist: String(totalMist),
|
|
182
|
+
note: 'Agent wallet must hold sufficient SUI for gas + registration. x402 payment covers service fee.',
|
|
183
|
+
})
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message = error instanceof Error ? error.message : 'Failed to calculate pricing'
|
|
186
|
+
return jsonResponse({ error: message }, 400)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
x402RegisterRoutes.post(
|
|
191
|
+
'/register',
|
|
192
|
+
x402PaymentMiddleware({
|
|
193
|
+
description: 'Auto-register .sui name with 25% NS discount',
|
|
194
|
+
allowBypassWhenUnconfigured: false,
|
|
195
|
+
}),
|
|
196
|
+
async (c) => {
|
|
197
|
+
const env = c.get('env')
|
|
198
|
+
const x402Payment = c.get('x402Payment')
|
|
199
|
+
|
|
200
|
+
if (!x402Payment) {
|
|
201
|
+
return jsonResponse({ error: 'Payment verification missing' }, 402)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let payload: {
|
|
205
|
+
domain?: string
|
|
206
|
+
years?: number
|
|
207
|
+
targetAddress?: string
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
payload = await c.req.json()
|
|
211
|
+
} catch {
|
|
212
|
+
return jsonResponse({ error: 'Invalid JSON body' }, 400)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!payload.domain?.trim()) {
|
|
216
|
+
return jsonResponse({ error: 'domain is required' }, 400)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const years = payload.years ?? 1
|
|
220
|
+
if (!Number.isInteger(years) || years < 1 || years > 5) {
|
|
221
|
+
return jsonResponse({ error: 'years must be an integer between 1 and 5' }, 400)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const payerAddress = payload.targetAddress || x402Payment.payer
|
|
225
|
+
if (!ADDRESS_PATTERN.test(payerAddress)) {
|
|
226
|
+
return jsonResponse({ error: 'Could not determine valid payer address' }, 400)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const cleanDomain = `${payload.domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
|
|
230
|
+
const requestId = generateRequestId()
|
|
231
|
+
const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const keypair = getAgentKeypair(env)
|
|
235
|
+
const agentAddress = keypair.toSuiAddress()
|
|
236
|
+
|
|
237
|
+
const client = new SuiClient({
|
|
238
|
+
url: getDefaultRpcUrl(env.SUI_NETWORK),
|
|
239
|
+
network: env.SUI_NETWORK,
|
|
240
|
+
})
|
|
241
|
+
const suinsClient = new SuinsClient({ client: client as never, network })
|
|
242
|
+
|
|
243
|
+
const [pricing, nsPrice, x402Recipient, feeRecipient] = await Promise.all([
|
|
244
|
+
calculateRegistrationPrice({ domain: cleanDomain, years, env }),
|
|
245
|
+
getNSSuiPrice(env, true),
|
|
246
|
+
resolveX402Recipient(env),
|
|
247
|
+
resolveFeeRecipient(client, suinsClient, env.SERVICE_FEE_NAME, agentAddress),
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
const registrationCostNsMist = pricing.nsNeededMist
|
|
251
|
+
const nsPoolAddress = DEEPBOOK_NS_SUI_POOL[network]
|
|
252
|
+
const deepbookPackage = DEEPBOOK_PACKAGE[network]
|
|
253
|
+
|
|
254
|
+
if (!nsPoolAddress || !deepbookPackage) {
|
|
255
|
+
throw new Error(`DeepBook pools not available on ${network}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const slippageBps = DEFAULT_SLIPPAGE_BPS
|
|
259
|
+
let suiForNsSwap: bigint
|
|
260
|
+
let minNsOutput: bigint
|
|
261
|
+
let priceImpactBps = 0
|
|
262
|
+
|
|
263
|
+
if (nsPrice.asks?.length) {
|
|
264
|
+
const wideSlippage = Math.max(slippageBps, 1500)
|
|
265
|
+
const quote = calculateSuiNeededForNs(registrationCostNsMist, nsPrice.asks, wideSlippage)
|
|
266
|
+
suiForNsSwap = quote.suiNeeded
|
|
267
|
+
|
|
268
|
+
minNsOutput = registrationCostNsMist
|
|
269
|
+
|
|
270
|
+
const simResult = simulateBuyNsWithSui(suiForNsSwap, nsPrice.asks)
|
|
271
|
+
priceImpactBps = simResult.priceImpactBps
|
|
272
|
+
|
|
273
|
+
if (simResult.outputNs < minNsOutput) {
|
|
274
|
+
suiForNsSwap = suiForNsSwap + (suiForNsSwap * 30n) / 100n
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
const bufferBps = Math.max(slippageBps * 3, 3000)
|
|
278
|
+
const nsWithBuffer =
|
|
279
|
+
registrationCostNsMist + (registrationCostNsMist * BigInt(bufferBps)) / 10000n
|
|
280
|
+
const nsTokens = Number(nsWithBuffer) / NS_SCALE
|
|
281
|
+
suiForNsSwap = BigInt(Math.ceil(nsTokens * nsPrice.suiPerNs * 1e9))
|
|
282
|
+
|
|
283
|
+
minNsOutput = registrationCostNsMist
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const x402FeeMist = (pricing.discountedSuiMist * BigInt(X402_FEE_PERCENT)) / 100n
|
|
287
|
+
|
|
288
|
+
const tx = new Transaction()
|
|
289
|
+
tx.setSender(agentAddress)
|
|
290
|
+
|
|
291
|
+
const [suiCoinForNs] = tx.splitCoins(tx.gas, [tx.pure.u64(suiForNsSwap)])
|
|
292
|
+
|
|
293
|
+
const [zeroDeepCoin] = tx.moveCall({
|
|
294
|
+
target: '0x2::coin::zero',
|
|
295
|
+
typeArguments: [DEEP_TYPE],
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const [nsCoin, nsLeftoverSui, nsLeftoverDeep] = tx.moveCall({
|
|
299
|
+
target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
|
|
300
|
+
typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
|
|
301
|
+
arguments: [
|
|
302
|
+
tx.object(nsPoolAddress),
|
|
303
|
+
suiCoinForNs,
|
|
304
|
+
zeroDeepCoin,
|
|
305
|
+
tx.pure.u64(minNsOutput),
|
|
306
|
+
tx.object(CLOCK_OBJECT),
|
|
307
|
+
],
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
tx.transferObjects([nsLeftoverSui, nsLeftoverDeep], agentAddress)
|
|
311
|
+
|
|
312
|
+
const suinsTx = new SuinsTransaction(suinsClient, tx)
|
|
313
|
+
const coinConfig = suinsClient.config.coins.NS
|
|
314
|
+
if (!coinConfig) {
|
|
315
|
+
throw new Error('SuiNS NS coin configuration not found')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const priceInfoObjectId = coinConfig.feed
|
|
319
|
+
? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
|
|
320
|
+
: undefined
|
|
321
|
+
|
|
322
|
+
const nft = suinsTx.register({
|
|
323
|
+
domain: cleanDomain,
|
|
324
|
+
years,
|
|
325
|
+
coinConfig,
|
|
326
|
+
coin: nsCoin,
|
|
327
|
+
priceInfoObjectId,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
suinsTx.setTargetAddress({
|
|
331
|
+
nft,
|
|
332
|
+
address: payerAddress,
|
|
333
|
+
isSubname: cleanDomain.replace(/\.sui$/i, '').includes('.'),
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
tx.transferObjects([nft], payerAddress)
|
|
337
|
+
|
|
338
|
+
if (x402Recipient && x402FeeMist > 0n) {
|
|
339
|
+
const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(x402FeeMist)])
|
|
340
|
+
tx.transferObjects([feeCoin], x402Recipient)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
tx.transferObjects([nsCoin], feeRecipient)
|
|
344
|
+
tx.setGasBudget(ESTIMATED_GAS_MIST)
|
|
345
|
+
|
|
346
|
+
const txBytes = await tx.build({ client: client as never })
|
|
347
|
+
const { signature } = await keypair.signTransaction(txBytes)
|
|
348
|
+
const txBytesBase64 = btoa(String.fromCharCode(...txBytes))
|
|
349
|
+
|
|
350
|
+
const rpcResponse = await fetch(env.SUI_RPC_URL, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({
|
|
354
|
+
jsonrpc: '2.0',
|
|
355
|
+
id: Date.now(),
|
|
356
|
+
method: 'sui_executeTransactionBlock',
|
|
357
|
+
params: [
|
|
358
|
+
txBytesBase64,
|
|
359
|
+
[signature],
|
|
360
|
+
{ showEffects: true, showEvents: true, showObjectChanges: true },
|
|
361
|
+
'WaitForLocalExecution',
|
|
362
|
+
],
|
|
363
|
+
}),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const rpcJson = (await rpcResponse.json()) as {
|
|
367
|
+
result?: {
|
|
368
|
+
digest?: string
|
|
369
|
+
effects?: { status?: { status: string; error?: string } }
|
|
370
|
+
events?: Array<{ type: string; parsedJson?: Record<string, unknown> }>
|
|
371
|
+
objectChanges?: Array<{
|
|
372
|
+
type: string
|
|
373
|
+
objectType?: string
|
|
374
|
+
objectId?: string
|
|
375
|
+
owner?: unknown
|
|
376
|
+
}>
|
|
377
|
+
}
|
|
378
|
+
error?: { message?: string }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (rpcJson.error) {
|
|
382
|
+
throw new Error(`Transaction failed: ${rpcJson.error.message}`)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const txResult = rpcJson.result
|
|
386
|
+
if (!txResult?.effects?.status || txResult.effects.status.status !== 'success') {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Transaction execution failed: ${txResult?.effects?.status?.error || 'unknown error'}`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const digest = txResult.digest || 'unknown'
|
|
393
|
+
|
|
394
|
+
let nftId: string | null = null
|
|
395
|
+
if (txResult.objectChanges) {
|
|
396
|
+
for (const change of txResult.objectChanges) {
|
|
397
|
+
if (
|
|
398
|
+
change.type === 'created' &&
|
|
399
|
+
change.objectType?.includes('::suins_registration::SuinsRegistration')
|
|
400
|
+
) {
|
|
401
|
+
nftId = change.objectId || null
|
|
402
|
+
break
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let registrationEvent: Record<string, unknown> | null = null
|
|
408
|
+
if (txResult.events) {
|
|
409
|
+
for (const event of txResult.events) {
|
|
410
|
+
if (
|
|
411
|
+
event.type.includes('::register::NameRegistered') ||
|
|
412
|
+
event.type.includes('Register')
|
|
413
|
+
) {
|
|
414
|
+
registrationEvent = event.parsedJson || null
|
|
415
|
+
break
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const receipt = {
|
|
421
|
+
success: true,
|
|
422
|
+
requestId,
|
|
423
|
+
domain: cleanDomain,
|
|
424
|
+
years,
|
|
425
|
+
nftId,
|
|
426
|
+
digest,
|
|
427
|
+
payerAddress,
|
|
428
|
+
agentAddress,
|
|
429
|
+
pricing: {
|
|
430
|
+
registrationCostNsMist: String(registrationCostNsMist),
|
|
431
|
+
suiInputMist: String(suiForNsSwap),
|
|
432
|
+
x402FeeMist: String(x402FeeMist),
|
|
433
|
+
nsPerSui: nsPrice.nsPerSui,
|
|
434
|
+
priceImpactBps,
|
|
435
|
+
},
|
|
436
|
+
registrationEvent,
|
|
437
|
+
x402Payment,
|
|
438
|
+
timestamp: Date.now(),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
await storeRequest(env, requestId, receipt)
|
|
442
|
+
|
|
443
|
+
return jsonResponse(receipt)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
const message = error instanceof Error ? error.message : 'Registration failed'
|
|
446
|
+
const failureRecord = {
|
|
447
|
+
success: false,
|
|
448
|
+
requestId,
|
|
449
|
+
domain: cleanDomain,
|
|
450
|
+
error: message,
|
|
451
|
+
payerAddress,
|
|
452
|
+
x402Payment,
|
|
453
|
+
timestamp: Date.now(),
|
|
454
|
+
}
|
|
455
|
+
await storeRequest(env, requestId, failureRecord).catch(() => {})
|
|
456
|
+
return jsonResponse({ error: message, requestId }, 500)
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
x402RegisterRoutes.post('/sweep', async (c) => {
|
|
462
|
+
const env = c.get('env')
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
const keypair = getAgentKeypair(env)
|
|
466
|
+
const agentAddress = keypair.toSuiAddress()
|
|
467
|
+
const client = new SuiClient({
|
|
468
|
+
url: getDefaultRpcUrl(env.SUI_NETWORK),
|
|
469
|
+
network: env.SUI_NETWORK,
|
|
470
|
+
})
|
|
471
|
+
const x402Recipient = await resolveX402Recipient(env)
|
|
472
|
+
if (!x402Recipient) {
|
|
473
|
+
return jsonResponse({ error: 'Could not resolve x402.sui recipient address' }, 500)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const GAS_RESERVE_MIST = 200_000_000n
|
|
477
|
+
const SWEEP_GAS_BUDGET = 50_000_000
|
|
478
|
+
|
|
479
|
+
const STABLECOIN_TYPES = [
|
|
480
|
+
'0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC',
|
|
481
|
+
'0xc060006111016b8a020ad5b33834984a437aaa7d3c74c18e09a95d48aceab08c::coin::COIN',
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
const allCoins = await client.getAllCoins({ owner: agentAddress })
|
|
485
|
+
const coinsByType: Map<string, Array<{ objectId: string; balance: bigint }>> = new Map()
|
|
486
|
+
|
|
487
|
+
for (const coin of allCoins.data) {
|
|
488
|
+
const existing = coinsByType.get(coin.coinType) || []
|
|
489
|
+
existing.push({ objectId: coin.coinObjectId, balance: BigInt(coin.balance) })
|
|
490
|
+
coinsByType.set(coin.coinType, existing)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const suiCoins = coinsByType.get('0x2::sui::SUI') || []
|
|
494
|
+
let totalSuiBalance = 0n
|
|
495
|
+
for (const coin of suiCoins) totalSuiBalance += coin.balance
|
|
496
|
+
|
|
497
|
+
const sweepableSui = totalSuiBalance - GAS_RESERVE_MIST - BigInt(SWEEP_GAS_BUDGET)
|
|
498
|
+
const transfers: Array<{ type: string; amount: string }> = []
|
|
499
|
+
|
|
500
|
+
if (sweepableSui <= 0n && STABLECOIN_TYPES.every((t) => !coinsByType.has(t))) {
|
|
501
|
+
return jsonResponse({
|
|
502
|
+
message: 'Nothing to sweep',
|
|
503
|
+
agentAddress,
|
|
504
|
+
recipient: x402Recipient,
|
|
505
|
+
suiBalance: String(totalSuiBalance),
|
|
506
|
+
gasReserve: String(GAS_RESERVE_MIST),
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const tx = new Transaction()
|
|
511
|
+
tx.setSender(agentAddress)
|
|
512
|
+
|
|
513
|
+
if (sweepableSui > 0n) {
|
|
514
|
+
const [sweepCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(sweepableSui)])
|
|
515
|
+
tx.transferObjects([sweepCoin], x402Recipient)
|
|
516
|
+
transfers.push({ type: 'SUI', amount: String(sweepableSui) })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const coinType of STABLECOIN_TYPES) {
|
|
520
|
+
const coins = coinsByType.get(coinType)
|
|
521
|
+
if (!coins?.length) continue
|
|
522
|
+
|
|
523
|
+
let totalBalance = 0n
|
|
524
|
+
for (const c of coins) totalBalance += c.balance
|
|
525
|
+
if (totalBalance === 0n) continue
|
|
526
|
+
|
|
527
|
+
const coinRefs = coins.map((c) => tx.object(c.objectId))
|
|
528
|
+
if (coinRefs.length > 1) {
|
|
529
|
+
tx.mergeCoins(coinRefs[0], coinRefs.slice(1))
|
|
530
|
+
}
|
|
531
|
+
tx.transferObjects([coinRefs[0]], x402Recipient)
|
|
532
|
+
|
|
533
|
+
const label = coinType.includes('usdc') ? 'USDC' : coinType.split('::').pop() || coinType
|
|
534
|
+
transfers.push({ type: label, amount: String(totalBalance) })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
tx.setGasBudget(SWEEP_GAS_BUDGET)
|
|
538
|
+
|
|
539
|
+
const txBytes = await tx.build({ client: client as never })
|
|
540
|
+
const { signature } = await keypair.signTransaction(txBytes)
|
|
541
|
+
const txBytesBase64 = btoa(String.fromCharCode(...txBytes))
|
|
542
|
+
|
|
543
|
+
const rpcResponse = await fetch(env.SUI_RPC_URL, {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
546
|
+
body: JSON.stringify({
|
|
547
|
+
jsonrpc: '2.0',
|
|
548
|
+
id: Date.now(),
|
|
549
|
+
method: 'sui_executeTransactionBlock',
|
|
550
|
+
params: [
|
|
551
|
+
txBytesBase64,
|
|
552
|
+
[signature],
|
|
553
|
+
{ showEffects: true, showBalanceChanges: true },
|
|
554
|
+
'WaitForLocalExecution',
|
|
555
|
+
],
|
|
556
|
+
}),
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
const rpcJson = (await rpcResponse.json()) as {
|
|
560
|
+
result?: {
|
|
561
|
+
digest?: string
|
|
562
|
+
effects?: { status?: { status: string; error?: string } }
|
|
563
|
+
}
|
|
564
|
+
error?: { message?: string }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (rpcJson.error) {
|
|
568
|
+
throw new Error(`Sweep transaction failed: ${rpcJson.error.message}`)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (rpcJson.result?.effects?.status?.status !== 'success') {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`Sweep execution failed: ${rpcJson.result?.effects?.status?.error || 'unknown'}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return jsonResponse({
|
|
578
|
+
success: true,
|
|
579
|
+
digest: rpcJson.result.digest,
|
|
580
|
+
agentAddress,
|
|
581
|
+
recipient: x402Recipient,
|
|
582
|
+
transfers,
|
|
583
|
+
timestamp: Date.now(),
|
|
584
|
+
})
|
|
585
|
+
} catch (error) {
|
|
586
|
+
const message = error instanceof Error ? error.message : 'Sweep failed'
|
|
587
|
+
return jsonResponse({ error: message }, 500)
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
x402RegisterRoutes.get('/status/:id', async (c) => {
|
|
592
|
+
const env = c.get('env')
|
|
593
|
+
const requestId = c.req.param('id')
|
|
594
|
+
try {
|
|
595
|
+
const data = await env.CACHE.get(`x402-register:${requestId}`)
|
|
596
|
+
if (!data) return jsonResponse({ error: 'Request not found' }, 404)
|
|
597
|
+
return jsonResponse(JSON.parse(data))
|
|
598
|
+
} catch {
|
|
599
|
+
return jsonResponse({ error: 'Failed to fetch request status' }, 500)
|
|
600
|
+
}
|
|
601
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { parseSubdomain, toSuiNSName } from './utils/subdomain'
|
|
3
|
+
|
|
4
|
+
describe('parseSubdomain', () => {
|
|
5
|
+
it('parses root domain', () => {
|
|
6
|
+
const result = parseSubdomain('sui.ski')
|
|
7
|
+
expect(result.type).toBe('root')
|
|
8
|
+
expect(result.subdomain).toBe('')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('parses www as root', () => {
|
|
12
|
+
const result = parseSubdomain('www.sui.ski')
|
|
13
|
+
expect(result.type).toBe('root')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses SuiNS name subdomain', () => {
|
|
17
|
+
const result = parseSubdomain('myname.sui.ski')
|
|
18
|
+
expect(result.type).toBe('suins')
|
|
19
|
+
expect(result.subdomain).toBe('myname')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('parses RPC subdomain', () => {
|
|
23
|
+
const result = parseSubdomain('rpc.sui.ski')
|
|
24
|
+
expect(result.type).toBe('rpc')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('parses IPFS content subdomain', () => {
|
|
28
|
+
const result = parseSubdomain('ipfs-QmTest123.sui.ski')
|
|
29
|
+
expect(result.type).toBe('content')
|
|
30
|
+
// Hostname is normalized to lowercase (DNS is case-insensitive)
|
|
31
|
+
expect(result.subdomain).toBe('ipfs-qmtest123')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('parses Walrus content subdomain', () => {
|
|
35
|
+
const result = parseSubdomain('walrus-abc123.sui.ski')
|
|
36
|
+
expect(result.type).toBe('content')
|
|
37
|
+
expect(result.subdomain).toBe('walrus-abc123')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('parses staging environment', () => {
|
|
41
|
+
const result = parseSubdomain('myname.staging.sui.ski')
|
|
42
|
+
expect(result.type).toBe('suins')
|
|
43
|
+
expect(result.subdomain).toBe('myname')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('toSuiNSName', () => {
|
|
48
|
+
it('adds .sui suffix', () => {
|
|
49
|
+
expect(toSuiNSName('myname')).toBe('myname.sui')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves existing .sui suffix', () => {
|
|
53
|
+
expect(toSuiNSName('myname.sui')).toBe('myname.sui')
|
|
54
|
+
})
|
|
55
|
+
})
|