@msafe/sui3-sdk 1.0.24 → 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.24",
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",
@@ -14,6 +14,14 @@ export const MAX_GAS_PAYMENT_OBJECTS = 256;
14
14
  /** Cover target for estimate probes (1 SUI is enough for a normal dry-run). */
15
15
  const ESTIMATE_GAS_COVER = 1_000_000_000n;
16
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
+
17
25
  export type GasFundingMode = 'classic' | 'addressBalance' | 'mixedTopUp';
18
26
 
19
27
  export interface GasFundingResult {
@@ -42,6 +50,44 @@ export class InsufficientGasFundsError extends Error {
42
50
  }
43
51
  }
44
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
+
45
91
  /**
46
92
  * Gas budget must cover pre-rebate costs (computation + storage + overhead).
47
93
  * storageRebate is refunded after execution and must not reduce the budget.
@@ -57,6 +103,13 @@ export function computeGasBudget(
57
103
 
58
104
  export type PaymentCoinRef = { objectId: string; version: string; digest: string; balance: bigint };
59
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
+
60
113
  /**
61
114
  * Pick the fewest large SUI coins that cover `cover`, capped at 256.
62
115
  * Never attach every owned coin — accounts with hundreds of SUI objects
@@ -68,16 +121,15 @@ export function pickGasPaymentCoins(coins: PaymentCoinRef[], cover: bigint): Pay
68
121
  return [];
69
122
  }
70
123
 
71
- const sorted = [...coins]
72
- .filter((coin) => coin.balance > 0n)
73
- .sort((a, b) => (a.balance === b.balance ? 0 : a.balance < b.balance ? 1 : -1));
124
+ const sorted = [...coins].filter((coin) => coin.balance > 0n).sort(compareBalanceDescending);
74
125
 
75
126
  const selected: PaymentCoinRef[] = [];
76
127
  let sum = 0n;
77
- for (const coin of sorted) {
128
+ for (let index = 0; index < sorted.length; index += 1) {
78
129
  if (selected.length >= MAX_GAS_PAYMENT_OBJECTS) {
79
130
  break;
80
131
  }
132
+ const coin = sorted[index];
81
133
  selected.push(coin);
82
134
  sum += coin.balance;
83
135
  if (sum >= cover) {
@@ -87,6 +139,58 @@ export function pickGasPaymentCoins(coins: PaymentCoinRef[], cover: bigint): Pay
87
139
  return selected;
88
140
  }
89
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
+ }
193
+
90
194
  interface SuiGasFunding {
91
195
  paymentCoins: PaymentCoinRef[];
92
196
  coinBalance: bigint;
@@ -317,6 +421,15 @@ export async function selectGasFunding(input: {
317
421
  throw new InsufficientGasFundsError(gasBudget, coinBalance, addressBalance);
318
422
  }
319
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
+
320
433
  // Prefer classic / mixed whenever owned SUI coins exist. Pure address-balance
321
434
  // gas (payment: []) is stricter on budget and is what Mysten auto-picks when
322
435
  // addressBalance > 0 — avoid it unless there is no usable gas coin.
@@ -419,7 +532,22 @@ export async function estimateGasBudget(input: {
419
532
  probe.setGasPrice(gasPrice);
420
533
 
421
534
  // Prefer a few large classic coins for the probe (never attach every owned coin).
422
- if (funding.paymentCoins.length > 0) {
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) {
423
551
  probe.setGasPayment(toObjectRefs(pickGasPaymentCoins(funding.paymentCoins, ESTIMATE_GAS_COVER)));
424
552
  }
425
553