@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/dist/index.cjs +125 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +125 -5
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/utils/gasFunding.ts +172 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msafe/sui3-sdk",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
package/src/utils/gasFunding.ts
CHANGED
|
@@ -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
|
|
385
|
-
|
|
386
|
-
|
|
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
|
}
|