@msafe/sui3-sdk 1.0.15 → 1.0.17

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.
@@ -25,6 +25,7 @@ import { OwnedCoin } from '@/types/assets';
25
25
  import { Pagination, PendingTx, SimulationResult } from '@/types/msafe';
26
26
  import { CoinHelper } from '@/utils';
27
27
  import { HexToUint8Array } from '@/utils/buffer';
28
+ import { prepareGasFunding } from '@/utils/gasFunding';
28
29
  import { BatchObjectOptions, getAllOwnedObjects, type OwnedObjectData } from '@/utils/iter/object';
29
30
  import { toSuiTransaction } from '@/utils/transaction';
30
31
 
@@ -214,8 +215,14 @@ export class MSafeAccount {
214
215
  );
215
216
  }
216
217
  txb.setGasPrice(input.gasPrice);
217
- txb.setGasBudget(input.gasBudget);
218
218
  txb.setSender(this.address);
219
+ await prepareGasFunding({
220
+ tx: txb,
221
+ suiClient: this.globals.suiClient,
222
+ owner: this.address,
223
+ gasPrice: input.gasPrice,
224
+ gasBudget: input.gasBudget,
225
+ });
219
226
  const payload = await txb.build({ client: this.globals.suiClient });
220
227
  const digest = await txb.getDigest({ client: this.globals.suiClient });
221
228
 
@@ -334,6 +341,14 @@ export class MSafeAccount {
334
341
  }
335
342
 
336
343
  const rejectTxb = toSuiTransaction(buildRejectTxb(this.address));
344
+ rejectTxb.setGasPrice(input.gasPrice);
345
+ await prepareGasFunding({
346
+ tx: rejectTxb,
347
+ suiClient: this.suiClient,
348
+ owner: this.address,
349
+ gasPrice: input.gasPrice,
350
+ gasBudget: input.gasBudget,
351
+ });
337
352
  const digest = await rejectTxb.getDigest({ client: this.suiClient });
338
353
  const payload = await rejectTxb.build({ client: this.suiClient });
339
354
  const signature = await this.wallet.signTransactionBlock({ transactionBlock: payload });
@@ -1,11 +1,11 @@
1
1
  import type { SuiClientTypes } from '@mysten/sui/client';
2
2
  import { Transaction } from '@mysten/sui/transactions';
3
3
 
4
- // Default to 120% o the reference gas price
5
4
  import { MSafeGlobals } from '@/globals/MSafeGlobals';
6
5
  import type { GasObjectReference, SimulationResult, SimulateDryRunResponse } from '@/types';
6
+ import { computeGasBudget, prepareGasFunding } from '@/utils/gasFunding';
7
7
 
8
- const GAS_SAFE_OVERHEAD = 1000n;
8
+ export { computeGasBudget, InsufficientGasFundsError } from '@/utils/gasFunding';
9
9
 
