@msafe/sui3-sdk 1.0.23 → 1.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msafe/sui3-sdk",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "SDK for MSafe SUI V3",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -51,6 +51,7 @@
51
51
  "eslint-plugin-prettier": "^5.0.0",
52
52
  "eslint-plugin-unused-imports": "^3.0.0",
53
53
  "exponential-backoff": "^3.1.1",
54
+ "husky": "^9.1.7",
54
55
  "jest": "^30.0.0",
55
56
  "prettier": "^3.0.3",
56
57
  "semver": "7.6.3",
@@ -73,7 +74,8 @@
73
74
  "build-yalc": "bun run build && yalc publish --private",
74
75
  "changeset": "changeset",
75
76
  "version-packages": "changeset version",
76
- "release": "bun run build && changeset publish"
77
+ "release": "bun run build && changeset publish",
78
+ "prepare": "husky"
77
79
  },
78
80
  "peerDependencies": {
79
81
  "@msafe/sui-app-store": ">0.0.100",
@@ -8,6 +8,20 @@ import { getAllCoins, SUI_COIN } from '@/utils/sui';
8
8
 
9
9
  const GAS_SAFE_OVERHEAD = 1000n;
10
10
 
11
+ /** Protocol cap: a transaction may smash at most this many gas payment objects. */
12
+ export const MAX_GAS_PAYMENT_OBJECTS = 256;
13
+
14
+ /** Cover target for estimate probes (1 SUI is enough for a normal dry-run). */
15
+ const ESTIMATE_GAS_COVER = 1_000_000_000n;
16
+
17
+ /**
18
+ * Cap for the estimate probe budget. High enough for a dry-run, and well below
19
+ * a large gas coin so the probe does not reserve the balance a GasCoin split needs.
20
+ */
21
+ const MAX_PROBE_GAS_BUDGET = 50_000_000_000n;
22
+
23
+ const COIN_WITH_BALANCE_INTENT = 'CoinWithBalance';
24
+
11
25
  export type GasFundingMode = 'classic' | 'addressBalance' | 'mixedTopUp';
12
26
 
13
27
  export interface GasFundingResult {
@@ -36,6 +50,44 @@ export class InsufficientGasFundsError extends Error {
36
50
  }
37
51
  }
38
52
 
53
+ /**
54
+ * Gas coins cannot cover a SUI amount that is split from the gas coin.
55
+ * Happens when the largest 256 coins are still short of amount + gas budget.
56
+ */
57
+ export class GasPaymentLimitError extends Error {
58
+ readonly required: bigint;
59
+
60
+ readonly covered: bigint;
61
+
62
+ readonly coinCount: number;
63
+
64
+ constructor(required: bigint, covered: bigint, coinCount: number) {
65
+ super(gasPaymentLimitMessage(required, covered, coinCount));
66
+ this.name = 'GasPaymentLimitError';
67
+ this.required = required;
68
+ this.covered = covered;
69
+ this.coinCount = coinCount;
70
+ }
71
+ }
72
+
73
+ function formatSui(mist: bigint): string {
74
+ const negative = mist < 0n;
75
+ const value = negative ? -mist : mist;
76
+ const whole = value / 1_000_000_000n;
77
+ const fraction = (value % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');
78
+ const text = fraction.length > 0 ? `${whole}.${fraction}` : `${whole}`;
79
+ return negative ? `-${text}` : text;
80
+ }
81
+
82
+ function gasPaymentLimitMessage(required: bigint, covered: bigint, coinCount: number): string {
83
+ const requiredSui = formatSui(required);
84
+ const coveredSui = formatSui(covered);
85
+ if (coinCount >= MAX_GAS_PAYMENT_OBJECTS) {
86
+ return `This transfer needs ${requiredSui} SUI from gas coins, but the largest ${MAX_GAS_PAYMENT_OBJECTS} coins only cover ${coveredSui} SUI. Send a smaller amount, or consolidate coins into fewer objects first.`;
87
+ }
88
+ return `This transfer needs ${requiredSui} SUI from gas coins, but the available SUI coin objects only cover ${coveredSui} SUI.`;
89
+ }
90
+
39
91
  /**
40
92
  * Gas budget must cover pre-rebate costs (computation + storage + overhead).
41
93
  * storageRebate is refunded after execution and must not reduce the budget.
@@ -49,7 +101,95 @@ export function computeGasBudget(
49
101
  return BigInt(computationCost) + BigInt(storageCost) + safeOverhead;
50
102
  }
51
103
 
52
- type PaymentCoinRef = { objectId: string; version: string; digest: string; balance: bigint };
104
+ export type PaymentCoinRef = { objectId: string; version: string; digest: string; balance: bigint };
105
+
106
+ function compareBalanceDescending(a: PaymentCoinRef, b: PaymentCoinRef): number {
107
+ if (a.balance === b.balance) {
108
+ return 0;
109
+ }
110
+ return a.balance < b.balance ? 1 : -1;
111
+ }
112
+
113
+ /**
114
+ * Pick the fewest large SUI coins that cover `cover`, capped at 256.
115
+ * Never attach every owned coin — accounts with hundreds of SUI objects
116
+ * exceed max_gas_payment_objects and trip gasless estimate
117
+ * ("gas_budget must be 0 for gasless transactions").
118
+ */
119
+ export function pickGasPaymentCoins(coins: PaymentCoinRef[], cover: bigint): PaymentCoinRef[] {
120
+ if (cover <= 0n) {
121
+ return [];
122
+ }
123
+
124
+ const sorted = [...coins].filter((coin) => coin.balance > 0n).sort(compareBalanceDescending);
125
+
126
+ const selected: PaymentCoinRef[] = [];
127
+ let sum = 0n;
128
+ for (let index = 0; index < sorted.length; index += 1) {
129
+ if (selected.length >= MAX_GAS_PAYMENT_OBJECTS) {
130
+ break;
131
+ }
132
+ const coin = sorted[index];
133
+ selected.push(coin);
134
+ sum += coin.balance;
135
+ if (sum >= cover) {
136
+ break;
137
+ }
138
+ }
139
+ return selected;
140
+ }
141
+
142
+ function readBigInt(value: unknown): bigint {
143
+ if (typeof value === 'bigint') {
144
+ return value;
145
+ }
146
+ if (typeof value === 'number' && Number.isFinite(value)) {
147
+ return BigInt(Math.trunc(value));
148
+ }
149
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) {
150
+ return BigInt(value);
151
+ }
152
+ return 0n;
153
+ }
154
+
155
+ /** SUI that CoinWithBalance intents will split out of the gas coin. */
156
+ export function gasCoinWithdrawal(tx: Transaction): bigint {
157
+ return tx.getData().commands.reduce((sum, command) => {
158
+ if (command.$kind !== '$Intent' || command.$Intent.name !== COIN_WITH_BALANCE_INTENT) {
159
+ return sum;
160
+ }
161
+ const data = command.$Intent.data as { type?: unknown; balance?: unknown } | undefined;
162
+ if (data?.type !== 'gas') {
163
+ return sum;
164
+ }
165
+ return sum + readBigInt(data.balance);
166
+ }, 0n);
167
+ }
168
+
169
+ /**
170
+ * Balance gas payment coins must hold.
171
+ * A SUI withdrawal is taken from the gas coin only when address balance cannot
172
+ * cover that withdrawal by itself. Mysten redeems address balance for the whole
173
+ * amount in that case, and gas payment only has to pay the gas budget.
174
+ */
175
+ export function gasPaymentCover(tx: Transaction, addressBalance: bigint, gasBudget: bigint): bigint {
176
+ const withdrawal = gasCoinWithdrawal(tx);
177
+ const fromGasCoin = withdrawal > addressBalance ? withdrawal : 0n;
178
+ return gasBudget + fromGasCoin;
179
+ }
180
+
181
+ function sumBalances(coins: PaymentCoinRef[]): bigint {
182
+ return coins.reduce((sum, coin) => sum + coin.balance, 0n);
183
+ }
184
+
185
+ function pickGasPaymentCoinsForCover(coins: PaymentCoinRef[], cover: bigint): PaymentCoinRef[] {
186
+ const picked = pickGasPaymentCoins(coins, cover);
187
+ const covered = sumBalances(picked);
188
+ if (cover > 0n && covered < cover) {
189
+ throw new GasPaymentLimitError(cover, covered, picked.length);
190
+ }
191
+ return picked;
192
+ }
53
193
 
54
194
  interface SuiGasFunding {
55
195
  paymentCoins: PaymentCoinRef[];
@@ -281,21 +421,31 @@ export async function selectGasFunding(input: {
281
421
  throw new InsufficientGasFundsError(gasBudget, coinBalance, addressBalance);
282
422
  }
283
423
 
424
+ // SUI sent via the gas coin must be in the payment set, not only the gas budget.
425
+ // Otherwise a single large coin is selected and any bigger transfer fails.
426
+ const cover = gasPaymentCover(tx, addressBalance, gasBudget);
427
+ if (cover > gasBudget) {
428
+ const picked = pickGasPaymentCoinsForCover(paymentCoins, cover);
429
+ tx.setGasPayment(toObjectRefs(picked));
430
+ return { mode: 'classic', gasBudget, coinBalance, addressBalance };
431
+ }
432
+
284
433
  // Prefer classic / mixed whenever owned SUI coins exist. Pure address-balance
285
434
  // gas (payment: []) is stricter on budget and is what Mysten auto-picks when
286
435
  // addressBalance > 0 — avoid it unless there is no usable gas coin.
287
436
  if (paymentCoins.length > 0 && coinBalance >= gasBudget) {
288
- tx.setGasPayment(toObjectRefs(paymentCoins));
437
+ tx.setGasPayment(toObjectRefs(pickGasPaymentCoins(paymentCoins, gasBudget)));
289
438
  return { mode: 'classic', gasBudget, coinBalance, addressBalance };
290
439
  }
291
440
 
292
441
  if (paymentCoins.length > 0 && total >= gasBudget) {
293
442
  const topUpAmount = gasBudget - coinBalance; // > 0 and <= addressBalance
443
+ // One real coin is enough — reservation smashes into it.
294
444
  await applyMixedTopUp({
295
445
  tx,
296
446
  suiClient,
297
447
  owner,
298
- paymentCoins,
448
+ paymentCoins: pickGasPaymentCoins(paymentCoins, 1n),
299
449
  topUpAmount,
300
450
  });
301
451
  return { mode: 'mixedTopUp', gasBudget, coinBalance, addressBalance };
@@ -381,9 +531,24 @@ export async function estimateGasBudget(input: {
381
531
  probe.setSender(owner);
382
532
  probe.setGasPrice(gasPrice);
383
533
 
384
- // Prefer classic coins for the probe when available (cheaper than address-balance gas).
385
- if (funding.paymentCoins.length > 0) {
386
- probe.setGasPayment(toObjectRefs(funding.paymentCoins));
534
+ // Prefer a few large classic coins for the probe (never attach every owned coin).
535
+ // When the PTB splits SUI from the gas coin, the probe must hold that amount too,
536
+ // and the budget must be set so the node does not run its own gas selection.
537
+ const fromGasCoin = gasPaymentCover(tx, funding.addressBalance, 0n);
538
+ if (fromGasCoin > 0n) {
539
+ const picked = pickGasPaymentCoins(funding.paymentCoins, fromGasCoin + ESTIMATE_GAS_COVER);
540
+ const covered = sumBalances(picked);
541
+ // The 1 SUI probe cushion is not part of the transfer. Reject only when the
542
+ // coins cannot hold the withdrawal itself and leave something for gas.
543
+ if (covered <= fromGasCoin) {
544
+ throw new GasPaymentLimitError(fromGasCoin, covered, picked.length);
545
+ }
546
+ probe.setGasPayment(toObjectRefs(picked));
547
+ const remainder = covered - fromGasCoin;
548
+ const probeBudget = remainder > MAX_PROBE_GAS_BUDGET ? MAX_PROBE_GAS_BUDGET : remainder;
549
+ probe.setGasBudget(probeBudget);
550
+ } else if (funding.paymentCoins.length > 0) {
551
+ probe.setGasPayment(toObjectRefs(pickGasPaymentCoins(funding.paymentCoins, ESTIMATE_GAS_COVER)));
387
552
  }
388
553
 
389
554
  let built: Uint8Array;
@@ -500,6 +665,6 @@ export async function preferClassicGasPayment(input: {
500
665
  if (funding.paymentCoins.length === 0) {
501
666
  return false;
502
667
  }
503
- tx.setGasPayment(toObjectRefs(funding.paymentCoins));
668
+ tx.setGasPayment(toObjectRefs(pickGasPaymentCoins(funding.paymentCoins, ESTIMATE_GAS_COVER)));
504
669
  return true;
505
670
  }