@msafe/sui3-sdk 1.0.20 → 1.0.22

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.20",
3
+ "version": "1.0.22",
4
4
  "description": "SDK for MSafe SUI V3",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -25,9 +25,9 @@ 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
+ import { parkedPayloadHex, prepareGasFunding } from '@/utils/gasFunding';
29
29
  import { BatchObjectOptions, getAllOwnedObjects, type OwnedObjectData } from '@/utils/iter/object';
30
- import { toSuiTransaction } from '@/utils/transaction';
30
+ import { getIntentionContent, toSuiTransaction, transactionFromContent } from '@/utils/transaction';
31
31
 
32
32
  export class MSafeAccount {
33
33
  public multiSig: MultiSigAccount;
@@ -138,34 +138,18 @@ export class MSafeAccount {
138
138
  async simulateIntention(
139
139
  request: Omit<IProposeIntentionRequest, 'msafeAddress' | 'signature'> & {
140
140
  txb?: Transaction;
141
+ preferAddressBalance?: boolean;
142
+ excludeObjectIds?: Iterable<string>;
141
143
  },
142
144
  ) {
143
- let txb: Transaction;
144
- if (request.txb) {
145
- txb = request.txb;
146
- } else {
147
- const appHelper = appHelpers.getAppHelper(request.application);
148
- if (!appHelper) {
149
- throw new Error(`Can't find app helper for application ${request.application}`);
150
- }
151
- txb = toSuiTransaction(
152
- await appHelper.build({
153
- network: this.globals.config.network,
154
- intentionData: request.intention,
155
- txType: request.txType,
156
- txSubType: request.txSubType,
157
- clientUrl: this.globals.config.suiClient.url,
158
- account: {
159
- address: this.address,
160
- publicKey: fromHex(this.address),
161
- chains: [SUI_MAINNET_CHAIN, SUI_TESTNET_CHAIN],
162
- features: [],
163
- },
164
- }),
165
- );
166
- }
145
+ const txb = await this.buildTxFromIntention(request);
167
146
  txb.setSender(this.address);
168
- return this.simulator.simulate({ txb, sender: this.address });
147
+ return this.simulator.simulate({
148
+ txb,
149
+ sender: this.address,
150
+ preferAddressBalance: request.preferAddressBalance,
151
+ excludeObjectIds: request.excludeObjectIds,
152
+ });
169
153
  }
170
154
 
171
155
  async proposeIntention(input: Omit<IProposeIntentionRequest, 'signature' | 'msafeAddress'>) {
@@ -188,32 +172,11 @@ export class MSafeAccount {
188
172
  async proposeIntentionAndBuildVote(
189
173
  input: Omit<IProposeIntentionAndBuildAndVoteRequest, 'signature' | 'msafeAddress' | 'payload' | 'digest'> & {
190
174
  txb?: Transaction;
175
+ preferAddressBalance?: boolean;
176
+ excludeObjectIds?: Iterable<string>;
191
177
  },
192
178
  ): Promise<void> {
193
- let txb: Transaction;
194
- if (input.txb) {
195
- txb = input.txb;
196
- } else {
197
- const appHelper = appHelpers.getAppHelper(input.application);
198
- if (!appHelper) {
199
- throw new Error(`Can't find app helper for application ${input.application}`);
200
- }
201
- txb = toSuiTransaction(
202
- await appHelper.build({
203
- network: this.globals.config.network,
204
- intentionData: input.intention,
205
- txType: input.txType,
206
- txSubType: input.txSubType,
207
- clientUrl: this.globals.config.suiClient.url,
208
- account: {
209
- address: this.address,
210
- publicKey: fromHex(this.address),
211
- chains: [SUI_MAINNET_CHAIN, SUI_TESTNET_CHAIN],
212
- features: [],
213
- },
214
- }),
215
- );
216
- }
179
+ const txb = await this.buildTxFromIntention(input);
217
180
  txb.setGasPrice(input.gasPrice);
218
181
  txb.setSender(this.address);
219
182
  await prepareGasFunding({
@@ -222,14 +185,20 @@ export class MSafeAccount {
222
185
  owner: this.address,
223
186
  gasPrice: input.gasPrice,
224
187
  gasBudget: input.gasBudget,
188
+ preferAddressBalance: input.preferAddressBalance,
189
+ excludeObjectIds: input.excludeObjectIds,
225
190
  });
226
191
  const payload = await txb.build({ client: this.globals.suiClient });
227
192
  const digest = await txb.getDigest({ client: this.globals.suiClient });
228
193
 
229
194
  const signature = await this.wallet.signTransactionBlock({ transactionBlock: payload });
230
195
 
196
+ const apiInput = { ...input };
197
+ delete apiInput.txb;
198
+ delete apiInput.preferAddressBalance;
199
+ delete apiInput.excludeObjectIds;
231
200
  return this.backend.proposeIntentionAndBuildVote({
232
- ...input,
201
+ ...apiInput,
233
202
  payload: toHex(payload),
234
203
  digest,
235
204
  msafeAddress: this.address,
@@ -356,25 +325,16 @@ export class MSafeAccount {
356
325
 
357
326
  async simulateBuildTransaction(): Promise<SimulationResult> {
358
327
  const intention = await this.backend.getNextIntention({ msafeAddress: this.address });
359
- const appHelper = appHelpers.getAppHelper(intention.application);
360
- if (!appHelper) {
361
- throw new Error(`Can't find app helper for application ${intention.application}`);
328
+ const parked = parkedPayloadHex(intention.intention);
329
+ if (parked) {
330
+ return this.simulator.simulateBuilt(fromHex(parked));
362
331
  }
363
- const txb = toSuiTransaction(
364
- await appHelper.build({
365
- network: this.globals.config.network,
366
- intentionData: intention.intention,
367
- txType: intention.txType as TransactionType,
368
- txSubType: intention.txSubType,
369
- clientUrl: this.globals.config.suiClient.url,
370
- account: {
371
- address: this.address,
372
- publicKey: fromHex(this.address),
373
- chains: [SUI_MAINNET_CHAIN, SUI_TESTNET_CHAIN],
374
- features: [],
375
- },
376
- }),
377
- );
332
+ const txb = await this.buildTxFromIntention({
333
+ application: intention.application,
334
+ intention: intention.intention,
335
+ txType: intention.txType as TransactionType,
336
+ txSubType: intention.txSubType,
337
+ });
378
338
  txb.setSender(this.address);
379
339
  return this.simulator.simulate({ txb, sender: this.address });
380
340
  }
@@ -427,6 +387,56 @@ export class MSafeAccount {
427
387
  });
428
388
  }
429
389
 
390
+ /**
391
+ * Resolve a Transaction from propose/simulate input.
392
+ * 1. Caller-supplied `txb` (generic store path — do not rebuild).
393
+ * 2. Registered app helper `build`.
394
+ * 3. Unregistered app: restore from `intention.content` (JSON or hex).
395
+ */
396
+ private async buildTxFromIntention(input: {
397
+ application: string;
398
+ txType: TransactionType;
399
+ txSubType: string;
400
+ intention: unknown;
401
+ txb?: Transaction;
402
+ }): Promise<Transaction> {
403
+ if (input.txb) {
404
+ return input.txb;
405
+ }
406
+
407
+ let appHelper: ReturnType<typeof appHelpers.getAppHelper> | undefined;
408
+ try {
409
+ appHelper = appHelpers.getAppHelper(input.application);
410
+ } catch {
411
+ appHelper = undefined;
412
+ }
413
+
414
+ if (appHelper) {
415
+ return toSuiTransaction(
416
+ await appHelper.build({
417
+ network: this.globals.config.network,
418
+ intentionData: input.intention,
419
+ txType: input.txType,
420
+ txSubType: input.txSubType,
421
+ clientUrl: this.globals.config.suiClient.url,
422
+ account: {
423
+ address: this.address,
424
+ publicKey: fromHex(this.address),
425
+ chains: [SUI_MAINNET_CHAIN, SUI_TESTNET_CHAIN],
426
+ features: [],
427
+ },
428
+ }),
429
+ );
430
+ }
431
+
432
+ const content = getIntentionContent(input.intention);
433
+ if (content != null) {
434
+ return transactionFromContent(content);
435
+ }
436
+
437
+ throw new Error(`Can't find app helper for application ${input.application}`);
438
+ }
439
+
430
440
  private calculateWeightFromVotes(votes: { userAddress: string }[]) {
431
441
  return this.calculateWeight(votes.map((vote) => vote.userAddress));
432
442
  }
@@ -23,7 +23,12 @@ function gasObjectToReference(gas: SuiClientTypes.ChangedObject | null | undefin
23
23
  export class Simulator {
24
24
  constructor(private globals: MSafeGlobals) {}
25
25
 
26
- public async simulate(input: { txb: Transaction; sender: string }): Promise<SimulationResult> {
26
+ public async simulate(input: {
27
+ txb: Transaction;
28
+ sender: string;
29
+ preferAddressBalance?: boolean;
30
+ excludeObjectIds?: Iterable<string>;
31
+ }): Promise<SimulationResult> {
27
32
  const tx = this.copyTransaction(input.txb);
28
33
 
29
34
  const { suiClient } = this.globals;
@@ -40,6 +45,8 @@ export class Simulator {
40
45
  suiClient,
41
46
  owner: input.sender,
42
47
  gasPrice,
48
+ preferAddressBalance: input.preferAddressBalance,
49
+ excludeObjectIds: input.excludeObjectIds,
43
50
  });
44
51
  } catch (e: unknown) {
45
52
  const message = e instanceof Error ? e.message : String(e);
@@ -62,9 +69,19 @@ export class Simulator {
62
69
  };
63
70
  }
64
71
 
72
+ return this.inspectBuilt(built, gasPrice);
73
+ }
74
+
75
+ /** Dry-run a frozen payload without re-selecting gas. */
76
+ public async simulateBuilt(built: Uint8Array): Promise<SimulationResult> {
77
+ const gasPrice = await this.getGasPrice();
78
+ return this.inspectBuilt(built, gasPrice);
79
+ }
80
+
81
+ private async inspectBuilt(built: Uint8Array, gasPrice: bigint): Promise<SimulationResult> {
65
82
  let inspectResult: SimulateDryRunResponse;
66
83
  try {
67
- inspectResult = await suiClient.simulateTransaction({
84
+ inspectResult = await this.globals.suiClient.simulateTransaction({
68
85
  transaction: built,
69
86
  include: {
70
87
  effects: true,
@@ -1,7 +1,7 @@
1
1
  import type { SuiClientTypes } from '@mysten/sui/client';
2
2
  import type { SuiGrpcClient } from '@mysten/sui/grpc';
3
3
  import { Transaction } from '@mysten/sui/transactions';
4
- import { normalizeStructTag, normalizeSuiObjectId } from '@mysten/sui/utils';
4
+ import { fromHex, normalizeStructTag, normalizeSuiObjectId } from '@mysten/sui/utils';
5
5
 
6
6
  import { createCoinReservationRef } from '@/utils/coinReservation';
7
7
  import { getAllCoins, SUI_COIN } from '@/utils/sui';
@@ -59,18 +59,81 @@ interface SuiGasFunding {
59
59
  }
60
60
 
61
61
  function collectUsedObjectIds(tx: Transaction): Set<string> {
62
- return tx.getData().inputs.reduce<Set<string>>((used, input) => {
62
+ const used = tx.getData().inputs.reduce<Set<string>>((acc, input) => {
63
63
  const immOrOwned = input.Object?.ImmOrOwnedObject?.objectId;
64
64
  if (immOrOwned) {
65
- used.add(normalizeSuiObjectId(immOrOwned));
66
- return used;
65
+ acc.add(normalizeSuiObjectId(immOrOwned));
66
+ return acc;
67
67
  }
68
68
  const unresolved = input.UnresolvedObject?.objectId;
69
69
  if (unresolved) {
70
- used.add(normalizeSuiObjectId(unresolved));
70
+ acc.add(normalizeSuiObjectId(unresolved));
71
71
  }
72
- return used;
72
+ return acc;
73
73
  }, new Set<string>());
74
+
75
+ (tx.getData().gasData.payment ?? []).forEach((payment) => {
76
+ if (payment.objectId) {
77
+ used.add(normalizeSuiObjectId(payment.objectId));
78
+ }
79
+ });
80
+ return used;
81
+ }
82
+
83
+ function addExcludeObjectIds(used: Set<string>, excludeObjectIds?: Iterable<string>) {
84
+ Array.from(excludeObjectIds ?? [], (id) => used.add(normalizeSuiObjectId(id)));
85
+ return used;
86
+ }
87
+
88
+ function payloadToTransaction(payload: string | Uint8Array): Transaction {
89
+ if (typeof payload !== 'string') {
90
+ return Transaction.from(payload);
91
+ }
92
+ if (/^[0-9a-fA-F]+$/.test(payload) && payload.length % 2 === 0) {
93
+ return Transaction.from(fromHex(payload));
94
+ }
95
+ return Transaction.from(payload);
96
+ }
97
+
98
+ /** Object IDs a built payload already pins (inputs + gas payment). */
99
+ export function collectPayloadObjectIds(payload: string | Uint8Array): string[] {
100
+ return [...collectUsedObjectIds(payloadToTransaction(payload))];
101
+ }
102
+
103
+ /**
104
+ * Parked queue item: intention was saved with a built hex payload + digest
105
+ * so build-next can promote it without rebuilding.
106
+ */
107
+ export function parkedPayloadHex(intention: unknown): string | undefined {
108
+ if (!intention || typeof intention !== 'object') {
109
+ return undefined;
110
+ }
111
+ const { content, digest } = intention as { content?: unknown; digest?: unknown };
112
+ if (typeof content !== 'string' || typeof digest !== 'string' || !digest) {
113
+ return undefined;
114
+ }
115
+ if (!/^[0-9a-fA-F]+$/.test(content) || content.length < 64) {
116
+ return undefined;
117
+ }
118
+ return content;
119
+ }
120
+
121
+ /** Coins already pinned by the current pending tx and any parked future payloads. */
122
+ export function collectQueueExcludeObjectIds(input: {
123
+ pendingPayload?: string;
124
+ parkedPayloads?: Array<string | undefined>;
125
+ }): string[] {
126
+ const ids = new Set<string>();
127
+ if (input.pendingPayload) {
128
+ collectPayloadObjectIds(input.pendingPayload).forEach((id) => ids.add(id));
129
+ }
130
+ (input.parkedPayloads ?? []).forEach((payload) => {
131
+ if (!payload) {
132
+ return;
133
+ }
134
+ collectPayloadObjectIds(payload).forEach((id) => ids.add(id));
135
+ });
136
+ return [...ids];
74
137
  }
75
138
 
76
139
  /**
@@ -96,8 +159,13 @@ function txConsumesOwnedSuiCoins(tx: Transaction): boolean {
96
159
  });
97
160
  }
98
161
 
99
- async function loadSuiGasFunding(suiClient: SuiGrpcClient, owner: string, tx: Transaction): Promise<SuiGasFunding> {
100
- const usedObjectIds = collectUsedObjectIds(tx);
162
+ async function loadSuiGasFunding(
163
+ suiClient: SuiGrpcClient,
164
+ owner: string,
165
+ tx: Transaction,
166
+ excludeObjectIds?: Iterable<string>,
167
+ ): Promise<SuiGasFunding> {
168
+ const usedObjectIds = addExcludeObjectIds(collectUsedObjectIds(tx), excludeObjectIds);
101
169
  const [coins, balanceRes] = await Promise.all([
102
170
  getAllCoins({ suiClient, owner, coinType: SUI_COIN }),
103
171
  suiClient.getBalance({ owner, coinType: SUI_COIN }),
@@ -186,6 +254,7 @@ export async function selectGasFunding(input: {
186
254
  suiClient: SuiGrpcClient;
187
255
  owner: string;
188
256
  gasBudget: bigint;
257
+ excludeObjectIds?: Iterable<string>;
189
258
  }): Promise<GasFundingResult> {
190
259
  const { tx, suiClient, owner, gasBudget } = input;
191
260
  const { payment } = tx.getData().gasData;
@@ -196,7 +265,7 @@ export async function selectGasFunding(input: {
196
265
  // skips re-selection and fails InsufficientGas under a tight budget.
197
266
  // Selection below always overwrites payment via setGasPayment(...).
198
267
  if (payment != null && payment.length > 0) {
199
- const funding = await loadSuiGasFunding(suiClient, owner, tx);
268
+ const funding = await loadSuiGasFunding(suiClient, owner, tx, input.excludeObjectIds);
200
269
  return {
201
270
  mode: 'classic',
202
271
  gasBudget,
@@ -205,7 +274,7 @@ export async function selectGasFunding(input: {
205
274
  };
206
275
  }
207
276
 
208
- const funding = await loadSuiGasFunding(suiClient, owner, tx);
277
+ const funding = await loadSuiGasFunding(suiClient, owner, tx, input.excludeObjectIds);
209
278
  const { paymentCoins, coinBalance, addressBalance, total } = funding;
210
279
 
211
280
  if (total < gasBudget) {
@@ -289,13 +358,21 @@ export async function estimateGasBudget(input: {
289
358
  suiClient: SuiGrpcClient;
290
359
  owner: string;
291
360
  gasPrice: bigint;
361
+ excludeObjectIds?: Iterable<string>;
362
+ preferAddressBalance?: boolean;
292
363
  }): Promise<bigint> {
293
364
  const { tx, suiClient, owner, gasPrice } = input;
294
- const funding = await loadSuiGasFunding(suiClient, owner, tx);
295
- if (funding.total <= 0n) {
365
+ const funding = input.preferAddressBalance
366
+ ? {
367
+ ...(await loadSuiGasFunding(suiClient, owner, tx, input.excludeObjectIds)),
368
+ paymentCoins: [] as PaymentCoinRef[],
369
+ coinBalance: 0n,
370
+ }
371
+ : await loadSuiGasFunding(suiClient, owner, tx, input.excludeObjectIds);
372
+ const fundingTotal = input.preferAddressBalance ? funding.addressBalance : funding.total;
373
+ if (fundingTotal <= 0n) {
296
374
  throw new InsufficientGasFundsError(0n, funding.coinBalance, funding.addressBalance);
297
375
  }
298
-
299
376
  const { budget, payment } = tx.getData().gasData;
300
377
  // app-store / Mysten pre-build can leave payment:[] with budget "0".
301
378
  // Mysten's setGasBudget skips when budget is the string "0" (truthy), so clear first.
@@ -341,8 +418,18 @@ export async function prepareGasFunding(input: {
341
418
  owner: string;
342
419
  gasPrice: bigint;
343
420
  gasBudget?: bigint;
421
+ /**
422
+ * Force Address Balance gas (`payment: []`). Prefer {@link excludeObjectIds}
423
+ * for queue-behind-pending so unused SUI coins can still pay.
424
+ */
425
+ preferAddressBalance?: boolean;
426
+ /**
427
+ * Queue-behind-pending: do not pick coins the current pending / parked
428
+ * payloads already pin. Those versions will change when the earlier tx executes.
429
+ */
430
+ excludeObjectIds?: Iterable<string>;
344
431
  }): Promise<GasFundingResult> {
345
- const { tx, suiClient, owner, gasPrice } = input;
432
+ const { tx, suiClient, owner, gasPrice, excludeObjectIds } = input;
346
433
  const { payment, budget: existingBudget } = tx.getData().gasData;
347
434
  const bakedAddressBalanceGas = payment != null && payment.length === 0;
348
435
 
@@ -358,7 +445,14 @@ export async function prepareGasFunding(input: {
358
445
 
359
446
  if (gasBudget == null) {
360
447
  if (bakedAddressBalanceGas || existingPositive == null) {
361
- gasBudget = await estimateGasBudget({ tx, suiClient, owner, gasPrice });
448
+ gasBudget = await estimateGasBudget({
449
+ tx,
450
+ suiClient,
451
+ owner,
452
+ gasPrice,
453
+ excludeObjectIds,
454
+ preferAddressBalance: input.preferAddressBalance,
455
+ });
362
456
  } else {
363
457
  gasBudget = existingPositive;
364
458
  }
@@ -370,7 +464,22 @@ export async function prepareGasFunding(input: {
370
464
  if (bakedAddressBalanceGas) {
371
465
  tx.setExpiration(null);
372
466
  }
373
- return selectGasFunding({ tx, suiClient, owner, gasBudget });
467
+
468
+ if (input.preferAddressBalance) {
469
+ const funding = await loadSuiGasFunding(suiClient, owner, tx, excludeObjectIds);
470
+ if (funding.addressBalance < gasBudget) {
471
+ throw new InsufficientGasFundsError(gasBudget, funding.coinBalance, funding.addressBalance);
472
+ }
473
+ tx.setGasPayment([]);
474
+ return {
475
+ mode: 'addressBalance',
476
+ gasBudget,
477
+ coinBalance: funding.coinBalance,
478
+ addressBalance: funding.addressBalance,
479
+ };
480
+ }
481
+
482
+ return selectGasFunding({ tx, suiClient, owner, gasBudget, excludeObjectIds });
374
483
  }
375
484
 
376
485
  /**
@@ -1,4 +1,5 @@
1
1
  import { Transaction, isTransaction } from '@mysten/sui/transactions';
2
+ import { fromHex } from '@mysten/sui/utils';
2
3
 
3
4
  /**
4
5
  * Normalize a transaction value to {@link Transaction}.
@@ -23,3 +24,34 @@ export function toSuiTransaction(txb: Transaction | unknown): Transaction {
23
24
 
24
25
  throw new Error('Unsupported transaction value for toSuiTransaction');
25
26
  }
27
+
28
+ function isHex(str: string): boolean {
29
+ return /^[0-9a-fA-F]+$/.test(str);
30
+ }
31
+
32
+ export function getIntentionContent(intention: unknown): unknown {
33
+ if (intention && typeof intention === 'object' && 'content' in intention) {
34
+ return (intention as { content: unknown }).content;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Restore a Transaction from plain-tx `intention.content`.
41
+ * Accepts V2 JSON (`tx.toJSON()`), hex BCS bytes, or a JSON object.
42
+ */
43
+ export function transactionFromContent(content: unknown): Transaction {
44
+ if (content instanceof Uint8Array) {
45
+ return Transaction.from(content);
46
+ }
47
+ if (typeof content === 'string') {
48
+ if (isHex(content)) {
49
+ return Transaction.from(fromHex(content));
50
+ }
51
+ return Transaction.from(content);
52
+ }
53
+ if (content && typeof content === 'object') {
54
+ return Transaction.from(JSON.stringify(content));
55
+ }
56
+ throw new Error('Invalid transaction content');
57
+ }