10
10
  function formatExecutionError(error: SuiClientTypes.ExecutionError): string {
11
11
  return error.message;
@@ -33,6 +33,23 @@ export class Simulator {
33
33
  tx.setGasPrice(gasPrice);
34
34
  tx.setSender(input.sender);
35
35
 
36
+ try {
37
+ // Resolve budget (no storageRebate) and select classic / address-balance / mixed top-up.
38
+ await prepareGasFunding({
39
+ tx,
40
+ suiClient,
41
+ owner: input.sender,
42
+ gasPrice,
43
+ });
44
+ } catch (e: unknown) {
45
+ const message = e instanceof Error ? e.message : String(e);
46
+ return {
47
+ success: false,
48
+ gasPrice,
49
+ simulationError: message,
50
+ };
51
+ }
52
+
36
53
  let built: Uint8Array;
37
54
  try {
38
55
  built = await tx.build({ client: suiClient });
@@ -101,16 +118,7 @@ export class Simulator {
101
118
  }
102
119
 
103
120
  private toGasBudget(gasUsed: SuiClientTypes.GasCostSummary, gasPrice: bigint) {
104
- const { computationCost, storageCost, storageRebate } = gasUsed;
105
- const safeOverhead = GAS_SAFE_OVERHEAD * gasPrice;
106
-
107
- const baseComputationCostWithOverhead = BigInt(computationCost) + safeOverhead;
108
-
109
- // do not subtract rebate for now, because it may cause normally tx gas to be lower than empty tx gas
110
- const gasBudget = baseComputationCostWithOverhead + BigInt(storageCost) - BigInt(storageRebate);
111
-
112
- // Set the budget to max(computation, computation + storage - rebate)
113
- return gasBudget > baseComputationCostWithOverhead ? gasBudget : baseComputationCostWithOverhead;
121
+ return computeGasBudget(gasUsed, gasPrice);
114
122
  }
115
123
 
116
124
  private copyTransaction(tx: Transaction): Transaction {
@@ -0,0 +1,65 @@
1
+ import { bcs, TypeTagSerializer } from '@mysten/sui/bcs';
2
+ import { deriveDynamicFieldID, fromBase58, fromHex, normalizeSuiAddress, toBase58, toHex } from '@mysten/sui/utils';
3
+
4
+ /**
5
+ * Compatibility coin-reservation ObjectRef (Mysten gas-smashing).
6
+ * Reserves address-balance SUI into gas payment without a persisted Coin object.
7
+ * Vendored because @mysten/sui does not export createCoinReservationRef publicly.
8
+ */
9
+
10
+ const SUI_ACCUMULATOR_ROOT_OBJECT_ID = normalizeSuiAddress('0xacc');
11
+ const ACCUMULATOR_KEY_TYPE_TAG = TypeTagSerializer.parseFromStr(
12
+ '0x2::accumulator::Key<0x2::balance::Balance<0x2::sui::SUI>>',
13
+ );
14
+
15
+ export const COIN_RESERVATION_MAGIC = new Uint8Array([
16
+ 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac,
17
+ 0xac,
18
+ ]);
19
+
20
+ export function isCoinReservationDigest(digestBase58: string): boolean {
21
+ const digestBytes = fromBase58(digestBase58);
22
+ const last20Bytes = digestBytes.slice(12, 32);
23
+ return last20Bytes.every((byte: number, i: number) => byte === COIN_RESERVATION_MAGIC[i]);
24
+ }
25
+
26
+ function deriveReservationObjectId(owner: string, chainIdentifier: string): string {
27
+ const keyBcs = bcs.Address.serialize(owner).toBytes();
28
+ const accumulatorId = deriveDynamicFieldID(SUI_ACCUMULATOR_ROOT_OBJECT_ID, ACCUMULATOR_KEY_TYPE_TAG, keyBcs);
29
+
30
+ const accBytes = fromHex(accumulatorId.slice(2));
31
+ const chainBytes = fromBase58(chainIdentifier);
32
+ if (chainBytes.length !== 32) {
33
+ throw new Error(`Invalid chain identifier length: expected 32 bytes, got ${chainBytes.length}`);
34
+ }
35
+ const xored = new Uint8Array(32);
36
+ for (let i = 0; i < 32; i++) {
37
+ // XOR is required to derive the reservation object ID from accumulator + chain id.
38
+ // eslint-disable-next-line no-bitwise
39
+ xored[i] = accBytes[i] ^ chainBytes[i];
40
+ }
41
+ return `0x${toHex(xored)}`;
42
+ }
43
+
44
+ export function createCoinReservationRef(
45
+ reservedBalance: bigint,
46
+ owner: string,
47
+ chainIdentifier: string,
48
+ epoch: string,
49
+ ): { objectId: string; version: string; digest: string } {
50
+ const digestBytes = new Uint8Array(32);
51
+ const view = new DataView(digestBytes.buffer);
52
+ view.setBigUint64(0, reservedBalance, true);
53
+ const epochNum = Number(epoch);
54
+ if (!Number.isSafeInteger(epochNum) || epochNum < 0 || epochNum > 0xffffffff) {
55
+ throw new Error(`Epoch ${epoch} out of u32 range for coin reservation digest`);
56
+ }
57
+ view.setUint32(8, epochNum, true);
58
+ digestBytes.set(COIN_RESERVATION_MAGIC, 12);
59
+
60
+ return {
61
+ objectId: deriveReservationObjectId(owner, chainIdentifier),
62
+ version: '0',
63
+ digest: toBase58(digestBytes),
64
+ };
65
+ }
@@ -0,0 +1,309 @@
1
+ import type { SuiClientTypes } from '@mysten/sui/client';
2
+ import type { SuiGrpcClient } from '@mysten/sui/grpc';
3
+ import { Transaction } from '@mysten/sui/transactions';
4
+ import { normalizeSuiObjectId } from '@mysten/sui/utils';
5
+
6
+ import { createCoinReservationRef } from '@/utils/coinReservation';
7
+ import { getAllCoins, SUI_COIN } from '@/utils/sui';
8
+
9
+ const GAS_SAFE_OVERHEAD = 1000n;
10
+
11
+ export type GasFundingMode = 'classic' | 'addressBalance' | 'mixedTopUp';
12
+
13
+ export interface GasFundingResult {
14
+ mode: GasFundingMode;
15
+ gasBudget: bigint;
16
+ coinBalance: bigint;
17
+ addressBalance: bigint;
18
+ }
19
+
20
+ export class InsufficientGasFundsError extends Error {
21
+ readonly gasBudget: bigint;
22
+
23
+ readonly coinBalance: bigint;
24
+
25
+ readonly addressBalance: bigint;
26
+
27
+ constructor(gasBudget: bigint, coinBalance: bigint, addressBalance: bigint) {
28
+ const total = coinBalance + addressBalance;
29
+ super(
30
+ `Insufficient gas funds: need ${gasBudget}, have coinBalance=${coinBalance} + addressBalance=${addressBalance} = ${total}`,
31
+ );
32
+ this.name = 'InsufficientGasFundsError';
33
+ this.gasBudget = gasBudget;
34
+ this.coinBalance = coinBalance;
35
+ this.addressBalance = addressBalance;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Gas budget must cover pre-rebate costs (computation + storage + overhead).
41
+ * storageRebate is refunded after execution and must not reduce the budget.
42
+ */
43
+ export function computeGasBudget(
44
+ gasUsed: Pick<SuiClientTypes.GasCostSummary, 'computationCost' | 'storageCost'>,
45
+ gasPrice: bigint,
46
+ ): bigint {
47
+ const { computationCost, storageCost } = gasUsed;
48
+ const safeOverhead = GAS_SAFE_OVERHEAD * gasPrice;
49
+ return BigInt(computationCost) + BigInt(storageCost) + safeOverhead;
50
+ }
51
+
52
+ type PaymentCoinRef = { objectId: string; version: string; digest: string; balance: bigint };
53
+
54
+ interface SuiGasFunding {
55
+ paymentCoins: PaymentCoinRef[];
56
+ coinBalance: bigint;
57
+ addressBalance: bigint;
58
+ total: bigint;
59
+ }
60
+
61
+ function collectUsedObjectIds(tx: Transaction): Set<string> {
62
+ return tx.getData().inputs.reduce<Set<string>>((used, input) => {
63
+ const immOrOwned = input.Object?.ImmOrOwnedObject?.objectId;
64
+ if (immOrOwned) {
65
+ used.add(normalizeSuiObjectId(immOrOwned));
66
+ return used;
67
+ }
68
+ const unresolved = input.UnresolvedObject?.objectId;
69
+ if (unresolved) {
70
+ used.add(normalizeSuiObjectId(unresolved));
71
+ }
72
+ return used;
73
+ }, new Set<string>());
74
+ }
75
+
76
+ async function loadSuiGasFunding(suiClient: SuiGrpcClient, owner: string, tx: Transaction): Promise<SuiGasFunding> {
77
+ const usedObjectIds = collectUsedObjectIds(tx);
78
+ const [coins, balanceRes] = await Promise.all([
79
+ getAllCoins({ suiClient, owner, coinType: SUI_COIN }),
80
+ suiClient.getBalance({ owner, coinType: SUI_COIN }),
81
+ ]);
82
+
83
+ const paymentCoins = coins
84
+ .filter((coin) => !usedObjectIds.has(normalizeSuiObjectId(coin.objectId)) && BigInt(coin.balance) > 0n)
85
+ .map((coin) => ({
86
+ objectId: coin.objectId,
87
+ version: coin.version,
88
+ digest: coin.digest,
89
+ balance: BigInt(coin.balance),
90
+ }));
91
+
92
+ const coinBalance = paymentCoins.reduce((sum, coin) => sum + coin.balance, 0n);
93
+ const addressBalance = BigInt(balanceRes.balance.addressBalance);
94
+ return {
95
+ paymentCoins,
96
+ coinBalance,
97
+ addressBalance,
98
+ total: coinBalance + addressBalance,
99
+ };
100
+ }
101
+
102
+ function toObjectRefs(coins: PaymentCoinRef[]) {
103
+ return coins.map(({ objectId, version, digest }) => ({ objectId, version, digest }));
104
+ }
105
+
106
+ /**
107
+ * Top up classic gas payment from address balance via compatibility coin reservation.
108
+ *
109
+ * Protocol gas-smashing (real Coin listed first): the reservation is withdrawn from
110
+ * address balance into that gas coin — the gas-payment equivalent of redeem_funds +
111
+ * mergeCoins into the primary gas coin, and it satisfies the upfront gas-budget check.
112
+ * (A PTB-only redeem+merge cannot fund gasBudget > coinBalance because budget is
113
+ * checked against gas payment before commands execute.)
114
+ */
115
+ async function applyMixedTopUp(input: {
116
+ tx: Transaction;
117
+ suiClient: SuiGrpcClient;
118
+ owner: string;
119
+ paymentCoins: PaymentCoinRef[];
120
+ topUpAmount: bigint;
121
+ }): Promise<void> {
122
+ const { tx, suiClient, owner, paymentCoins, topUpAmount } = input;
123
+ if (paymentCoins.length === 0) {
124
+ throw new Error('mixed gas top-up requires at least one SUI coin object');
125
+ }
126
+ if (topUpAmount <= 0n) {
127
+ tx.setGasPayment(toObjectRefs(paymentCoins));
128
+ return;
129
+ }
130
+
131
+ const [{ chainIdentifier }, { systemState }] = await Promise.all([
132
+ suiClient.core.getChainIdentifier(),
133
+ suiClient.core.getCurrentSystemState(),
134
+ ]);
135
+
136
+ const reservation = createCoinReservationRef(topUpAmount, owner, chainIdentifier, systemState.epoch);
137
+
138
+ // Real Coin first so reservation is smashed into the gas coin (not into address balance).
139
+ tx.setGasPayment([...toObjectRefs(paymentCoins), reservation]);
140
+ }
141
+
142
+ /**
143
+ * Select gas funding before tx.build() according to mixed coin/addressBalance policy:
144
+ * 1) budget without storageRebate (caller supplies computeGasBudget result)
145
+ * 2-3) fail if total < budget
146
+ * 4) coins exist and coinBal >= budget → classic setGasPayment(coins)
147
+ * 5) coins exist and total >= budget → mixed top-up via coin reservation
148
+ * 6) no usable coins and addrBal >= budget → Address Balance gas (payment: [])
149
+ */
150
+ export async function selectGasFunding(input: {
151
+ tx: Transaction;
152
+ suiClient: SuiGrpcClient;
153
+ owner: string;
154
+ gasBudget: bigint;
155
+ }): Promise<GasFundingResult> {
156
+ const { tx, suiClient, owner, gasBudget } = input;
157
+ const { payment } = tx.getData().gasData;
158
+
159
+ // Only respect an explicit *classic* gas payment (non-empty ObjectRefs).
160
+ // Do NOT respect payment: [] — Mysten auto-pick and plain-tx
161
+ // Transaction.from(builtHex) often bake address-balance gas; respecting it
162
+ // skips re-selection and fails InsufficientGas under a tight budget.
163
+ // Selection below always overwrites payment via setGasPayment(...).
164
+ if (payment != null && payment.length > 0) {
165
+ const funding = await loadSuiGasFunding(suiClient, owner, tx);
166
+ return {
167
+ mode: 'classic',
168
+ gasBudget,
169
+ coinBalance: funding.coinBalance,
170
+ addressBalance: funding.addressBalance,
171
+ };
172
+ }
173
+
174
+ const funding = await loadSuiGasFunding(suiClient, owner, tx);
175
+ const { paymentCoins, coinBalance, addressBalance, total } = funding;
176
+
177
+ if (total < gasBudget) {
178
+ throw new InsufficientGasFundsError(gasBudget, coinBalance, addressBalance);
179
+ }
180
+
181
+ // Prefer classic / mixed whenever owned SUI coins exist. Pure address-balance
182
+ // gas (payment: []) is stricter on budget and is what Mysten auto-picks when
183
+ // addressBalance > 0 — avoid it unless there is no usable gas coin.
184
+ if (paymentCoins.length > 0 && coinBalance >= gasBudget) {
185
+ tx.setGasPayment(toObjectRefs(paymentCoins));
186
+ return { mode: 'classic', gasBudget, coinBalance, addressBalance };
187
+ }
188
+
189
+ if (paymentCoins.length > 0 && total >= gasBudget) {
190
+ const topUpAmount = gasBudget - coinBalance; // > 0 and <= addressBalance
191
+ await applyMixedTopUp({
192
+ tx,
193
+ suiClient,
194
+ owner,
195
+ paymentCoins,
196
+ topUpAmount,
197
+ });
198
+ return { mode: 'mixedTopUp', gasBudget, coinBalance, addressBalance };
199
+ }
200
+
201
+ if (addressBalance >= gasBudget) {
202
+ tx.setGasPayment([]);
203
+ return { mode: 'addressBalance', gasBudget, coinBalance, addressBalance };
204
+ }
205
+
206
+ throw new InsufficientGasFundsError(gasBudget, coinBalance, addressBalance);
207
+ }
208
+
209
+ /**
210
+ * Estimate gas budget (no storageRebate) using a probe build/simulate.
211
+ * Prefers classic coin gas when coin objects exist so the estimate matches the
212
+ * cheaper payment path. Budget is left unset so Mysten can dry-run with MAX_GAS
213
+ * overrides (avoids locking the full balance away from GasCoin splits).
214
+ */
215
+ export async function estimateGasBudget(input: {
216
+ tx: Transaction;
217
+ suiClient: SuiGrpcClient;
218
+ owner: string;
219
+ gasPrice: bigint;
220
+ }): Promise<bigint> {
221
+ const { tx, suiClient, owner, gasPrice } = input;
222
+ const funding = await loadSuiGasFunding(suiClient, owner, tx);
223
+ if (funding.total <= 0n) {
224
+ throw new InsufficientGasFundsError(0n, funding.coinBalance, funding.addressBalance);
225
+ }
226
+
227
+ const probe = Transaction.from(tx);
228
+ probe.setSender(owner);
229
+ probe.setGasPrice(gasPrice);
230
+
231
+ // Prefer classic coins for the probe when available (cheaper than address-balance gas).
232
+ if (funding.paymentCoins.length > 0) {
233
+ probe.setGasPayment(toObjectRefs(funding.paymentCoins));
234
+ }
235
+
236
+ let built: Uint8Array;
237
+ try {
238
+ built = await probe.build({ client: suiClient });
239
+ } catch (e: unknown) {
240
+ const message = e instanceof Error ? e.message : String(e);
241
+ throw new Error(`Failed to estimate gas budget: ${message}`);
242
+ }
243
+
244
+ const inspect = await suiClient.simulateTransaction({
245
+ transaction: built,
246
+ include: { effects: true },
247
+ });
248
+ const effects = (inspect.Transaction ?? inspect.FailedTransaction)?.effects;
249
+ if (!effects?.gasUsed) {
250
+ throw new Error('Failed to estimate gas budget: simulateTransaction returned no gasUsed');
251
+ }
252
+ if (effects.status.success !== true) {
253
+ const err = effects.status.success === false ? effects.status.error?.message : undefined;
254
+ throw new Error(`Failed to estimate gas budget: ${err ?? 'simulation failed'}`);
255
+ }
256
+ return computeGasBudget(effects.gasUsed, gasPrice);
257
+ }
258
+
259
+ /**
260
+ * Resolve gasBudget (no rebate) and apply gas funding selection before build.
261
+ */
262
+ export async function prepareGasFunding(input: {
263
+ tx: Transaction;
264
+ suiClient: SuiGrpcClient;
265
+ owner: string;
266
+ gasPrice: bigint;
267
+ gasBudget?: bigint;
268
+ }): Promise<GasFundingResult> {
269
+ const { tx, suiClient, owner, gasPrice } = input;
270
+ const { payment, budget: existingBudget } = tx.getData().gasData;
271
+ const bakedAddressBalanceGas = payment != null && payment.length === 0;
272
+
273
+ // Never trust a budget baked into plain-tx / Mysten auto AB-gas txs.
274
+ // Those budgets are often under-estimated for payment: [] and would skip
275
+ // estimateGasBudget if we kept existingBudget.
276
+ let { gasBudget } = input;
277
+ if (gasBudget == null) {
278
+ if (bakedAddressBalanceGas || existingBudget == null) {
279
+ gasBudget = await estimateGasBudget({ tx, suiClient, owner, gasPrice });
280
+ } else {
281
+ gasBudget = BigInt(existingBudget);
282
+ }
283
+ }
284
+
285
+ tx.setGasBudget(gasBudget);
286
+ return selectGasFunding({ tx, suiClient, owner, gasBudget });
287
+ }
288
+
289
+ /**
290
+ * @deprecated Use {@link prepareGasFunding} / {@link selectGasFunding}.
291
+ * Kept as a thin classic-only helper for callers that only want coin gas when available.
292
+ */
293
+ export async function preferClassicGasPayment(input: {
294
+ tx: Transaction;
295
+ suiClient: SuiGrpcClient;
296
+ owner: string;
297
+ }): Promise<boolean> {
298
+ const { tx, suiClient, owner } = input;
299
+ const { payment } = tx.getData().gasData;
300
+ if (payment != null) {
301
+ return payment.length > 0;
302
+ }
303
+ const funding = await loadSuiGasFunding(suiClient, owner, tx);
304
+ if (funding.paymentCoins.length === 0) {
305
+ return false;
306
+ }
307
+ tx.setGasPayment(toObjectRefs(funding.paymentCoins));
308
+ return true;
309
+ }
@@ -3,4 +3,6 @@ export * from './crypto';
3
3
  export * from './format';
4
4
  export * from './sui';
5
5
  export * from './coin';
6
+ export * from './coinReservation';
7
+ export * from './gasFunding';
6
8
  export * from './transaction';
@@ -1,12 +1,25 @@
1
1
  import { Transaction, isTransaction } from '@mysten/sui/transactions';
2
2
 
3
3
  /**
4
- * Normalize a transaction value to {@link Transaction} (handles plain Transaction or serialized legacy shapes).
4
+ * Normalize a transaction value to {@link Transaction}.
5
+ * Prefer V2 restore paths — avoid deprecated V1 serialize() when possible
6
+ * (V1 drops CallArg::FundsWithdrawal).
5
7
  */
6
8
  export function toSuiTransaction(txb: Transaction | unknown): Transaction {
7
9
  if (isTransaction(txb)) {
8
10
  return txb as Transaction;
9
11
  }
10
- const legacy = txb as { serialize: () => string };
11
- return Transaction.from(legacy.serialize()) as Transaction;
12
+
13
+ if (typeof txb === 'string' || txb instanceof Uint8Array) {
14
+ return Transaction.from(txb);
15
+ }
16
+
17
+ // Last resort for true legacy TransactionBlock (pre-Transaction API).
18
+ // Modern Transaction instances are handled by isTransaction above.
19
+ const legacy = txb as { serialize?: () => string };
20
+ if (typeof legacy?.serialize === 'function') {
21
+ return Transaction.from(legacy.serialize()) as Transaction;
22
+ }
23
+
24
+ throw new Error('Unsupported transaction value for toSuiTransaction');
12
25
  }