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,1222 @@
1
+ import { SuiJsonRpcClient as SuiClient } from '@mysten/sui/jsonRpc'
2
+ import { Transaction, TransactionDataBuilder } from '@mysten/sui/transactions'
3
+ import { SuinsClient, SuinsTransaction } from '@mysten/suins'
4
+ import type { Env } from '../types'
5
+ import {
6
+ calculateSuiNeededForNs,
7
+ DEEP_TYPE,
8
+ DEEPBOOK_NS_SUI_POOL,
9
+ DEEPBOOK_PACKAGE,
10
+ DEEPBOOK_SUI_USDC_POOL,
11
+ DEFAULT_SLIPPAGE_BPS,
12
+ getDeepBookSuiPools,
13
+ getNSSuiPrice,
14
+ NS_SCALE,
15
+ NS_TYPE_MAINNET,
16
+ SUI_TYPE,
17
+ simulateBuyNsWithSui,
18
+ USDC_TYPE,
19
+ } from './ns-price'
20
+ import { calculateRegistrationPrice, calculateRenewalPrice } from './pricing'
21
+ import { getDefaultRpcUrl } from './rpc'
22
+
23
+ const CLOCK_OBJECT = '0x6'
24
+ const DUST_SINK_ADDRESS = '0x0000000000000000000000000000000000000000000000000000000000000000'
25
+
26
+ async function resolveFeeRecipient(
27
+ client: SuiClient,
28
+ suinsClient: SuinsClient,
29
+ feeName: string | undefined,
30
+ fallback: string,
31
+ ): Promise<string> {
32
+ if (!feeName) return fallback
33
+ const normalizedName = feeName.replace(/\.sui$/i, '') + '.sui'
34
+ try {
35
+ const record = await suinsClient.getNameRecord(normalizedName)
36
+ if (record?.targetAddress && /^0x[a-fA-F0-9]{64}$/.test(record.targetAddress)) {
37
+ return record.targetAddress
38
+ }
39
+ } catch {
40
+ // try fallback resolver below
41
+ }
42
+ try {
43
+ const resolved = await client.resolveNameServiceAddress({ name: normalizedName })
44
+ if (resolved && /^0x[a-fA-F0-9]{64}$/.test(resolved)) {
45
+ return resolved
46
+ }
47
+ } catch {
48
+ // use fallback
49
+ }
50
+ try {
51
+ if (/^0x[a-fA-F0-9]{64}$/.test(fallback)) {
52
+ return fallback
53
+ }
54
+ const resolvedFallback = await client.resolveNameServiceAddress({
55
+ name: fallback.replace(/\.sui$/i, '') + '.sui',
56
+ })
57
+ if (resolvedFallback && /^0x[a-fA-F0-9]{64}$/.test(resolvedFallback)) {
58
+ return resolvedFallback
59
+ }
60
+ } catch {
61
+ // use raw fallback
62
+ }
63
+ try {
64
+ const recordFallback = await suinsClient.getNameRecord(fallback.replace(/\.sui$/i, '') + '.sui')
65
+ if (recordFallback?.targetAddress && /^0x[a-fA-F0-9]{64}$/.test(recordFallback.targetAddress)) {
66
+ return recordFallback.targetAddress
67
+ }
68
+ } catch {
69
+ // use raw fallback
70
+ }
71
+ if (/^0x[a-fA-F0-9]{64}$/.test(fallback)) {
72
+ return fallback
73
+ }
74
+ return fallback
75
+ }
76
+
77
+ interface SwapRegisterParams {
78
+ domain: string
79
+ years: number
80
+ senderAddress: string
81
+ slippageBps?: number
82
+ expirationMs?: number
83
+ }
84
+
85
+ interface SwapBreakdown {
86
+ suiInputMist: bigint
87
+ nsOutputEstimate: bigint
88
+ registrationCostNsMist: bigint
89
+ slippageBps: number
90
+ nsPerSui: number
91
+ source: 'deepbook' | 'fallback'
92
+ priceImpactBps: number
93
+ minNsOutput: bigint
94
+ feeRecipient?: string
95
+ }
96
+
97
+ interface SwapRegisterResult {
98
+ tx: Transaction
99
+ breakdown: SwapBreakdown
100
+ }
101
+
102
+ interface MultiCoinRegisterParams {
103
+ domain: string
104
+ years: number
105
+ senderAddress: string
106
+ sourceCoinType: string
107
+ coinObjectIds: string[]
108
+ slippageBps?: number
109
+ expirationMs?: number
110
+ extraSuiForFeesMist?: bigint
111
+ }
112
+
113
+ interface MultiCoinRegisterResult {
114
+ tx: Transaction
115
+ breakdown: SwapBreakdown & {
116
+ sourceCoinType: string
117
+ sourceTokensNeeded: string
118
+ }
119
+ }
120
+
121
+ export async function buildSwapAndRegisterTx(
122
+ params: SwapRegisterParams,
123
+ env: Env,
124
+ ): Promise<SwapRegisterResult> {
125
+ const { domain, years, senderAddress, expirationMs } = params
126
+ const slippageBps = params.slippageBps ?? DEFAULT_SLIPPAGE_BPS
127
+
128
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
129
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
130
+
131
+ const [pricing, nsPrice] = await Promise.all([
132
+ calculateRegistrationPrice({ domain: cleanDomain, years, expirationMs, env }),
133
+ getNSSuiPrice(env, true),
134
+ ])
135
+
136
+ const registrationCostNsMist = pricing.nsNeededMist
137
+
138
+ const nsPoolAddress = DEEPBOOK_NS_SUI_POOL[network]
139
+ const deepbookPackage = DEEPBOOK_PACKAGE[network]
140
+
141
+ if (!nsPoolAddress || !deepbookPackage) {
142
+ throw new Error(`DeepBook pools not available on ${network}`)
143
+ }
144
+
145
+ let suiForNsSwap: bigint
146
+ let minNsOutput: bigint
147
+ let expectedNsOutput: bigint
148
+ let priceImpactBps = 0
149
+
150
+ if (nsPrice.asks?.length) {
151
+ const wideSlippage = Math.max(slippageBps, 1500)
152
+ const quote = calculateSuiNeededForNs(registrationCostNsMist, nsPrice.asks, wideSlippage)
153
+ suiForNsSwap = quote.suiNeeded
154
+ expectedNsOutput = quote.expectedNs
155
+ minNsOutput = registrationCostNsMist
156
+
157
+ const simResult = simulateBuyNsWithSui(suiForNsSwap, nsPrice.asks)
158
+ priceImpactBps = simResult.priceImpactBps
159
+
160
+ if (simResult.outputNs < minNsOutput) {
161
+ const extraBuffer = (suiForNsSwap * 30n) / 100n
162
+ suiForNsSwap = suiForNsSwap + extraBuffer
163
+ }
164
+ } else {
165
+ const bufferBps = Math.max(slippageBps * 3, 3000)
166
+ const nsWithBuffer =
167
+ registrationCostNsMist + (registrationCostNsMist * BigInt(bufferBps)) / 10000n
168
+ const nsTokens = Number(nsWithBuffer) / NS_SCALE
169
+ suiForNsSwap = BigInt(Math.ceil(nsTokens * nsPrice.suiPerNs * 1e9))
170
+ expectedNsOutput = nsWithBuffer
171
+ minNsOutput = registrationCostNsMist
172
+ }
173
+
174
+ const suiInputMist = suiForNsSwap
175
+
176
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
177
+ const suinsClient = new SuinsClient({ client: client as never, network })
178
+
179
+ const tx = new Transaction()
180
+ tx.setSender(senderAddress)
181
+
182
+ const [suiCoinForNs] = tx.splitCoins(tx.gas, [tx.pure.u64(suiForNsSwap)])
183
+
184
+ const [zeroDeepCoin] = tx.moveCall({
185
+ target: '0x2::coin::zero',
186
+ typeArguments: [DEEP_TYPE],
187
+ })
188
+
189
+ const [nsCoin, nsLeftoverSui, nsLeftoverDeep] = tx.moveCall({
190
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
191
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
192
+ arguments: [
193
+ tx.object(nsPoolAddress),
194
+ suiCoinForNs,
195
+ zeroDeepCoin,
196
+ tx.pure.u64(minNsOutput),
197
+ tx.object(CLOCK_OBJECT),
198
+ ],
199
+ })
200
+
201
+ tx.transferObjects([nsLeftoverSui, nsLeftoverDeep], senderAddress)
202
+
203
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
204
+ const coinConfig = suinsClient.config.coins.NS
205
+ if (!coinConfig) {
206
+ throw new Error('SuiNS NS coin configuration not found')
207
+ }
208
+
209
+ const priceInfoObjectId = coinConfig.feed
210
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
211
+ : undefined
212
+
213
+ const nft = suinsTx.register({
214
+ domain: cleanDomain,
215
+ years,
216
+ coinConfig,
217
+ coin: nsCoin,
218
+ priceInfoObjectId,
219
+ })
220
+
221
+ suinsTx.setTargetAddress({
222
+ nft,
223
+ address: senderAddress,
224
+ isSubname: cleanDomain.replace(/\.sui$/i, '').includes('.'),
225
+ })
226
+
227
+ tx.transferObjects([nft], senderAddress)
228
+
229
+ const feeRecipient = await resolveFeeRecipient(
230
+ client,
231
+ suinsClient,
232
+ env.SERVICE_FEE_NAME,
233
+ senderAddress,
234
+ )
235
+ tx.transferObjects([nsCoin], feeRecipient)
236
+
237
+ tx.setGasBudget(100_000_000)
238
+
239
+ return {
240
+ tx,
241
+ breakdown: {
242
+ suiInputMist,
243
+ nsOutputEstimate: expectedNsOutput,
244
+ registrationCostNsMist,
245
+ slippageBps,
246
+ nsPerSui: nsPrice.nsPerSui,
247
+ source: nsPrice.source,
248
+ priceImpactBps,
249
+ minNsOutput,
250
+ feeRecipient,
251
+ },
252
+ }
253
+ }
254
+
255
+ export async function buildMultiCoinRegisterTx(
256
+ params: MultiCoinRegisterParams,
257
+ env: Env,
258
+ ): Promise<MultiCoinRegisterResult> {
259
+ const { domain, years, senderAddress, sourceCoinType, coinObjectIds, expirationMs } = params
260
+ const slippageBps =
261
+ typeof params.slippageBps === 'number' && Number.isFinite(params.slippageBps)
262
+ ? Math.max(0, Math.floor(params.slippageBps))
263
+ : DEFAULT_SLIPPAGE_BPS
264
+
265
+ if (!coinObjectIds.length) {
266
+ throw new Error('At least one coin object ID is required')
267
+ }
268
+
269
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
270
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
271
+
272
+ const [pricing, nsPrice, pools] = await Promise.all([
273
+ calculateRegistrationPrice({ domain: cleanDomain, years, expirationMs, env }),
274
+ getNSSuiPrice(env, true),
275
+ getDeepBookSuiPools(env),
276
+ ])
277
+
278
+ const pool = pools.find((p) => p.coinType === sourceCoinType)
279
+ if (!pool) {
280
+ throw new Error(`No DeepBook pool found for coin type: ${sourceCoinType}`)
281
+ }
282
+
283
+ const registrationCostNsMist = pricing.nsNeededMist
284
+
285
+ const nsPoolAddress = DEEPBOOK_NS_SUI_POOL[network]
286
+ const deepbookPackage = DEEPBOOK_PACKAGE[network]
287
+
288
+ if (!nsPoolAddress || !deepbookPackage) {
289
+ throw new Error(`DeepBook pools not available on ${network}`)
290
+ }
291
+
292
+ let suiForNsSwap: bigint
293
+ let minNsOutput: bigint
294
+ let expectedNsOutput: bigint
295
+ let priceImpactBps = 0
296
+
297
+ if (nsPrice.asks?.length) {
298
+ const wideSlippage = slippageBps
299
+ const quote = calculateSuiNeededForNs(registrationCostNsMist, nsPrice.asks, wideSlippage)
300
+ suiForNsSwap = quote.suiNeeded
301
+ expectedNsOutput = quote.expectedNs
302
+ minNsOutput = registrationCostNsMist
303
+
304
+ const simResult = simulateBuyNsWithSui(suiForNsSwap, nsPrice.asks)
305
+ priceImpactBps = simResult.priceImpactBps
306
+
307
+ if (simResult.outputNs < minNsOutput) {
308
+ suiForNsSwap = suiForNsSwap + (suiForNsSwap * 30n) / 100n
309
+ }
310
+ } else {
311
+ const bufferBps = Math.max(slippageBps * 3, 3000)
312
+ const nsWithBuffer =
313
+ registrationCostNsMist + (registrationCostNsMist * BigInt(bufferBps)) / 10000n
314
+ const nsTokens = Number(nsWithBuffer) / NS_SCALE
315
+ suiForNsSwap = BigInt(Math.ceil(nsTokens * nsPrice.suiPerNs * 1e9))
316
+ expectedNsOutput = nsWithBuffer
317
+ minNsOutput = registrationCostNsMist
318
+ }
319
+
320
+ const extraSui = params.extraSuiForFeesMist ?? 0n
321
+ const totalSuiNeeded = suiForNsSwap + extraSui
322
+
323
+ const tokensNeededFloat = Number(totalSuiNeeded) / 1e9 / pool.suiPerToken
324
+ const tokenMistNeeded = BigInt(Math.ceil(tokensNeededFloat * 10 ** pool.decimals))
325
+ const tokenMistWithSlippage =
326
+ tokenMistNeeded + (tokenMistNeeded * BigInt(slippageBps)) / 10000n
327
+
328
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
329
+ const suinsClient = new SuinsClient({ client: client as never, network })
330
+
331
+ const tx = new Transaction()
332
+ tx.setSender(senderAddress)
333
+
334
+ const sourceCoin = tx.object(coinObjectIds[0])
335
+ if (coinObjectIds.length > 1) {
336
+ tx.mergeCoins(
337
+ sourceCoin,
338
+ coinObjectIds.slice(1).map((id) => tx.object(id)),
339
+ )
340
+ }
341
+
342
+ const [tokenToSell] = tx.splitCoins(sourceCoin, [tx.pure.u64(tokenMistWithSlippage)])
343
+
344
+ const [zeroDeepCoin] = tx.moveCall({
345
+ target: '0x2::coin::zero',
346
+ typeArguments: [DEEP_TYPE],
347
+ })
348
+
349
+ const minSuiFromSwap = suiForNsSwap - (suiForNsSwap * BigInt(slippageBps)) / 10000n
350
+
351
+ let swappedSuiCoin: ReturnType<Transaction['moveCall']>[0]
352
+ if (!pool.isDirect) {
353
+ const suiUsdcPoolAddress = DEEPBOOK_SUI_USDC_POOL[network]
354
+ if (!suiUsdcPoolAddress) {
355
+ throw new Error('SUI/USDC pool not available for indirect swap')
356
+ }
357
+ const [zeroDeep1] = tx.moveCall({
358
+ target: '0x2::coin::zero',
359
+ typeArguments: [DEEP_TYPE],
360
+ })
361
+ const [tokenLeft1, usdcOut, deepLeft1] = tx.moveCall({
362
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
363
+ typeArguments: [sourceCoinType, USDC_TYPE],
364
+ arguments: [
365
+ tx.object(pool.poolAddress),
366
+ tokenToSell,
367
+ zeroDeepCoin,
368
+ tx.pure.u64(0n),
369
+ tx.object(CLOCK_OBJECT),
370
+ ],
371
+ })
372
+ const [suiOut, usdcLeft, deepLeft2] = tx.moveCall({
373
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
374
+ typeArguments: [SUI_TYPE, USDC_TYPE],
375
+ arguments: [
376
+ tx.object(suiUsdcPoolAddress),
377
+ usdcOut,
378
+ zeroDeep1,
379
+ tx.pure.u64(minSuiFromSwap),
380
+ tx.object(CLOCK_OBJECT),
381
+ ],
382
+ })
383
+ swappedSuiCoin = suiOut
384
+ tx.transferObjects([tokenLeft1, usdcLeft, deepLeft1, deepLeft2, sourceCoin], senderAddress)
385
+ } else if (pool.suiIsBase) {
386
+ const [suiOut, tokenLeft, deepLeft2] = tx.moveCall({
387
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
388
+ typeArguments: [SUI_TYPE, sourceCoinType],
389
+ arguments: [
390
+ tx.object(pool.poolAddress),
391
+ tokenToSell,
392
+ zeroDeepCoin,
393
+ tx.pure.u64(minSuiFromSwap),
394
+ tx.object(CLOCK_OBJECT),
395
+ ],
396
+ })
397
+ swappedSuiCoin = suiOut
398
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], senderAddress)
399
+ } else {
400
+ const [tokenLeft, suiOut, deepLeft2] = tx.moveCall({
401
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
402
+ typeArguments: [sourceCoinType, SUI_TYPE],
403
+ arguments: [
404
+ tx.object(pool.poolAddress),
405
+ tokenToSell,
406
+ zeroDeepCoin,
407
+ tx.pure.u64(minSuiFromSwap),
408
+ tx.object(CLOCK_OBJECT),
409
+ ],
410
+ })
411
+ swappedSuiCoin = suiOut
412
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], senderAddress)
413
+ }
414
+
415
+ const [suiCoinForNs] = tx.splitCoins(swappedSuiCoin, [tx.pure.u64(suiForNsSwap)])
416
+ tx.mergeCoins(tx.gas, [swappedSuiCoin])
417
+
418
+ const [zeroDeepForNs] = tx.moveCall({
419
+ target: '0x2::coin::zero',
420
+ typeArguments: [DEEP_TYPE],
421
+ })
422
+
423
+ const [nsCoin, nsLeftoverSui, nsLeftoverDeep] = tx.moveCall({
424
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
425
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
426
+ arguments: [
427
+ tx.object(nsPoolAddress),
428
+ suiCoinForNs,
429
+ zeroDeepForNs,
430
+ tx.pure.u64(minNsOutput),
431
+ tx.object(CLOCK_OBJECT),
432
+ ],
433
+ })
434
+
435
+ tx.transferObjects([nsLeftoverSui, nsLeftoverDeep], senderAddress)
436
+
437
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
438
+ const coinConfig = suinsClient.config.coins.NS
439
+ if (!coinConfig) {
440
+ throw new Error('SuiNS NS coin configuration not found')
441
+ }
442
+
443
+ const priceInfoObjectId = coinConfig.feed
444
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
445
+ : undefined
446
+
447
+ const nft = suinsTx.register({
448
+ domain: cleanDomain,
449
+ years,
450
+ coinConfig,
451
+ coin: nsCoin,
452
+ priceInfoObjectId,
453
+ })
454
+
455
+ suinsTx.setTargetAddress({
456
+ nft,
457
+ address: senderAddress,
458
+ isSubname: cleanDomain.replace(/\.sui$/i, '').includes('.'),
459
+ })
460
+
461
+ tx.transferObjects([nft], senderAddress)
462
+
463
+ const feeRecipient = await resolveFeeRecipient(
464
+ client,
465
+ suinsClient,
466
+ env.SERVICE_FEE_NAME,
467
+ senderAddress,
468
+ )
469
+ tx.transferObjects([nsCoin], feeRecipient)
470
+ tx.setGasBudget(100_000_000)
471
+
472
+ return {
473
+ tx,
474
+ breakdown: {
475
+ suiInputMist: totalSuiNeeded,
476
+ nsOutputEstimate: expectedNsOutput,
477
+ registrationCostNsMist,
478
+ slippageBps,
479
+ nsPerSui: nsPrice.nsPerSui,
480
+ source: nsPrice.source,
481
+ priceImpactBps,
482
+ minNsOutput,
483
+ feeRecipient,
484
+ sourceCoinType,
485
+ sourceTokensNeeded: String(tokenMistWithSlippage),
486
+ },
487
+ }
488
+ }
489
+
490
+ export async function buildSuiRegisterTx(
491
+ params: Omit<SwapRegisterParams, 'slippageBps'>,
492
+ env: Env,
493
+ ): Promise<Transaction> {
494
+ const { domain, years, senderAddress, expirationMs } = params
495
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
496
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
497
+
498
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
499
+ const suinsClient = new SuinsClient({ client: client as never, network })
500
+
501
+ const pricing = await calculateRegistrationPrice({
502
+ domain: cleanDomain,
503
+ years,
504
+ expirationMs,
505
+ env,
506
+ })
507
+
508
+ const tx = new Transaction()
509
+ tx.setSender(senderAddress)
510
+
511
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
512
+ const coinConfig = suinsClient.config.coins.SUI
513
+ if (!coinConfig) {
514
+ throw new Error('SuiNS SUI coin configuration not found')
515
+ }
516
+
517
+ const priceInfoObjectId = coinConfig.feed
518
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
519
+ : undefined
520
+
521
+ const priceWithBuffer = pricing.directSuiMist + (pricing.directSuiMist * 1n) / 100n
522
+ const [paymentCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(priceWithBuffer)])
523
+
524
+ const nft = suinsTx.register({
525
+ domain: cleanDomain,
526
+ years,
527
+ coinConfig,
528
+ coin: paymentCoin,
529
+ priceInfoObjectId,
530
+ })
531
+
532
+ suinsTx.setTargetAddress({
533
+ nft,
534
+ address: senderAddress,
535
+ isSubname: cleanDomain.replace(/\.sui$/i, '').includes('.'),
536
+ })
537
+
538
+ tx.transferObjects([nft], senderAddress)
539
+ tx.mergeCoins(tx.gas, [paymentCoin])
540
+ tx.setGasBudget(100_000_000)
541
+
542
+ return tx
543
+ }
544
+
545
+ interface SwapRenewParams {
546
+ domain: string
547
+ nftId: string
548
+ years: number
549
+ senderAddress: string
550
+ slippageBps?: number
551
+ }
552
+
553
+ interface SwapRenewResult {
554
+ tx: Transaction
555
+ breakdown: SwapBreakdown
556
+ }
557
+
558
+ export async function buildSwapAndRenewTx(
559
+ params: SwapRenewParams,
560
+ env: Env,
561
+ ): Promise<SwapRenewResult> {
562
+ const { domain, years, senderAddress, nftId } = params
563
+ const slippageBps = params.slippageBps ?? DEFAULT_SLIPPAGE_BPS
564
+
565
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
566
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
567
+
568
+ const [pricing, nsPrice] = await Promise.all([
569
+ calculateRenewalPrice({ domain: cleanDomain, years, env }),
570
+ getNSSuiPrice(env, true),
571
+ ])
572
+
573
+ const renewalCostNsMist = pricing.nsNeededMist
574
+
575
+ const nsPoolAddress = DEEPBOOK_NS_SUI_POOL[network]
576
+ const deepbookPackage = DEEPBOOK_PACKAGE[network]
577
+
578
+ if (!nsPoolAddress || !deepbookPackage) {
579
+ throw new Error(`DeepBook pools not available on ${network}`)
580
+ }
581
+
582
+ let suiForNsSwap: bigint
583
+ let minNsOutput: bigint
584
+ let expectedNsOutput: bigint
585
+ let priceImpactBps = 0
586
+
587
+ if (nsPrice.asks?.length) {
588
+ const wideSlippage = Math.max(slippageBps, 1500)
589
+ const quote = calculateSuiNeededForNs(renewalCostNsMist, nsPrice.asks, wideSlippage)
590
+ suiForNsSwap = quote.suiNeeded
591
+ expectedNsOutput = quote.expectedNs
592
+ minNsOutput = renewalCostNsMist
593
+
594
+ const simResult = simulateBuyNsWithSui(suiForNsSwap, nsPrice.asks)
595
+ priceImpactBps = simResult.priceImpactBps
596
+
597
+ if (simResult.outputNs < minNsOutput) {
598
+ const extraBuffer = (suiForNsSwap * 30n) / 100n
599
+ suiForNsSwap = suiForNsSwap + extraBuffer
600
+ }
601
+ } else {
602
+ const bufferBps = Math.max(slippageBps * 3, 3000)
603
+ const nsWithBuffer = renewalCostNsMist + (renewalCostNsMist * BigInt(bufferBps)) / 10000n
604
+ const nsTokens = Number(nsWithBuffer) / NS_SCALE
605
+ suiForNsSwap = BigInt(Math.ceil(nsTokens * nsPrice.suiPerNs * 1e9))
606
+ expectedNsOutput = nsWithBuffer
607
+ minNsOutput = renewalCostNsMist
608
+ }
609
+
610
+ const suiInputMist = suiForNsSwap
611
+
612
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
613
+ const suinsClient = new SuinsClient({ client: client as never, network })
614
+
615
+ const tx = new Transaction()
616
+ tx.setSender(senderAddress)
617
+
618
+ const [suiCoinForNs] = tx.splitCoins(tx.gas, [tx.pure.u64(suiForNsSwap)])
619
+
620
+ const [zeroDeepCoin] = tx.moveCall({
621
+ target: '0x2::coin::zero',
622
+ typeArguments: [DEEP_TYPE],
623
+ })
624
+
625
+ const [nsCoin, nsLeftoverSui, nsLeftoverDeep] = tx.moveCall({
626
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
627
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
628
+ arguments: [
629
+ tx.object(nsPoolAddress),
630
+ suiCoinForNs,
631
+ zeroDeepCoin,
632
+ tx.pure.u64(minNsOutput),
633
+ tx.object(CLOCK_OBJECT),
634
+ ],
635
+ })
636
+
637
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
638
+ const coinConfig = suinsClient.config.coins.NS
639
+ if (!coinConfig) {
640
+ throw new Error('SuiNS NS coin configuration not found')
641
+ }
642
+
643
+ const priceInfoObjectId = coinConfig.feed
644
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
645
+ : undefined
646
+
647
+ suinsTx.renew({
648
+ nft: tx.object(nftId),
649
+ years,
650
+ coinConfig,
651
+ coin: nsCoin,
652
+ priceInfoObjectId,
653
+ })
654
+
655
+ const feeRecipient = await resolveFeeRecipient(
656
+ client,
657
+ suinsClient,
658
+ env.DISCOUNT_RECIPIENT_NAME || 'extra.sui',
659
+ senderAddress,
660
+ )
661
+ const senderLower = senderAddress.toLowerCase()
662
+ const feeRecipientLower = feeRecipient.toLowerCase()
663
+ const residualRecipient = feeRecipientLower === senderLower ? DUST_SINK_ADDRESS : feeRecipient
664
+
665
+ const [postRenewNsLeftover, nsSweepSui, nsSweepDeep] = tx.moveCall({
666
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
667
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
668
+ arguments: [
669
+ tx.object(nsPoolAddress),
670
+ nsCoin,
671
+ nsLeftoverDeep,
672
+ tx.pure.u64(0),
673
+ tx.object(CLOCK_OBJECT),
674
+ ],
675
+ })
676
+
677
+ const [postRenewNsLeftoverDust, nsSweepSuiDust, nsSweepDeepDust] = tx.moveCall({
678
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
679
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
680
+ arguments: [
681
+ tx.object(nsPoolAddress),
682
+ postRenewNsLeftover,
683
+ nsSweepDeep,
684
+ tx.pure.u64(0),
685
+ tx.object(CLOCK_OBJECT),
686
+ ],
687
+ })
688
+
689
+ tx.mergeCoins(nsLeftoverSui, [nsSweepSui, nsSweepSuiDust])
690
+ tx.transferObjects([nsLeftoverSui], feeRecipient)
691
+ tx.transferObjects([postRenewNsLeftoverDust, nsSweepDeepDust], residualRecipient)
692
+ tx.setGasBudget(100_000_000)
693
+
694
+ return {
695
+ tx,
696
+ breakdown: {
697
+ suiInputMist,
698
+ nsOutputEstimate: expectedNsOutput,
699
+ registrationCostNsMist: renewalCostNsMist,
700
+ slippageBps,
701
+ nsPerSui: nsPrice.nsPerSui,
702
+ source: nsPrice.source,
703
+ priceImpactBps,
704
+ minNsOutput,
705
+ feeRecipient,
706
+ },
707
+ }
708
+ }
709
+
710
+ export async function buildSuiRenewTx(
711
+ params: Omit<SwapRenewParams, 'slippageBps'>,
712
+ env: Env,
713
+ ): Promise<Transaction> {
714
+ const { domain, years, senderAddress, nftId } = params
715
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
716
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
717
+
718
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
719
+ const suinsClient = new SuinsClient({ client: client as never, network })
720
+
721
+ const pricing = await calculateRenewalPrice({
722
+ domain: cleanDomain,
723
+ years,
724
+ env,
725
+ })
726
+
727
+ const tx = new Transaction()
728
+ tx.setSender(senderAddress)
729
+
730
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
731
+ const coinConfig = suinsClient.config.coins.SUI
732
+ if (!coinConfig) {
733
+ throw new Error('SuiNS SUI coin configuration not found')
734
+ }
735
+
736
+ const priceInfoObjectId = coinConfig.feed
737
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
738
+ : undefined
739
+
740
+ const priceWithBuffer = pricing.directSuiMist + (pricing.directSuiMist * 1n) / 100n
741
+ const [paymentCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(priceWithBuffer)])
742
+
743
+ suinsTx.renew({
744
+ nft: tx.object(nftId),
745
+ years,
746
+ coinConfig,
747
+ coin: paymentCoin,
748
+ priceInfoObjectId,
749
+ })
750
+ tx.mergeCoins(tx.gas, [paymentCoin])
751
+
752
+ tx.setGasBudget(100_000_000)
753
+
754
+ return tx
755
+ }
756
+
757
+ interface MultiCoinRenewParams {
758
+ domain: string
759
+ nftId: string
760
+ years: number
761
+ senderAddress: string
762
+ sourceCoinType: string
763
+ coinObjectIds: string[]
764
+ slippageBps?: number
765
+ }
766
+
767
+ interface MultiCoinRenewResult {
768
+ tx: Transaction
769
+ breakdown: SwapBreakdown & {
770
+ sourceCoinType: string
771
+ sourceTokensNeeded: string
772
+ }
773
+ }
774
+
775
+ export async function buildMultiCoinRenewTx(
776
+ params: MultiCoinRenewParams,
777
+ env: Env,
778
+ ): Promise<MultiCoinRenewResult> {
779
+ const { domain, years, senderAddress, nftId, sourceCoinType, coinObjectIds } = params
780
+ const slippageBps =
781
+ typeof params.slippageBps === 'number' && Number.isFinite(params.slippageBps)
782
+ ? Math.max(0, Math.floor(params.slippageBps))
783
+ : DEFAULT_SLIPPAGE_BPS
784
+
785
+ if (!coinObjectIds.length) {
786
+ throw new Error('At least one coin object ID is required')
787
+ }
788
+
789
+ const cleanDomain = `${domain.toLowerCase().replace(/\.sui$/i, '')}.sui`
790
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
791
+
792
+ const [pricing, nsPrice, pools] = await Promise.all([
793
+ calculateRenewalPrice({ domain: cleanDomain, years, env }),
794
+ getNSSuiPrice(env, true),
795
+ getDeepBookSuiPools(env),
796
+ ])
797
+
798
+ const pool = pools.find((p) => p.coinType === sourceCoinType)
799
+ if (!pool) {
800
+ throw new Error(`No DeepBook pool found for coin type: ${sourceCoinType}`)
801
+ }
802
+
803
+ const renewalCostNsMist = pricing.nsNeededMist
804
+
805
+ const nsPoolAddress = DEEPBOOK_NS_SUI_POOL[network]
806
+ const deepbookPackage = DEEPBOOK_PACKAGE[network]
807
+
808
+ if (!nsPoolAddress || !deepbookPackage) {
809
+ throw new Error(`DeepBook pools not available on ${network}`)
810
+ }
811
+
812
+ let suiForNsSwap: bigint
813
+ let minNsOutput: bigint
814
+ let expectedNsOutput: bigint
815
+ let priceImpactBps = 0
816
+
817
+ if (nsPrice.asks?.length) {
818
+ const wideSlippage = slippageBps
819
+ const quote = calculateSuiNeededForNs(renewalCostNsMist, nsPrice.asks, wideSlippage)
820
+ suiForNsSwap = quote.suiNeeded
821
+ expectedNsOutput = quote.expectedNs
822
+ minNsOutput = renewalCostNsMist
823
+
824
+ const simResult = simulateBuyNsWithSui(suiForNsSwap, nsPrice.asks)
825
+ priceImpactBps = simResult.priceImpactBps
826
+
827
+ if (simResult.outputNs < minNsOutput) {
828
+ suiForNsSwap = suiForNsSwap + (suiForNsSwap * 30n) / 100n
829
+ }
830
+ } else {
831
+ const bufferBps = Math.max(slippageBps * 3, 3000)
832
+ const nsWithBuffer = renewalCostNsMist + (renewalCostNsMist * BigInt(bufferBps)) / 10000n
833
+ const nsTokens = Number(nsWithBuffer) / NS_SCALE
834
+ suiForNsSwap = BigInt(Math.ceil(nsTokens * nsPrice.suiPerNs * 1e9))
835
+ expectedNsOutput = nsWithBuffer
836
+ minNsOutput = renewalCostNsMist
837
+ }
838
+
839
+ const totalSuiNeeded = suiForNsSwap
840
+
841
+ const tokensNeededFloat = Number(totalSuiNeeded) / 1e9 / pool.suiPerToken
842
+ const tokenMistNeeded = BigInt(Math.ceil(tokensNeededFloat * 10 ** pool.decimals))
843
+ const tokenMistWithSlippage =
844
+ tokenMistNeeded + (tokenMistNeeded * BigInt(slippageBps)) / 10000n
845
+
846
+ const client = new SuiClient({ url: getDefaultRpcUrl(env.SUI_NETWORK), network: env.SUI_NETWORK })
847
+ const suinsClient = new SuinsClient({ client: client as never, network })
848
+
849
+ const tx = new Transaction()
850
+ tx.setSender(senderAddress)
851
+
852
+ const sourceCoin = tx.object(coinObjectIds[0])
853
+ if (coinObjectIds.length > 1) {
854
+ tx.mergeCoins(
855
+ sourceCoin,
856
+ coinObjectIds.slice(1).map((id) => tx.object(id)),
857
+ )
858
+ }
859
+
860
+ const [tokenToSell] = tx.splitCoins(sourceCoin, [tx.pure.u64(tokenMistWithSlippage)])
861
+
862
+ const [zeroDeepCoin] = tx.moveCall({
863
+ target: '0x2::coin::zero',
864
+ typeArguments: [DEEP_TYPE],
865
+ })
866
+
867
+ const minSuiFromSwap = suiForNsSwap - (suiForNsSwap * BigInt(slippageBps)) / 10000n
868
+
869
+ let swappedSuiCoin: ReturnType<Transaction['moveCall']>[0]
870
+ if (!pool.isDirect) {
871
+ const suiUsdcPoolAddress = DEEPBOOK_SUI_USDC_POOL[network]
872
+ if (!suiUsdcPoolAddress) {
873
+ throw new Error('SUI/USDC pool not available for indirect swap')
874
+ }
875
+ const [zeroDeep1] = tx.moveCall({
876
+ target: '0x2::coin::zero',
877
+ typeArguments: [DEEP_TYPE],
878
+ })
879
+ const [tokenLeft1, usdcOut, deepLeft1] = tx.moveCall({
880
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
881
+ typeArguments: [sourceCoinType, USDC_TYPE],
882
+ arguments: [
883
+ tx.object(pool.poolAddress),
884
+ tokenToSell,
885
+ zeroDeepCoin,
886
+ tx.pure.u64(0n),
887
+ tx.object(CLOCK_OBJECT),
888
+ ],
889
+ })
890
+ const [suiOut, usdcLeft, deepLeft2] = tx.moveCall({
891
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
892
+ typeArguments: [SUI_TYPE, USDC_TYPE],
893
+ arguments: [
894
+ tx.object(suiUsdcPoolAddress),
895
+ usdcOut,
896
+ zeroDeep1,
897
+ tx.pure.u64(minSuiFromSwap),
898
+ tx.object(CLOCK_OBJECT),
899
+ ],
900
+ })
901
+ swappedSuiCoin = suiOut
902
+ tx.transferObjects([tokenLeft1, usdcLeft, deepLeft1, deepLeft2, sourceCoin], senderAddress)
903
+ } else if (pool.suiIsBase) {
904
+ const [suiOut, tokenLeft, deepLeft2] = tx.moveCall({
905
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
906
+ typeArguments: [SUI_TYPE, sourceCoinType],
907
+ arguments: [
908
+ tx.object(pool.poolAddress),
909
+ tokenToSell,
910
+ zeroDeepCoin,
911
+ tx.pure.u64(minSuiFromSwap),
912
+ tx.object(CLOCK_OBJECT),
913
+ ],
914
+ })
915
+ swappedSuiCoin = suiOut
916
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], senderAddress)
917
+ } else {
918
+ const [tokenLeft, suiOut, deepLeft2] = tx.moveCall({
919
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
920
+ typeArguments: [sourceCoinType, SUI_TYPE],
921
+ arguments: [
922
+ tx.object(pool.poolAddress),
923
+ tokenToSell,
924
+ zeroDeepCoin,
925
+ tx.pure.u64(minSuiFromSwap),
926
+ tx.object(CLOCK_OBJECT),
927
+ ],
928
+ })
929
+ swappedSuiCoin = suiOut
930
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], senderAddress)
931
+ }
932
+
933
+ const [suiCoinForNs] = tx.splitCoins(swappedSuiCoin, [tx.pure.u64(suiForNsSwap)])
934
+ tx.mergeCoins(tx.gas, [swappedSuiCoin])
935
+
936
+ const [zeroDeepForNs] = tx.moveCall({
937
+ target: '0x2::coin::zero',
938
+ typeArguments: [DEEP_TYPE],
939
+ })
940
+
941
+ const [nsCoin, nsLeftoverSui, nsLeftoverDeep] = tx.moveCall({
942
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
943
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
944
+ arguments: [
945
+ tx.object(nsPoolAddress),
946
+ suiCoinForNs,
947
+ zeroDeepForNs,
948
+ tx.pure.u64(minNsOutput),
949
+ tx.object(CLOCK_OBJECT),
950
+ ],
951
+ })
952
+
953
+ const suinsTx = new SuinsTransaction(suinsClient, tx)
954
+ const coinConfig = suinsClient.config.coins.NS
955
+ if (!coinConfig) {
956
+ throw new Error('SuiNS NS coin configuration not found')
957
+ }
958
+
959
+ const priceInfoObjectId = coinConfig.feed
960
+ ? (await suinsClient.getPriceInfoObject(tx, coinConfig.feed))[0]
961
+ : undefined
962
+
963
+ suinsTx.renew({
964
+ nft: tx.object(nftId),
965
+ years,
966
+ coinConfig,
967
+ coin: nsCoin,
968
+ priceInfoObjectId,
969
+ })
970
+
971
+ const feeRecipient = await resolveFeeRecipient(
972
+ client,
973
+ suinsClient,
974
+ env.DISCOUNT_RECIPIENT_NAME || 'extra.sui',
975
+ senderAddress,
976
+ )
977
+ const senderLower = senderAddress.toLowerCase()
978
+ const feeRecipientLower = feeRecipient.toLowerCase()
979
+ const residualRecipient = feeRecipientLower === senderLower ? DUST_SINK_ADDRESS : feeRecipient
980
+
981
+ const [postRenewNsLeftover, nsSweepSui, nsSweepDeep] = tx.moveCall({
982
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
983
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
984
+ arguments: [
985
+ tx.object(nsPoolAddress),
986
+ nsCoin,
987
+ nsLeftoverDeep,
988
+ tx.pure.u64(0),
989
+ tx.object(CLOCK_OBJECT),
990
+ ],
991
+ })
992
+
993
+ const [postRenewNsLeftoverDust, nsSweepSuiDust, nsSweepDeepDust] = tx.moveCall({
994
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
995
+ typeArguments: [NS_TYPE_MAINNET, SUI_TYPE],
996
+ arguments: [
997
+ tx.object(nsPoolAddress),
998
+ postRenewNsLeftover,
999
+ nsSweepDeep,
1000
+ tx.pure.u64(0),
1001
+ tx.object(CLOCK_OBJECT),
1002
+ ],
1003
+ })
1004
+
1005
+ tx.mergeCoins(nsLeftoverSui, [nsSweepSui, nsSweepSuiDust])
1006
+ tx.transferObjects([nsLeftoverSui], feeRecipient)
1007
+ tx.transferObjects([postRenewNsLeftoverDust, nsSweepDeepDust], residualRecipient)
1008
+ tx.setGasBudget(100_000_000)
1009
+
1010
+ return {
1011
+ tx,
1012
+ breakdown: {
1013
+ suiInputMist: totalSuiNeeded,
1014
+ nsOutputEstimate: expectedNsOutput,
1015
+ registrationCostNsMist: renewalCostNsMist,
1016
+ slippageBps,
1017
+ nsPerSui: nsPrice.nsPerSui,
1018
+ source: nsPrice.source,
1019
+ priceImpactBps,
1020
+ minNsOutput,
1021
+ feeRecipient,
1022
+ sourceCoinType,
1023
+ sourceTokensNeeded: String(tokenMistWithSlippage),
1024
+ },
1025
+ }
1026
+ }
1027
+
1028
+ interface GasSwapInfo {
1029
+ pool: import('./ns-price').SwappablePool
1030
+ coinObjectIds: string[]
1031
+ amountToSell: bigint
1032
+ minSuiOut: bigint
1033
+ tokenCoinType: string
1034
+ }
1035
+
1036
+ async function findBestGasSwap(
1037
+ client: SuiClient,
1038
+ sender: string,
1039
+ shortfallMist: bigint,
1040
+ env: Env,
1041
+ ): Promise<GasSwapInfo | null> {
1042
+ const pools = await getDeepBookSuiPools(env)
1043
+ if (pools.length === 0) return null
1044
+
1045
+ const poolBalances = await Promise.all(
1046
+ pools.map((p) => client.getBalance({ owner: sender, coinType: p.coinType })),
1047
+ )
1048
+
1049
+ const candidates: Array<{ pool: (typeof pools)[0]; balance: bigint }> = []
1050
+ for (let i = 0; i < pools.length; i++) {
1051
+ const bal = BigInt(poolBalances[i]?.totalBalance ?? '0')
1052
+ if (bal > 0n) candidates.push({ pool: pools[i], balance: bal })
1053
+ }
1054
+ if (candidates.length === 0) return null
1055
+
1056
+ const slippageBps = Math.max(DEFAULT_SLIPPAGE_BPS, 800)
1057
+ let best: GasSwapInfo | null = null
1058
+ let bestSuiValue = 0n
1059
+
1060
+ for (const { pool, balance } of candidates) {
1061
+ const shortfallSui = Number(shortfallMist) / 1e9
1062
+ const tokensNeeded = shortfallSui / pool.suiPerToken
1063
+ const tokenMistNeeded = BigInt(Math.ceil(tokensNeeded * 10 ** pool.decimals))
1064
+ const tokenMistWithBuffer = (tokenMistNeeded * BigInt(10000 + slippageBps)) / 10000n
1065
+ const maxSellable = (balance * 95n) / 100n
1066
+ const amountToSell =
1067
+ tokenMistWithBuffer > maxSellable ? maxSellable : tokenMistWithBuffer
1068
+
1069
+ const expectedSui =
1070
+ (Number(amountToSell) / 10 ** pool.decimals) * pool.suiPerToken
1071
+ const minSuiOut = BigInt(Math.floor(expectedSui * 0.8 * 1e9))
1072
+
1073
+ if (minSuiOut <= 0n) continue
1074
+
1075
+ const coinsRes = await client.getCoins({
1076
+ owner: sender,
1077
+ coinType: pool.coinType,
1078
+ limit: 50,
1079
+ })
1080
+ const coinIds = coinsRes.data
1081
+ .filter((c) => typeof c.coinObjectId === 'string')
1082
+ .map((c) => c.coinObjectId as string)
1083
+ if (coinIds.length === 0) continue
1084
+
1085
+ const suiValue = minSuiOut
1086
+ if (suiValue > bestSuiValue) {
1087
+ bestSuiValue = suiValue
1088
+ best = {
1089
+ pool,
1090
+ coinObjectIds: coinIds,
1091
+ amountToSell,
1092
+ minSuiOut,
1093
+ tokenCoinType: pool.coinType,
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ return best
1099
+ }
1100
+
1101
+ function addTokenToSuiGasSwap(
1102
+ tx: Transaction,
1103
+ swapInfo: GasSwapInfo,
1104
+ sender: string,
1105
+ network: 'mainnet' | 'testnet',
1106
+ ): void {
1107
+ const deepbookPackage = DEEPBOOK_PACKAGE[network]
1108
+ const suiUsdcPoolAddress = DEEPBOOK_SUI_USDC_POOL[network]
1109
+ if (!deepbookPackage) return
1110
+
1111
+ const sourceCoin = tx.object(swapInfo.coinObjectIds[0])
1112
+ if (swapInfo.coinObjectIds.length > 1) {
1113
+ tx.mergeCoins(
1114
+ sourceCoin,
1115
+ swapInfo.coinObjectIds.slice(1).map((id) => tx.object(id)),
1116
+ )
1117
+ }
1118
+
1119
+ const [tokenToSell] = tx.splitCoins(sourceCoin, [tx.pure.u64(swapInfo.amountToSell)])
1120
+
1121
+ const [zeroDeepCoin] = tx.moveCall({
1122
+ target: '0x2::coin::zero',
1123
+ typeArguments: [DEEP_TYPE],
1124
+ })
1125
+
1126
+ const minSuiFromSwap =
1127
+ swapInfo.minSuiOut -
1128
+ (swapInfo.minSuiOut * BigInt(DEFAULT_SLIPPAGE_BPS)) / 10000n
1129
+
1130
+ let swappedSui: ReturnType<Transaction['moveCall']>[0]
1131
+ if (!swapInfo.pool.isDirect && swapInfo.pool.usdcPoolAddress && suiUsdcPoolAddress) {
1132
+ const [zeroDeep1] = tx.moveCall({
1133
+ target: '0x2::coin::zero',
1134
+ typeArguments: [DEEP_TYPE],
1135
+ })
1136
+ const [tokenLeft1, usdcOut, deepLeft1] = tx.moveCall({
1137
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
1138
+ typeArguments: [swapInfo.tokenCoinType, USDC_TYPE],
1139
+ arguments: [
1140
+ tx.object(swapInfo.pool.poolAddress),
1141
+ tokenToSell,
1142
+ zeroDeepCoin,
1143
+ tx.pure.u64(0n),
1144
+ tx.object(CLOCK_OBJECT),
1145
+ ],
1146
+ })
1147
+ const [suiOut, usdcLeft, deepLeft2] = tx.moveCall({
1148
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
1149
+ typeArguments: [SUI_TYPE, USDC_TYPE],
1150
+ arguments: [
1151
+ tx.object(suiUsdcPoolAddress),
1152
+ usdcOut,
1153
+ zeroDeep1,
1154
+ tx.pure.u64(minSuiFromSwap),
1155
+ tx.object(CLOCK_OBJECT),
1156
+ ],
1157
+ })
1158
+ swappedSui = suiOut
1159
+ tx.transferObjects([tokenLeft1, usdcLeft, deepLeft1, deepLeft2, sourceCoin], sender)
1160
+ } else if (swapInfo.pool.suiIsBase) {
1161
+ const [suiOut, tokenLeft, deepLeft2] = tx.moveCall({
1162
+ target: `${deepbookPackage}::pool::swap_exact_quote_for_base`,
1163
+ typeArguments: [SUI_TYPE, swapInfo.tokenCoinType],
1164
+ arguments: [
1165
+ tx.object(swapInfo.pool.poolAddress),
1166
+ tokenToSell,
1167
+ zeroDeepCoin,
1168
+ tx.pure.u64(minSuiFromSwap),
1169
+ tx.object(CLOCK_OBJECT),
1170
+ ],
1171
+ })
1172
+ swappedSui = suiOut
1173
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], sender)
1174
+ } else {
1175
+ const [tokenLeft, suiOut, deepLeft2] = tx.moveCall({
1176
+ target: `${deepbookPackage}::pool::swap_exact_base_for_quote`,
1177
+ typeArguments: [swapInfo.tokenCoinType, SUI_TYPE],
1178
+ arguments: [
1179
+ tx.object(swapInfo.pool.poolAddress),
1180
+ tokenToSell,
1181
+ zeroDeepCoin,
1182
+ tx.pure.u64(minSuiFromSwap),
1183
+ tx.object(CLOCK_OBJECT),
1184
+ ],
1185
+ })
1186
+ swappedSui = suiOut
1187
+ tx.transferObjects([tokenLeft, deepLeft2, sourceCoin], sender)
1188
+ }
1189
+
1190
+ tx.mergeCoins(tx.gas, [swappedSui])
1191
+ }
1192
+
1193
+ export async function prependGasSwapIfNeeded(
1194
+ tx: Transaction,
1195
+ client: SuiClient,
1196
+ sender: string,
1197
+ suiNeededMist: bigint,
1198
+ env: Env,
1199
+ ): Promise<Transaction> {
1200
+ const balanceRes = await client.getBalance({ owner: sender, coinType: SUI_TYPE })
1201
+ const availableMist = BigInt(balanceRes?.totalBalance ?? '0')
1202
+ if (availableMist >= suiNeededMist) return tx
1203
+
1204
+ const shortfall = suiNeededMist - availableMist
1205
+ const swapInfo = await findBestGasSwap(client, sender, shortfall, env)
1206
+ if (!swapInfo) return tx
1207
+
1208
+ const network = env.SUI_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
1209
+ const swapTx = new Transaction()
1210
+ swapTx.setSender(sender)
1211
+ addTokenToSuiGasSwap(swapTx, swapInfo, sender, network)
1212
+
1213
+ const mainData = tx.getData()
1214
+ const swapData = swapTx.getData()
1215
+ const combined = TransactionDataBuilder.restore(
1216
+ mainData as Parameters<typeof TransactionDataBuilder.restore>[0],
1217
+ )
1218
+ combined.insertTransaction(0, swapData as Parameters<typeof combined.insertTransaction>[1])
1219
+
1220
+ const restored = Transaction.from(JSON.stringify(combined.snapshot()))
1221
+ return restored
1222
+ }