@exodus/sui-api 1.4.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.5.0](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.4.1...@exodus/sui-api@1.5.0) (2026-11-02)
7
+
8
+
9
+ ### Features
10
+
11
+ * **sui-api:** spend sui address balances in sends ([#9144](https://github.com/ExodusMovement/assets/issues/9144)) ([d4b001d](https://github.com/ExodusMovement/assets/commit/d4b001d349cd8418e19638c8ff0f7d58c64e3c49))
12
+
13
+
14
+
15
+ ## [1.4.1](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.4.0...@exodus/sui-api@1.4.1) (2026-11-02)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **sui-api:** count sui address balances in balances ([#9140](https://github.com/ExodusMovement/assets/issues/9140)) ([9486138](https://github.com/ExodusMovement/assets/commit/9486138e6cedc7edf88f136bdfc998d735756ece))
21
+
22
+
23
+
6
24
  ## [1.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.3.1...@exodus/sui-api@1.4.0) (2026-10-22)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/sui-api",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Transaction monitors, RPC with the blockchain node, and other networking code for Sui",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -24,16 +24,16 @@
24
24
  "@exodus/asset-lib": "^5.0.0",
25
25
  "@exodus/basic-utils": "^5.1.0",
26
26
  "@exodus/crypto": "^1.0.0-rc.26",
27
- "@exodus/currency": "^6.0.1",
28
27
  "@exodus/models": "^13.0.0",
29
- "@exodus/sui-lib": "^1.3.0",
28
+ "@exodus/sui-lib": "^1.4.0",
30
29
  "@exodus/sui-meta": "^1.2.0",
31
30
  "lodash": "^4.17.21",
32
31
  "minimalistic-assert": "^1.0.1",
33
32
  "ms": "^2.1.3"
34
33
  },
35
34
  "devDependencies": {
36
- "@exodus/assets-testing": "^1.0.0"
35
+ "@exodus/assets-testing": "^1.0.0",
36
+ "@exodus/currency": "^6.0.1"
37
37
  },
38
38
  "homepage": "https://github.com/ExodusMovement/assets/tree/master/sui/sui-api",
39
39
  "bugs": {
@@ -43,5 +43,5 @@
43
43
  "type": "git",
44
44
  "url": "git+https://github.com/ExodusMovement/assets.git"
45
45
  },
46
- "gitHead": "f210c7a3aa55561320c43aa8d8d5804b49f390d0"
46
+ "gitHead": "05ed20b7d858ef18380e7108022c9d323a34f5f4"
47
47
  }
@@ -0,0 +1,48 @@
1
+ const ZERO = BigInt(0)
2
+
3
+ const min = (a, b) => (a < b ? a : b)
4
+
5
+ const planBaseSpend = ({ amount, coins, addressBalance, gasBudget }) => {
6
+ if (addressBalance === ZERO || coins >= amount + gasBudget) {
7
+ return { fromCoins: amount, fromAddressBalance: ZERO, gasFromAddressBalance: false }
8
+ }
9
+
10
+ if (addressBalance >= amount + gasBudget) {
11
+ return { fromCoins: ZERO, fromAddressBalance: amount, gasFromAddressBalance: true }
12
+ }
13
+
14
+ if (coins < gasBudget && addressBalance >= gasBudget) {
15
+ const fromAddressBalance = min(addressBalance - gasBudget, amount)
16
+ return {
17
+ fromCoins: amount - fromAddressBalance,
18
+ fromAddressBalance,
19
+ gasFromAddressBalance: true,
20
+ }
21
+ }
22
+
23
+ const fromAddressBalance = min(addressBalance, amount)
24
+ return {
25
+ fromCoins: amount - fromAddressBalance,
26
+ fromAddressBalance,
27
+ gasFromAddressBalance: false,
28
+ }
29
+ }
30
+
31
+ const planTokenSpend = ({
32
+ amount,
33
+ coins,
34
+ addressBalance,
35
+ gasCoins,
36
+ gasAddressBalance,
37
+ gasBudget,
38
+ }) => {
39
+ const fromAddressBalance = min(addressBalance, amount - min(coins, amount))
40
+ return {
41
+ fromCoins: amount - fromAddressBalance,
42
+ fromAddressBalance,
43
+ gasFromAddressBalance: gasCoins < gasBudget && gasAddressBalance >= gasBudget,
44
+ }
45
+ }
46
+
47
+ export const planSpend = ({ isBaseAsset, ...balances }) =>
48
+ isBaseAsset ? planBaseSpend(balances) : planTokenSpend(balances)
package/src/api.js CHANGED
@@ -181,9 +181,10 @@ export class SuiApi {
181
181
  variables: { owner: address },
182
182
  getConnection: (data) => data.address?.balances,
183
183
  })
184
- return balances.map(({ coinType, totalBalance }) => ({
184
+ return balances.map(({ coinType, totalBalance, addressBalance }) => ({
185
185
  coinType: toJsonRpcTypeTag(coinType.repr),
186
186
  totalBalance,
187
+ addressBalance,
187
188
  }))
188
189
  }
189
190
 
@@ -214,8 +215,9 @@ export class SuiApi {
214
215
  const cached = this.#epoch
215
216
  if (cached && SynchronizedTime.now() < cached.startMs + cached.durationMs) return cached
216
217
 
217
- const { epoch } = await this.#query(LATEST_EPOCH)
218
+ const { chainIdentifier, epoch } = await this.#query(LATEST_EPOCH)
218
219
  this.#epoch = {
220
+ chainIdentifier,
219
221
  epochId: Number(epoch.epochId),
220
222
  referenceGasPrice: String(epoch.referenceGasPrice),
221
223
  startMs: Number(epoch.systemState.startMs.json),
@@ -224,6 +226,20 @@ export class SuiApi {
224
226
  return this.#epoch
225
227
  }
226
228
 
229
+ async getValidDuringExpiration() {
230
+ const { chainIdentifier, epochId } = await this.#getEpoch()
231
+ return {
232
+ ValidDuring: {
233
+ minEpoch: String(epochId),
234
+ maxEpoch: String(epochId + 1),
235
+ minTimestamp: null,
236
+ maxTimestamp: null,
237
+ chain: chainIdentifier,
238
+ nonce: Math.floor(Math.random() * 2 ** 32),
239
+ },
240
+ }
241
+ }
242
+
227
243
  async getReferenceGasPrice() {
228
244
  const { referenceGasPrice } = await this.#getEpoch()
229
245
  return referenceGasPrice
package/src/balances.js CHANGED
@@ -1,29 +1,29 @@
1
1
  import { getStakingBalances } from './staking/staking-balances.js'
2
2
 
3
+ const getAddressBalance = ({ asset, accountState }) =>
4
+ asset.currency.baseUnit(accountState.addressBalances[asset.name] ?? 0)
5
+
3
6
  export const getBalances = ({ asset, accountState }) => {
4
7
  const isBaseAsset = asset.name === asset.baseAssetName
8
+ const addressBalance = getAddressBalance({ asset, accountState })
5
9
 
6
10
  if (isBaseAsset) {
7
- const spendable = accountState.utxos.value
11
+ const coins = accountState.utxos.value
12
+ const spendable = coins.add(addressBalance)
8
13
  const stakingBalances = getStakingBalances({ asset, stakingInfo: accountState.stakingInfo })
9
14
  const { staking, staked, unstaked } = stakingBalances
10
15
  return {
11
16
  total: spendable.add(staking).add(staked).add(unstaked),
12
17
  spendable,
13
- stakeable: spendable,
18
+ stakeable: coins,
14
19
  ...stakingBalances,
15
20
  }
16
21
  }
17
22
 
18
- if (accountState.tokenUtxos[asset.name]) {
19
- return {
20
- total: accountState.tokenUtxos[asset.name].value,
21
- spendable: accountState.tokenUtxos[asset.name].value,
22
- }
23
- }
24
-
23
+ const coins = accountState.tokenUtxos[asset.name]?.value ?? asset.currency.ZERO
24
+ const spendable = coins.add(addressBalance)
25
25
  return {
26
- total: asset.currency.ZERO,
27
- spendable: asset.currency.ZERO,
26
+ total: spendable,
27
+ spendable,
28
28
  }
29
29
  }
@@ -8,6 +8,7 @@ export const createBasicAccountState = () => {
8
8
  static defaults = {
9
9
  utxos: UtxoCollection.createEmpty({ currency: asset.currency }),
10
10
  tokenUtxos: Object.create(null),
11
+ addressBalances: Object.create(null),
11
12
  stakingInfo: EMPTY_STAKING_INFO,
12
13
  }
13
14
  }
@@ -1,8 +1,41 @@
1
1
  import { UtxoCollection } from '@exodus/models'
2
+ import { coinTypes, vendorLib } from '@exodus/sui-lib'
2
3
  import assert from 'minimalistic-assert'
3
4
 
5
+ import { planSpend } from './address-balance-spend.js'
4
6
  import { Transaction } from './vendors/transactions/index.js'
5
7
 
8
+ const { normalizeSuiAddress } = vendorLib.utils
9
+
10
+ const ZERO = BigInt(0)
11
+
12
+ const toBaseUnits = (utxos) => BigInt(utxos.value.toBaseString())
13
+
14
+ const toAddressBalance = ({ accountState, assetName }) =>
15
+ BigInt(accountState.addressBalances[assetName] ?? 0)
16
+
17
+ const splitFromCoins = ({ txb, utxos, amount }) => {
18
+ const coinObjectIds = utxos
19
+ .toArray()
20
+ .reverse()
21
+ .map((coin) => coin.coinObjectId)
22
+
23
+ const [inputCoin, ...rest] = coinObjectIds
24
+ if (rest.length > 0) {
25
+ txb.mergeCoins(inputCoin, rest)
26
+ }
27
+
28
+ const [coin] = txb.splitCoins(inputCoin, [amount])
29
+ return coin
30
+ }
31
+
32
+ const redeemFromAddressBalance = ({ txb, amount, type }) =>
33
+ txb.moveCall({
34
+ target: '0x2::coin::redeem_funds',
35
+ typeArguments: [type],
36
+ arguments: [txb.withdrawal({ amount, type })],
37
+ })
38
+
6
39
  export const createUnsignedTxFactory = ({ assetClientInterface, serverApi }) => {
7
40
  assert(assetClientInterface, 'assetClientInterface is required')
8
41
  return async ({
@@ -19,42 +52,82 @@ export const createUnsignedTxFactory = ({ assetClientInterface, serverApi }) =>
19
52
  walletAccount,
20
53
  })
21
54
 
22
- const txb = new Transaction()
55
+ const isBaseAsset = asset.name === asset.baseAssetName
56
+ const tokenUtxos = isBaseAsset
57
+ ? null
58
+ : accountState.tokenUtxos[asset.name] ||
59
+ UtxoCollection.createEmpty({ currency: asset.currency })
60
+
61
+ const plan = planSpend({
62
+ isBaseAsset,
63
+ amount: BigInt(amount),
64
+ coins: toBaseUnits(isBaseAsset ? accountState.utxos : tokenUtxos),
65
+ addressBalance: toAddressBalance({ accountState, assetName: asset.name }),
66
+ gasCoins: toBaseUnits(accountState.utxos),
67
+ gasAddressBalance: toAddressBalance({ accountState, assetName: asset.baseAssetName }),
68
+ gasBudget: BigInt(gasBudget),
69
+ })
23
70
 
71
+ const txb = new Transaction()
24
72
  txb.setGasBudget(BigInt(gasBudget))
25
73
 
26
- if (asset.name === asset.baseAssetName) {
27
- txb.transferObjects([txb.splitCoins(txb.gas, [amount])], txb.pure.address(recipientAddress))
28
- txb.setSender(senderAddress)
74
+ const coinType = isBaseAsset ? coinTypes.Base : asset.assetId
75
+ const coinUtxos = isBaseAsset ? accountState.utxos : tokenUtxos
76
+ const splitFromGas = isBaseAsset && !plan.gasFromAddressBalance
77
+ const splitCoinPart = (value) =>
78
+ splitFromGas
79
+ ? txb.splitCoins(txb.gas, [value])
80
+ : splitFromCoins({ txb, utxos: coinUtxos, amount: value })
29
81
 
30
- return {
31
- txData: await serverApi.buildTransaction({ transaction: txb, ...buildOptions }),
32
- txMeta: Object.create(null),
33
- }
82
+ const parts = []
83
+ if (plan.fromCoins > ZERO) {
84
+ parts.push(splitCoinPart(String(plan.fromCoins)))
34
85
  }
35
86
 
36
- const usedCoins = (
37
- accountState.tokenUtxos[asset.name] ||
38
- UtxoCollection.createEmpty({ currency: asset.currency })
39
- ).toArray()
40
-
41
- usedCoins.reverse()
42
-
43
- const coinObjectIds = usedCoins.map((coin) => coin.coinObjectId)
87
+ if (plan.fromAddressBalance > ZERO) {
88
+ parts.push(
89
+ redeemFromAddressBalance({
90
+ txb,
91
+ amount: String(plan.fromAddressBalance),
92
+ type: coinType,
93
+ })
94
+ )
95
+ }
44
96
 
45
- const [inputCoin, ...rest] = coinObjectIds
46
- if (rest.length > 0) {
47
- txb.mergeCoins(inputCoin, rest)
97
+ if (parts.length === 0) {
98
+ parts.push(
99
+ splitFromGas || coinUtxos.size > 0
100
+ ? splitCoinPart('0')
101
+ : txb.moveCall({ target: '0x2::coin::zero', typeArguments: [coinType] })
102
+ )
48
103
  }
49
104
 
50
- const [coin] = txb.splitCoins(inputCoin, [amount])
105
+ const [coin, ...others] = parts
106
+ if (others.length > 0) {
107
+ txb.mergeCoins(coin, others)
108
+ }
51
109
 
52
110
  txb.transferObjects([coin], txb.pure.address(recipientAddress))
53
111
  txb.setSender(senderAddress)
54
112
 
113
+ const gasFromAddressBalance = plan.gasFromAddressBalance && !buildOptions.onlyTransactionKind
114
+ if (gasFromAddressBalance) {
115
+ txb.setGasPayment([])
116
+ txb.setExpiration(await serverApi.getValidDuringExpiration())
117
+ }
118
+
55
119
  return {
56
120
  txData: await serverApi.buildTransaction({ transaction: txb, ...buildOptions }),
57
- txMeta: Object.create(null),
121
+ txMeta: {
122
+ addressBalanceWithdrawals:
123
+ plan.fromAddressBalance > ZERO
124
+ ? { [asset.name]: String(plan.fromAddressBalance) }
125
+ : Object.create(null),
126
+ gasFromAddressBalance,
127
+ ...(normalizeSuiAddress(senderAddress) === normalizeSuiAddress(recipientAddress) && {
128
+ selfSendAmount: String(amount),
129
+ }),
130
+ },
58
131
  }
59
132
  }
60
133
  }
@@ -51,7 +51,11 @@ export const getFeeAsyncFactory = ({ assetClientInterface, serverApi, createUnsi
51
51
 
52
52
  const gasBudget = getGasBudget({ gasUsed, feeAsset })
53
53
 
54
- const balance = baseAssetAccountState.utxos.value
54
+ const coins = baseAssetAccountState.utxos.value
55
+ const addressBalance = feeAsset.currency.baseUnit(
56
+ baseAssetAccountState.addressBalances[feeAsset.name] ?? 0
57
+ )
58
+ const balance = coins.gt(addressBalance) ? coins : addressBalance
55
59
 
56
60
  if (fee.lt(asset.feeAsset.currency.baseUnit(MIN_GAS_BUDGET))) {
57
61
  // sometimes we can receive a negative fee because of storage rebate
@@ -63,6 +67,15 @@ export const getFeeAsyncFactory = ({ assetClientInterface, serverApi, createUnsi
63
67
  }
64
68
  }
65
69
 
70
+ if (coins.lt(gasBudget) && !addressBalance.lt(gasBudget)) {
71
+ // gas will come from the address balance, which reserves the whole budget,
72
+ // so send max must leave the budget there, not just the fee
73
+ return {
74
+ fee: gasBudget,
75
+ gasBudget: gasBudget.toNumber(),
76
+ }
77
+ }
78
+
66
79
  if (gasBudget.gt(balance)) {
67
80
  // if the gas budget is greater than balance, allow to try to send with fee
68
81
  return {
@@ -5,7 +5,7 @@ import { getPureBcsSchema } from '../vendors/transactions/serializer.js'
5
5
  const { fromBase64, normalizeSuiAddress } = vendorLib.utils
6
6
 
7
7
  const SPECIAL_ADDRESS_REGEX = /0x0{63}([\dA-Fa-f])(?![\dA-Fa-f])/g
8
- const NO_GAS_OBJECT_ID = `0x${'0'.repeat(64)}`
8
+ export const NO_GAS_OBJECT_ID = `0x${'0'.repeat(64)}`
9
9
 
10
10
  export const toJsonRpcTypeTag = (typeTag) =>
11
11
  typeTag.replaceAll(SPECIAL_ADDRESS_REGEX, '0x$1').replaceAll(/,\s*/g, ', ')
@@ -65,7 +65,7 @@ export const ALL_BALANCES = `
65
65
  address(address: $owner) {
66
66
  balances(first: 50, after: $cursor) {
67
67
  pageInfo { hasNextPage endCursor }
68
- nodes { coinType { repr } totalBalance }
68
+ nodes { coinType { repr } totalBalance addressBalance }
69
69
  }
70
70
  }
71
71
  }
@@ -98,6 +98,7 @@ export const MOVE_FUNCTION_PARAMETERS = `
98
98
 
99
99
  export const LATEST_EPOCH = `
100
100
  query {
101
+ chainIdentifier
101
102
  epoch {
102
103
  epochId
103
104
  referenceGasPrice
@@ -134,18 +134,18 @@ export const createStakingApi = ({
134
134
  }
135
135
  }
136
136
 
137
- const getMaxStakeAmount = ({ asset, spendable, fee }) =>
138
- spendable.sub(fee).sub(estimateFee({ asset })).clampLowerZero()
137
+ const getMaxStakeAmount = ({ asset, stakeable, fee }) =>
138
+ stakeable.sub(fee).sub(estimateFee({ asset })).clampLowerZero()
139
139
 
140
140
  const getLimits = ({ asset, accountState, feeData, operation }) => {
141
- const { spendable, staking, staked, unstaked } = getBalances({ asset, accountState })
141
+ const { stakeable, staking, staked, unstaked } = getBalances({ asset, accountState })
142
142
  const estimatedFee = estimateFee({ asset })
143
143
 
144
144
  switch (operation) {
145
145
  case 'stake':
146
146
  return {
147
147
  min: getMinStakingAmount(feeData),
148
- max: getMaxStakeAmount({ asset, spendable, fee: estimatedFee }),
148
+ max: getMaxStakeAmount({ asset, stakeable, fee: estimatedFee }),
149
149
  reserve: estimatedFee,
150
150
  estimatedFee,
151
151
  }
@@ -165,34 +165,34 @@ export const createStakingApi = ({
165
165
  const canStake = ({ asset, accountState, feeData, amount, isMax }) => {
166
166
  assert(isMax || amount, '`amount` or `isMax` required to stake')
167
167
  const minStakingAmount = getMinStakingAmount(feeData)
168
- const { spendable } = getBalances({ asset, accountState })
168
+ const { stakeable } = getBalances({ asset, accountState })
169
169
  const fee = estimateFee({ asset })
170
- const stakeAmount = isMax ? getMaxStakeAmount({ asset, spendable, fee }) : amount
170
+ const stakeAmount = isMax ? getMaxStakeAmount({ asset, stakeable, fee }) : amount
171
171
 
172
172
  if (stakeAmount.lt(minStakingAmount)) {
173
173
  return fail('BELOW_MINIMUM', minStakingAmount.sub(stakeAmount), asset.name)
174
174
  }
175
175
 
176
- if (spendable.lt(stakeAmount)) {
177
- return fail('INSUFFICIENT_BALANCE', stakeAmount.sub(spendable), asset.name)
176
+ if (stakeable.lt(stakeAmount)) {
177
+ return fail('INSUFFICIENT_BALANCE', stakeAmount.sub(stakeable), asset.name)
178
178
  }
179
179
 
180
180
  const required = stakeAmount.add(fee)
181
- if (spendable.lt(required)) {
182
- return fail('INSUFFICIENT_FEE_BALANCE', required.sub(spendable), asset.feeAsset.name)
181
+ if (stakeable.lt(required)) {
182
+ return fail('INSUFFICIENT_FEE_BALANCE', required.sub(stakeable), asset.feeAsset.name)
183
183
  }
184
184
 
185
- return pass(spendable.sub(required).lt(estimateFee({ asset })) && 'LOW_FEE_BALANCE')
185
+ return pass(stakeable.sub(required).lt(estimateFee({ asset })) && 'LOW_FEE_BALANCE')
186
186
  }
187
187
 
188
188
  const canUnstake = ({ asset, accountState, positionIds }) => {
189
189
  const stakes = selectStakes({ stakingInfo: accountState.stakingInfo, positionIds })
190
190
  if (stakes.length === 0) return fail('NOTHING_TO_UNSTAKE')
191
191
 
192
- const { spendable } = getBalances({ asset, accountState })
192
+ const { stakeable } = getBalances({ asset, accountState })
193
193
  const fee = estimateFee({ asset })
194
- if (spendable.lt(fee)) {
195
- return fail('INSUFFICIENT_FEE_BALANCE', fee.sub(spendable), asset.feeAsset.name)
194
+ if (stakeable.lt(fee)) {
195
+ return fail('INSUFFICIENT_FEE_BALANCE', fee.sub(stakeable), asset.feeAsset.name)
196
196
  }
197
197
 
198
198
  return pass()
@@ -264,7 +264,7 @@ export const createStakingApi = ({
264
264
  const prepareStake = async ({ asset, accountState, feeData, address, amount, isMax }) => {
265
265
  const minStakingAmount = getMinStakingAmount(feeData)
266
266
  const validator = stakingSettings.getValidator()
267
- const { spendable } = getBalances({ asset, accountState })
267
+ const { stakeable } = getBalances({ asset, accountState })
268
268
  const build = (stakeAmount) =>
269
269
  buildStakeTransaction({
270
270
  sender: address,
@@ -278,7 +278,7 @@ export const createStakingApi = ({
278
278
  transaction: build(isMax ? minStakingAmount : amount),
279
279
  })
280
280
  const stakeAmount = isMax
281
- ? getMaxStakeAmount({ asset, spendable, fee: estimate.gasBudget })
281
+ ? getMaxStakeAmount({ asset, stakeable, fee: estimate.gasBudget })
282
282
  : amount
283
283
  if (stakeAmount.lt(minStakingAmount)) {
284
284
  reject({
@@ -288,10 +288,10 @@ export const createStakingApi = ({
288
288
  }
289
289
 
290
290
  const required = stakeAmount.add(estimate.gasBudget)
291
- if (spendable.lt(required)) {
291
+ if (stakeable.lt(required)) {
292
292
  reject({
293
293
  operation: 'stake',
294
- check: fail('INSUFFICIENT_FEE_BALANCE', required.sub(spendable), asset.feeAsset.name),
294
+ check: fail('INSUFFICIENT_FEE_BALANCE', required.sub(stakeable), asset.feeAsset.name),
295
295
  })
296
296
  }
297
297
 
@@ -310,13 +310,13 @@ export const createStakingApi = ({
310
310
  const stakedSuiIds = stakes.map(({ id }) => id)
311
311
  const build = () => buildUnstakeTransaction({ sender: address, stakedSuiIds })
312
312
  const estimate = await simulateGas({ asset, sender: address, transaction: build() })
313
- const { spendable } = getBalances({ asset, accountState })
314
- if (spendable.lt(estimate.gasBudget)) {
313
+ const { stakeable } = getBalances({ asset, accountState })
314
+ if (stakeable.lt(estimate.gasBudget)) {
315
315
  reject({
316
316
  operation: 'unstake',
317
317
  check: fail(
318
318
  'INSUFFICIENT_FEE_BALANCE',
319
- estimate.gasBudget.sub(spendable),
319
+ estimate.gasBudget.sub(stakeable),
320
320
  asset.feeAsset.name
321
321
  ),
322
322
  })
@@ -61,7 +61,7 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
61
61
 
62
62
  const ourWalletAddress = await this.aci.getReceiveAddress({ assetName, walletAccount })
63
63
 
64
- const { utxos, tokenUtxos, unknownAssetIds } = await this.#getNewAccountState({
64
+ const { utxos, tokenUtxos, addressBalances, unknownAssetIds } = await this.#getNewAccountState({
65
65
  address: ourWalletAddress,
66
66
  })
67
67
 
@@ -81,6 +81,7 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
81
81
  newData: {
82
82
  utxos,
83
83
  tokenUtxos,
84
+ addressBalances,
84
85
  ...stakingData,
85
86
  },
86
87
  batch,
@@ -238,9 +239,17 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
238
239
  }
239
240
 
240
241
  #getNewAccountState = async ({ address }) => {
241
- const allCoins = await this.#serverApi.getAllCoins({
242
- owner: address,
243
- })
242
+ const [allCoins, allBalances] = await Promise.all([
243
+ this.#serverApi.getAllCoins({ owner: address }),
244
+ this.#serverApi.getAccountAllBalances({ address }),
245
+ ])
246
+
247
+ const addressBalanceByCoinType = Object.create(null)
248
+ for (const { coinType, addressBalance } of allBalances) {
249
+ if (addressBalance && addressBalance !== '0') {
250
+ addressBalanceByCoinType[coinType] = addressBalance
251
+ }
252
+ }
244
253
 
245
254
  const groupedCoins = allCoins.reduce((acc, coin) => {
246
255
  if (!acc[coin.coinType]) {
@@ -252,39 +261,43 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
252
261
  }, Object.create(null))
253
262
 
254
263
  const tokenUtxos = Object.create(null)
264
+ const addressBalances = Object.create(null)
255
265
  let baseUtxos = UtxoCollection.createEmpty({
256
266
  currency: this.asset.currency,
257
267
  })
258
268
 
259
269
  const unknownAssetIds = new Set(
260
- Object.keys(groupedCoins).filter((coinType) => coinType !== coinTypes.Base)
270
+ [...Object.keys(groupedCoins), ...Object.keys(addressBalanceByCoinType)].filter(
271
+ (coinType) => coinType !== coinTypes.Base
272
+ )
261
273
  )
262
274
 
263
275
  for (const [assetName, asset] of Object.entries(this.assets)) {
264
276
  const isBaseAsset = asset.baseAssetName === asset.name
265
- if (isBaseAsset) {
266
- baseUtxos = this.#formatUtxos({
267
- address,
268
- coinType: coinTypes.Base,
269
- coins: groupedCoins[coinTypes.Base] || [],
270
- asset,
271
- })
272
- continue
273
- }
277
+ const coinType = isBaseAsset ? coinTypes.Base : asset.assetId
278
+ if (!isBaseAsset && !unknownAssetIds.delete(coinType)) continue
274
279
 
275
- if (!unknownAssetIds.delete(asset.assetId)) continue
276
-
277
- tokenUtxos[assetName] = this.#formatUtxos({
280
+ const utxos = this.#formatUtxos({
278
281
  address,
279
- coinType: asset.assetId,
280
- coins: groupedCoins[asset.assetId] || [],
282
+ coinType,
283
+ coins: groupedCoins[coinType] || [],
281
284
  asset,
282
285
  })
286
+ if (isBaseAsset) {
287
+ baseUtxos = utxos
288
+ } else {
289
+ tokenUtxos[assetName] = utxos
290
+ }
291
+
292
+ if (addressBalanceByCoinType[coinType]) {
293
+ addressBalances[assetName] = addressBalanceByCoinType[coinType]
294
+ }
283
295
  }
284
296
 
285
297
  return {
286
298
  utxos: baseUtxos,
287
299
  tokenUtxos,
300
+ addressBalances,
288
301
  unknownAssetIds: [...unknownAssetIds],
289
302
  }
290
303
  }
@@ -1,3 +1,4 @@
1
+ import { NO_GAS_OBJECT_ID } from './graphql/json-rpc-shapes.js'
1
2
  import { parseStakingCommands } from './staking/staking-transactions.js'
2
3
 
3
4
  const parseTransaction = ({ transaction, asset }) => {
@@ -49,8 +50,10 @@ export const getFeeAmount = ({ gasUsed, feeAsset }) => {
49
50
 
50
51
  const parseConfirmations = ({ effects }) => {
51
52
  const { status, gasObject } = effects
53
+ const hasGas =
54
+ gasObject?.reference?.version || gasObject?.reference?.objectId === NO_GAS_OBJECT_ID
52
55
 
53
- if (!status?.status || !gasObject?.reference?.version) {
56
+ if (!status?.status || !hasGas) {
54
57
  return {
55
58
  confirmations: 0,
56
59
  error: 0,
package/src/tx-send.js CHANGED
@@ -1,4 +1,4 @@
1
- import { UtxoCollection } from '@exodus/models'
1
+ import { Address, UtxoCollection } from '@exodus/models'
2
2
  import { coinTypes, objectTypes, toCoinType } from '@exodus/sui-lib'
3
3
  import assert from 'minimalistic-assert'
4
4
  import ms from 'ms'
@@ -8,6 +8,7 @@ import { applyStakingTx } from './staking/staking-info.js'
8
8
  import { parseStakingCommands } from './staking/staking-transactions.js'
9
9
  import { getFeeAmount } from './transform-tx.js'
10
10
 
11
+ const ZERO = BigInt(0)
11
12
  const LOOKUP_ATTEMPTS = 5
12
13
  const LOOKUP_DELAY_MS = ms('3s')
13
14
 
@@ -78,12 +79,7 @@ const deriveNewUtxos = ({ mutatedObject, balanceChange, deletedObjects, utxos, a
78
79
  }
79
80
  }
80
81
 
81
- const getObjectAndBalanceChangesByType = ({
82
- objectChanges,
83
- balanceChanges,
84
- ownerAddress,
85
- asset,
86
- }) => {
82
+ const getObjectAndBalanceChangesByType = ({ objectChanges, balanceChanges, ownerAddress }) => {
87
83
  const ownerBalanceChangesByType = balanceChanges.reduce((acc, change) => {
88
84
  const { owner, coinType, amount } = change
89
85
  if (owner?.AddressOwner !== ownerAddress) {
@@ -95,8 +91,6 @@ const getObjectAndBalanceChangesByType = ({
95
91
  return acc
96
92
  }, Object.create(null))
97
93
 
98
- const wasBaseAssetSent = asset.name === asset.baseAssetName
99
-
100
94
  const ownerObjectChangesByType = objectChanges.reduce((acc, change) => {
101
95
  const { owner, objectType, type } = change
102
96
 
@@ -131,37 +125,110 @@ const getObjectAndBalanceChangesByType = ({
131
125
  return acc
132
126
  }, Object.create(null))
133
127
 
134
- const baseObjectsMutated = ownerObjectChangesByType[objectTypes.Base]?.mutated
128
+ if (!ownerBalanceChangesByType[coinTypes.Base]) {
129
+ throw new Error('In-decidable state change')
130
+ }
131
+
132
+ return {
133
+ ownerObjectChangesByType,
134
+ ownerBalanceChangesByType,
135
+ }
136
+ }
137
+
138
+ const addCreatedCoin = ({ utxos, created, value, coinType, ownerAddress, digest, asset }) =>
139
+ UtxoCollection.fromArray(
140
+ [
141
+ ...utxos.toArray(),
142
+ {
143
+ address: Address.create(ownerAddress, { path: 'm/0/0' }),
144
+ txId: digest,
145
+ digest: created.digest,
146
+ coinObjectId: created.objectId,
147
+ version: created.version,
148
+ coinType,
149
+ confirmations: 1,
150
+ value: asset.currency.baseUnit(String(value)),
151
+ },
152
+ ],
153
+ { currency: asset.currency }
154
+ )
155
+
156
+ // A self-send's new coin holds the sent amount. Without that amount, a new coin next to
157
+ // no mutated coin holds whatever the coins gained.
158
+ const getCreatedCoinValue = ({ created, mutated, coinChange, selfSendAmount }) => {
159
+ if (created.length !== 1) return ZERO
160
+ if (selfSendAmount !== undefined) return BigInt(selfSendAmount)
161
+ return mutated.length === 0 ? coinChange : ZERO
162
+ }
163
+
164
+ // The address balance changes by what the transaction declared: its withdrawals, plus
165
+ // the gas cost when gas is paid from it. Coins take the rest of the owner's net change,
166
+ // split between the mutated coin and the coin a self-send created.
167
+ const applyCoinChanges = ({
168
+ asset,
169
+ coinType,
170
+ objectChanges,
171
+ balanceChange = '0',
172
+ addressBalanceChange,
173
+ selfSendAmount,
174
+ utxos,
175
+ addressBalance = '0',
176
+ ownerAddress,
177
+ digest,
178
+ }) => {
179
+ const { mutated = [], created = [], deleted = [] } = objectChanges ?? Object.create(null)
180
+ const newAddressBalance = BigInt(addressBalance) + addressBalanceChange
181
+ const coinChange = BigInt(balanceChange) - addressBalanceChange
182
+ const createdCoinValue = getCreatedCoinValue({ created, mutated, coinChange, selfSendAmount })
183
+ const mutatedCoinChange = coinChange - createdCoinValue
135
184
 
136
185
  if (
137
- !baseObjectsMutated ||
138
- baseObjectsMutated.length !== 1 ||
139
- !ownerBalanceChangesByType[coinTypes.Base]
186
+ newAddressBalance < ZERO ||
187
+ mutated.length > 1 ||
188
+ createdCoinValue < ZERO ||
189
+ (selfSendAmount !== undefined && created.length !== 1) ||
190
+ (mutated.length === 0 && mutatedCoinChange !== ZERO)
140
191
  ) {
141
- // expect to have 1 mutated object at point because coins were merged
142
192
  throw new Error('In-decidable state change')
143
193
  }
144
194
 
145
- if (!wasBaseAssetSent) {
146
- const assetId = asset.assetId
147
- const coinType = toCoinType(assetId)
195
+ let newUtxos = utxos
196
+ if (mutated.length === 1) {
197
+ newUtxos = deriveNewUtxos({
198
+ mutatedObject: mutated[0],
199
+ balanceChange: String(mutatedCoinChange),
200
+ deletedObjects: deleted,
201
+ utxos,
202
+ asset,
203
+ }).utxos
204
+ }
205
+
206
+ if (createdCoinValue > ZERO) {
207
+ newUtxos = addCreatedCoin({
208
+ utxos: newUtxos,
209
+ created: created[0],
210
+ value: createdCoinValue,
211
+ coinType,
212
+ ownerAddress,
213
+ digest,
214
+ asset,
215
+ })
216
+ }
148
217
 
149
- const assetObjectsMutated = ownerObjectChangesByType[coinType]?.mutated
218
+ return { utxos: newUtxos, addressBalance: String(newAddressBalance) }
219
+ }
150
220
 
151
- if (
152
- !assetObjectsMutated ||
153
- assetObjectsMutated.length !== 1 ||
154
- !ownerBalanceChangesByType[assetId]
155
- ) {
156
- // expect to have 1 mutated object at point because coins were merged
157
- throw new Error('In-decidable state change')
221
+ const updateAddressBalances = ({ addressBalances, updates }) => {
222
+ const next = { ...addressBalances }
223
+ for (const [assetName, value] of Object.entries(updates)) {
224
+ if (value === '0') {
225
+ delete next[assetName]
226
+ } else {
227
+ next[assetName] = value
158
228
  }
159
229
  }
160
230
 
161
- return {
162
- ownerObjectChangesByType,
163
- ownerBalanceChangesByType,
164
- }
231
+ return next
165
232
  }
166
233
 
167
234
  const getRecipientAddress = ({ balanceChanges, ownerAddress }) => {
@@ -182,7 +249,9 @@ const stringifyFailureReason = (error) => {
182
249
  }
183
250
 
184
251
  // TODO: extract into separate file
185
- const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
252
+ const getStateChanges = async ({ aci, asset, walletAccount, result, txMeta }) => {
253
+ const withdrawals = txMeta?.addressBalanceWithdrawals ?? Object.create(null)
254
+ const withdrawn = (assetName) => BigInt(withdrawals[assetName] ?? 0)
186
255
  const { feeAsset, currency: assetCurrency, name: assetName } = asset
187
256
  const { objectChanges, balanceChanges, digest, effects } = result
188
257
  const { gasUsed, status } = effects
@@ -210,20 +279,33 @@ const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
210
279
  objectChanges,
211
280
  balanceChanges,
212
281
  ownerAddress,
213
- asset,
214
282
  })
215
283
 
216
284
  const newAccountState = Object.create(null)
285
+ const addressBalanceUpdates = Object.create(null)
286
+ const baseAssetName = asset.baseAsset.name
217
287
 
218
- const newBaseUtxos = deriveNewUtxos({
219
- mutatedObject: ownerObjectChangesByType[objectTypes.Base].mutated[0],
288
+ const gasCost =
289
+ BigInt(gasUsed.computationCost) + BigInt(gasUsed.storageCost) - BigInt(gasUsed.storageRebate)
290
+ const gasFromAddressBalance = txMeta?.gasFromAddressBalance ? gasCost : ZERO
291
+ const selfSendAmount = txMeta?.selfSendAmount
292
+
293
+ const base = applyCoinChanges({
294
+ asset: asset.baseAsset,
295
+ coinType: coinTypes.Base,
296
+ objectChanges: ownerObjectChangesByType[objectTypes.Base],
220
297
  balanceChange: ownerBalanceChangesByType[coinTypes.Base],
221
- deletedObjects: ownerObjectChangesByType[objectTypes.Base].deleted,
298
+ addressBalanceChange:
299
+ -(wasBaseAssetSent ? withdrawn(baseAssetName) : BigInt(0)) - gasFromAddressBalance,
300
+ selfSendAmount: wasBaseAssetSent ? selfSendAmount : undefined,
222
301
  utxos: accountState.utxos,
223
- asset: asset.baseAsset,
302
+ addressBalance: accountState.addressBalances[baseAssetName],
303
+ ownerAddress,
304
+ digest,
224
305
  })
225
306
 
226
- newAccountState.utxos = newBaseUtxos.utxos
307
+ newAccountState.utxos = base.utxos
308
+ addressBalanceUpdates[baseAssetName] = base.addressBalance
227
309
 
228
310
  const parsedStaking = wasBaseAssetSent
229
311
  ? parseStakingCommands(result.transaction?.data?.transaction)
@@ -233,17 +315,24 @@ const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
233
315
  if (wasBaseAssetSent) {
234
316
  amount = asset.currency.baseUnit(ownerBalanceChangesByType[coinTypes.Base]).add(fee)
235
317
  } else {
236
- const coinType = toCoinType(asset.assetId)
237
- const newTokenUtxos = deriveNewUtxos({
238
- mutatedObject: ownerObjectChangesByType[coinType].mutated[0],
239
- balanceChange: ownerBalanceChangesByType[asset.assetId],
240
- deletedObjects: ownerObjectChangesByType[coinType].deleted,
241
- utxos: accountState.tokenUtxos[asset.name],
318
+ const token = applyCoinChanges({
242
319
  asset,
320
+ coinType: asset.assetId,
321
+ objectChanges: ownerObjectChangesByType[toCoinType(asset.assetId)],
322
+ balanceChange: ownerBalanceChangesByType[asset.assetId],
323
+ addressBalanceChange: -withdrawn(asset.name),
324
+ selfSendAmount,
325
+ utxos:
326
+ accountState.tokenUtxos[asset.name] ||
327
+ UtxoCollection.createEmpty({ currency: asset.currency }),
328
+ addressBalance: accountState.addressBalances[asset.name],
329
+ ownerAddress,
330
+ digest,
243
331
  })
244
332
 
245
- newAccountState.tokenUtxos = { [asset.name]: newTokenUtxos.utxos }
246
- amount = asset.currency.baseUnit(ownerBalanceChangesByType[asset.assetId])
333
+ newAccountState.tokenUtxos = { [asset.name]: token.utxos }
334
+ addressBalanceUpdates[asset.name] = token.addressBalance
335
+ amount = asset.currency.baseUnit(ownerBalanceChangesByType[asset.assetId] ?? 0)
247
336
  }
248
337
 
249
338
  const staking = parsedStaking && { ...parsedStaking, amount: amount.abs().toBaseString() }
@@ -275,6 +364,10 @@ const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
275
364
  }),
276
365
  }),
277
366
  utxos: newAccountState.utxos,
367
+ addressBalances: updateAddressBalances({
368
+ addressBalances: accountState.addressBalances,
369
+ updates: addressBalanceUpdates,
370
+ }),
278
371
  tokenUtxos: {
279
372
  ...accountState.tokenUtxos,
280
373
  ...(newAccountState.tokenUtxos && newAccountState.tokenUtxos[asset.name]
@@ -324,6 +417,7 @@ export const txSendFactory = ({
324
417
  walletAccount,
325
418
  result,
326
419
  aci,
420
+ txMeta: unsignedTx.txMeta,
327
421
  })
328
422
 
329
423
  let batch = aci.createOperationsBatch()
@@ -14,6 +14,16 @@ function Pure(data) {
14
14
 
15
15
  const Inputs = {
16
16
  Pure,
17
+ FundsWithdrawal({ amount, type }) {
18
+ return {
19
+ $kind: 'FundsWithdrawal',
20
+ FundsWithdrawal: {
21
+ reservation: { $kind: 'MaxAmountU64', MaxAmountU64: String(amount) },
22
+ typeArg: { $kind: 'Balance', Balance: type },
23
+ withdrawFrom: { $kind: 'Sender', Sender: true },
24
+ },
25
+ }
26
+ },
17
27
  ObjectRef({ objectId, digest, version }) {
18
28
  return {
19
29
  $kind: 'Object',
@@ -365,6 +365,15 @@ const _Transaction = class _Transaction {
365
365
  return { $kind: 'GasCoin', GasCoin: true }
366
366
  }
367
367
 
368
+ /** Withdraws `amount` of coin `type` from the sender address balance (SIP-58). */
369
+ withdrawal({ amount, type = '0x2::sui::SUI' }) {
370
+ return __privateMethod(this, _Transaction_instances, addInput_fn).call(
371
+ this,
372
+ 'object',
373
+ Inputs.FundsWithdrawal({ amount, type })
374
+ )
375
+ }
376
+
368
377
  /**
369
378
  * Add a new object input to the transaction using the fully-resolved object reference.
370
379
  * If you only have an object ID, use `builder.object(id)` instead.
@@ -156,11 +156,24 @@ const ObjectArg = safeEnum({
156
156
  }),
157
157
  Receiving: ObjectRef,
158
158
  })
159
+ const FundsWithdrawal = object({
160
+ reservation: safeEnum({
161
+ MaxAmountU64: JsonU64,
162
+ }),
163
+ typeArg: safeEnum({
164
+ Balance: string(),
165
+ }),
166
+ withdrawFrom: safeEnum({
167
+ Sender: literal(true),
168
+ Sponsor: literal(true),
169
+ }),
170
+ })
159
171
  const CallArg = safeEnum({
160
172
  Object: ObjectArg,
161
173
  Pure: object({
162
174
  bytes: BCSBytes,
163
175
  }),
176
+ FundsWithdrawal,
164
177
  UnresolvedPure: object({
165
178
  value: unknown(),
166
179
  }),
@@ -176,10 +189,19 @@ const NormalizedCallArg = safeEnum({
176
189
  Pure: object({
177
190
  bytes: BCSBytes,
178
191
  }),
192
+ FundsWithdrawal,
179
193
  })
180
194
  const TransactionExpiration = safeEnum({
181
195
  None: literal(true),
182
196
  Epoch: JsonU64,
197
+ ValidDuring: object({
198
+ minEpoch: nullable(JsonU64),
199
+ maxEpoch: nullable(JsonU64),
200
+ minTimestamp: nullable(JsonU64),
201
+ maxTimestamp: nullable(JsonU64),
202
+ chain: string(),
203
+ nonce: pipe(number(), integer()),
204
+ }),
183
205
  })
184
206
  const TransactionData = object({
185
207
  version: literal(2),
@@ -86,11 +86,24 @@ const ObjectArg = enumUnion({
86
86
  }),
87
87
  Receiving: ObjectRef,
88
88
  })
89
+ const FundsWithdrawal = object({
90
+ reservation: enumUnion({
91
+ MaxAmountU64: JsonU64,
92
+ }),
93
+ typeArg: enumUnion({
94
+ Balance: string(),
95
+ }),
96
+ withdrawFrom: enumUnion({
97
+ Sender: literal(true),
98
+ Sponsor: literal(true),
99
+ }),
100
+ })
89
101
  const CallArg = enumUnion({
90
102
  Object: ObjectArg,
91
103
  Pure: object({
92
104
  bytes: BCSBytes,
93
105
  }),
106
+ FundsWithdrawal,
94
107
  UnresolvedPure: object({
95
108
  value: unknown(),
96
109
  }),
@@ -104,6 +117,14 @@ const CallArg = enumUnion({
104
117
  const TransactionExpiration = enumUnion({
105
118
  None: literal(true),
106
119
  Epoch: JsonU64,
120
+ ValidDuring: object({
121
+ minEpoch: nullable(JsonU64),
122
+ maxEpoch: nullable(JsonU64),
123
+ minTimestamp: nullable(JsonU64),
124
+ maxTimestamp: nullable(JsonU64),
125
+ chain: string(),
126
+ nonce: pipe(number(), integer()),
127
+ }),
107
128
  })
108
129
  const SerializedTransactionDataV2 = object({
109
130
  version: literal(2),
@@ -42,9 +42,9 @@ async function resolveTransactionPlugin(transactionData, options, next) {
42
42
 
43
43
  function validate(transactionData) {
44
44
  transactionData.inputs.forEach((input, index) => {
45
- if (input.$kind !== 'Object' && input.$kind !== 'Pure') {
45
+ if (input.$kind !== 'Object' && input.$kind !== 'Pure' && input.$kind !== 'FundsWithdrawal') {
46
46
  throw new Error(
47
- `Input at index ${index} has not been resolved. Expected a Pure or Object input, but found ${JSON.stringify(
47
+ `Input at index ${index} has not been resolved. Expected a Pure, Object or FundsWithdrawal input, but found ${JSON.stringify(
48
48
  input
49
49
  )}`
50
50
  )