Skip to content

Commit cadeda0

Browse files
feat(sdk-coin-sui): update balance querying to handle fundsInAddressBalance field
TICKET: CSHLD-407
1 parent 5428d17 commit cadeda0

17 files changed

Lines changed: 827 additions & 86 deletions

modules/sdk-coin-sui/src/lib/iface.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,25 @@ export enum MethodNames {
172172
* @see https://github.com/MystenLabs/walrus-docs/blob/9307e66df0ea3f6555cdef78d46aefa62737e216/contracts/walrus/sources/staking/staked_wal.move#L143
173173
*/
174174
WalrusSplitStakedWal = '::staked_wal::split',
175+
/**
176+
* Redeem funds from the address balance system into a Coin<T> object.
177+
* Used with the BalanceWithdrawal CallArg to spend from address balance.
178+
*
179+
* @see https://docs.sui.io/concepts/sui-move-concepts/address-balance
180+
*/
181+
RedeemFunds = '::coin::redeem_funds',
175182
}
176183

177184
export interface SuiObjectInfo extends SuiObjectRef {
178185
/** balance */
179186
balance: BigNumber;
180187
}
188+
189+
export interface SuiBalanceInfo {
190+
/** Total balance combining coin object balance and address balance */
191+
totalBalance: string;
192+
/** Balance held in coin objects (UTXO-style Coin<T> objects) */
193+
coinObjectBalance: string;
194+
/** Balance held in the address balance system (not in coin objects) */
195+
fundsInAddressBalance: string;
196+
}

modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { array, boolean, Infer, integer, object, string, union } from 'superstruct';
2-
import { normalizeSuiAddress, ObjectId, SharedObjectRef, SuiObjectRef } from '../types';
1+
import { any, array, boolean, Infer, integer, object, string, union } from 'superstruct';
2+
import { normalizeSuiAddress, ObjectId, SharedObjectRef, SuiObjectRef, TypeTag } from '../types';
33
import { builder } from './bcs';
44

55
const ObjectArg = union([
@@ -15,10 +15,17 @@ const ObjectArg = union([
1515

1616
export const PureCallArg = object({ Pure: array(integer()) });
1717
export const ObjectCallArg = object({ Object: ObjectArg });
18+
export const BalanceWithdrawalCallArg = object({
19+
BalanceWithdrawal: object({
20+
amount: any(),
21+
type_: any(),
22+
}),
23+
});
1824
export type PureCallArg = Infer<typeof PureCallArg>;
1925
export type ObjectCallArg = Infer<typeof ObjectCallArg>;
26+
export type BalanceWithdrawalCallArg = Infer<typeof BalanceWithdrawalCallArg>;
2027

21-
export const BuilderCallArg = union([PureCallArg, ObjectCallArg]);
28+
export const BuilderCallArg = union([PureCallArg, ObjectCallArg, BalanceWithdrawalCallArg]);
2229
export type BuilderCallArg = Infer<typeof BuilderCallArg>;
2330

2431
export const Inputs = {
@@ -33,12 +40,27 @@ export const Inputs = {
3340
SharedObjectRef(ref: SharedObjectRef): ObjectCallArg {
3441
return { Object: { Shared: ref } };
3542
},
43+
/**
44+
* Create a BalanceWithdrawal CallArg that withdraws `amount` from the sender's
45+
* address balance at execution time. Use with `0x2::coin::redeem_funds` to
46+
* convert the withdrawal into a `Coin<T>` object.
47+
*
48+
* @param amount - amount in base units (MIST for SUI)
49+
* @param type_ - the TypeTag of the coin (defaults to SUI)
50+
*/
51+
BalanceWithdrawal(amount: bigint | number, type_: TypeTag): BalanceWithdrawalCallArg {
52+
return { BalanceWithdrawal: { amount, type_ } };
53+
},
3654
};
3755

38-
export function getIdFromCallArg(arg: ObjectId | ObjectCallArg): string {
56+
export function getIdFromCallArg(arg: ObjectId | ObjectCallArg | BalanceWithdrawalCallArg): string {
3957
if (typeof arg === 'string') {
4058
return normalizeSuiAddress(arg);
4159
}
60+
if ('BalanceWithdrawal' in arg) {
61+
// BalanceWithdrawal inputs have no object ID; they cannot be deduplicated by ID
62+
return '';
63+
}
4264
if ('ImmOrOwned' in arg.Object) {
4365
return arg.Object.ImmOrOwned.objectId;
4466
}

modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionBlock.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ObjectId, SuiObjectRef } from '../types';
44
import { Transactions, TransactionArgument, TransactionType, TransactionBlockInput } from './Transactions';
55
import { BuilderCallArg, getIdFromCallArg, Inputs, ObjectCallArg } from './Inputs';
66
import { TransactionBlockDataBuilder, TransactionExpiration } from './TransactionDataBlock';
7+
import { TypeTagSerializer } from '../txn-data-serializers/type-tag-serializer';
78
import { create } from './utils';
89

910
type TransactionResult = TransactionArgument & TransactionArgument[];
@@ -239,6 +240,28 @@ export class TransactionBlock {
239240

240241
// Method shorthands:
241242

243+
/**
244+
* Create a BalanceWithdrawal argument that withdraws `amount` from the sender's
245+
* address balance at execution time. Pass this as an argument to
246+
* `0x2::coin::redeem_funds` to receive a `Coin<T>` object:
247+
*
248+
* ```typescript
249+
* const [coin] = tx.moveCall({
250+
* target: '0x2::coin::redeem_funds',
251+
* typeArguments: ['0x2::sui::SUI'],
252+
* arguments: [tx.withdrawal({ amount: 1_000_000n })],
253+
* });
254+
* tx.transferObjects([coin], recipientAddress);
255+
* ```
256+
*
257+
* @param amount - amount in base units (e.g. MIST for SUI)
258+
* @param type - coin type string, defaults to `'0x2::sui::SUI'`
259+
*/
260+
withdrawal({ amount, type: coinType = '0x2::sui::SUI' }: { amount: bigint | number; type?: string }): TransactionArgument {
261+
const typeTag = TypeTagSerializer.parseFromStr(coinType, true);
262+
return this.input('object', Inputs.BalanceWithdrawal(amount, typeTag));
263+
}
264+
242265
splitCoins(...args: Parameters<(typeof Transactions)['SplitCoins']>) {
243266
return this.add(Transactions.SplitCoins(...args));
244267
}

modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const CoinBalance = object({
2525
coinType: string(),
2626
coinObjectCount: number(),
2727
totalBalance: number(),
28+
fundsInAddressBalance: optional(number()),
2829
lockedBalance: object({
2930
epochId: optional(number()),
3031
number: optional(number()),

modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function isPureArg(arg: any): arg is PureArg {
6666
* For `Pure` arguments BCS is required. You must encode the values with BCS according
6767
* to the type required by the called function. Pure accepts only serialized values
6868
*/
69-
export type CallArg = PureArg | { Object: ObjectArg };
69+
export type CallArg = PureArg | { Object: ObjectArg } | { BalanceWithdrawal: { amount: bigint | number; type_: TypeTag } };
7070

7171
/**
7272
* Kind of a TypeTag which is represented by a Move type identifier.
@@ -144,6 +144,7 @@ const BCS_SPEC: TypeSchema = {
144144
Pure: [VECTOR, BCS.U8],
145145
Object: 'ObjectArg',
146146
ObjVec: [VECTOR, 'ObjectArg'],
147+
BalanceWithdrawal: 'BalanceWithdrawal',
147148
},
148149
TypeTag: {
149150
bool: null,
@@ -175,6 +176,10 @@ const BCS_SPEC: TypeSchema = {
175176
},
176177
},
177178
structs: {
179+
BalanceWithdrawal: {
180+
amount: BCS.U64,
181+
type_: 'TypeTag',
182+
},
178183
SuiObjectRef: {
179184
objectId: BCS.ADDRESS,
180185
version: BCS.U64,

modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'assert';
22
import { TransactionType, Recipient, BuildTransactionError, BaseKey } from '@bitgo/sdk-core';
3-
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { BaseCoin as CoinConfig, SuiCoin } from '@bitgo/statics';
44
import { SuiTransaction, SuiTransactionType, TokenTransferProgrammableTransaction } from './iface';
55
import { Transaction } from './transaction';
66
import { TransactionBuilder } from './transactionBuilder';
@@ -12,10 +12,19 @@ import {
1212
TransactionBlock as ProgrammingTransactionBlockBuilder,
1313
TransactionArgument,
1414
} from './mystenlab/builder';
15+
import BigNumber from 'bignumber.js';
1516

1617
export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgrammableTransaction> {
1718
protected _recipients: Recipient[];
1819
protected _inputObjects: SuiObjectRef[];
20+
/**
21+
* Balance held in the address balance system for the token being transferred.
22+
* When set, this amount is included in the total available balance.
23+
* At execution time, tx.withdrawal() + 0x2::coin::redeem_funds converts it
24+
* to a Coin<T> that is merged with any coin objects before splitting.
25+
*/
26+
protected _fundsInAddressBalance: BigNumber = new BigNumber(0);
27+
1928
constructor(_coinConfig: Readonly<CoinConfig>) {
2029
super(_coinConfig);
2130
this._transaction = new TokenTransferTransaction(_coinConfig);
@@ -25,6 +34,14 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
2534
return TransactionType.Send;
2635
}
2736

37+
/**
38+
* The full coin type string derived from the coin config (e.g. `0xabc::my_token::MY_TOKEN`).
39+
*/
40+
private get tokenCoinType(): string {
41+
const config = this._coinConfig as SuiCoin;
42+
return `${config.packageId}::${config.module}::${config.symbol}`;
43+
}
44+
2845
/** @inheritdoc */
2946
validateTransaction(transaction: TokenTransferTransaction): void {
3047
if (!transaction.suiTransaction) {
@@ -78,8 +95,23 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
7895
this.gasData(txData.gasData);
7996
const recipients = utils.getRecipients(tx.suiTransaction);
8097
this.send(recipients);
81-
assert(txData.inputObjects);
82-
this.inputObjects(txData.inputObjects);
98+
99+
// Reconstruct fundsInAddressBalance from BalanceWithdrawal input if present.
100+
// After BCS deserialization inputs are CallArg format: { BalanceWithdrawal: {...} }
101+
// During building they are TransactionBlockInput format: { kind:'Input', value: { BalanceWithdrawal: {...} } }
102+
const withdrawalInput = (tx.suiTransaction?.tx?.inputs as any[])?.find(
103+
(input: any) =>
104+
(input !== null && typeof input === 'object' && 'BalanceWithdrawal' in input) ||
105+
(input?.value !== null && typeof input?.value === 'object' && 'BalanceWithdrawal' in (input.value ?? {}))
106+
);
107+
if (withdrawalInput) {
108+
const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal;
109+
this._fundsInAddressBalance = new BigNumber(String(bw.amount));
110+
}
111+
112+
if (txData.inputObjects && txData.inputObjects.length > 0) {
113+
this.inputObjects(txData.inputObjects);
114+
}
83115
}
84116

85117
send(recipients: Recipient[]): this {
@@ -89,11 +121,21 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
89121
}
90122

91123
inputObjects(inputObject: SuiObjectRef[]): this {
92-
this.validateInputObjects(inputObject);
124+
this.validateInputObjectRefs(inputObject);
93125
this._inputObjects = inputObject;
94126
return this;
95127
}
96128

129+
/**
130+
* Set the amount of token funds held in the Sui address balance system for this sender.
131+
*
132+
* @param {string} amount - amount in base units held in address balance
133+
*/
134+
fundsInAddressBalance(amount: string): this {
135+
this._fundsInAddressBalance = new BigNumber(amount);
136+
return this;
137+
}
138+
97139
/**
98140
* Validates all fields are defined correctly
99141
*/
@@ -106,21 +148,37 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
106148
);
107149
assert(this._gasData, new BuildTransactionError('gasData is required before building'));
108150
this.validateGasData(this._gasData);
109-
this.validateInputObjects(this._inputObjects);
110-
}
111151

112-
private validateInputObjects(inputObjects: SuiObjectRef[]): void {
152+
// Must have at least coin objects OR address balance
113153
assert(
114-
inputObjects && inputObjects.length > 0,
115-
new BuildTransactionError('input objects required before building')
154+
(this._inputObjects && this._inputObjects.length > 0) || this._fundsInAddressBalance.gt(0),
155+
new BuildTransactionError('input objects or fundsInAddressBalance required before building')
116156
);
117-
inputObjects.forEach((inputObject) => {
118-
this.validateSuiObjectRef(inputObject, 'input object');
119-
});
157+
if (this._inputObjects && this._inputObjects.length > 0) {
158+
this.validateInputObjectRefs(this._inputObjects);
159+
}
160+
}
161+
162+
/** Validates the individual object refs (does not require non-empty array). */
163+
private validateInputObjectRefs(inputObjects: SuiObjectRef[]): void {
164+
if (inputObjects) {
165+
inputObjects.forEach((inputObject) => {
166+
this.validateSuiObjectRef(inputObject, 'input object');
167+
});
168+
}
120169
}
121170

122171
/**
123-
* Build SuiTransaction
172+
* Build SuiTransaction.
173+
*
174+
* Two build paths:
175+
*
176+
* Path A — coin objects only (fundsInAddressBalance = 0):
177+
* MergeCoins(inputObject[0], [inputObject[1..]]) → SplitCoins → TransferObjects
178+
*
179+
* Path B — coin objects + address balance (or address balance only):
180+
* MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin<T>
181+
* MergeCoins(inputObject[0] | addrCoin, [rest...]) → SplitCoins → TransferObjects
124182
*
125183
* @return {SuiTransaction<TokenTransferProgrammableTransaction>}
126184
* @protected
@@ -130,9 +188,27 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
130188

131189
const programmableTxBuilder = new ProgrammingTransactionBlockBuilder();
132190

133-
const inputObjects = this._inputObjects.map((object) => programmableTxBuilder.object(Inputs.ObjectRef(object)));
134-
const mergedObject = inputObjects.shift() as TransactionArgument;
191+
const inputObjects: TransactionArgument[] = (this._inputObjects ?? []).map((object) =>
192+
programmableTxBuilder.object(Inputs.ObjectRef(object))
193+
);
194+
195+
// If address balance is available, withdraw it as Coin<T> and add to the pool
196+
if (this._fundsInAddressBalance.gt(0)) {
197+
const coinType = this.tokenCoinType;
198+
const [addrCoin] = programmableTxBuilder.moveCall({
199+
target: '0x2::coin::redeem_funds',
200+
typeArguments: [coinType],
201+
arguments: [
202+
programmableTxBuilder.withdrawal({
203+
amount: BigInt(this._fundsInAddressBalance.toFixed()),
204+
type: coinType,
205+
}),
206+
],
207+
});
208+
inputObjects.push(addrCoin);
209+
}
135210

211+
const mergedObject = inputObjects.shift() as TransactionArgument;
136212
if (inputObjects.length > 0) {
137213
programmableTxBuilder.mergeCoins(mergedObject, inputObjects);
138214
}

modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,15 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
181181
return Inputs.Pure(amount, BCS.U64);
182182
}
183183
}
184-
if (input.kind === 'Input' && (input.value.hasOwnProperty('Object') || input.value.hasOwnProperty('Pure'))) {
184+
if (input.hasOwnProperty('BalanceWithdrawal')) {
185+
return input;
186+
}
187+
if (
188+
input.kind === 'Input' &&
189+
(input.value.hasOwnProperty('Object') ||
190+
input.value.hasOwnProperty('Pure') ||
191+
input.value.hasOwnProperty('BalanceWithdrawal'))
192+
) {
185193
return input.value;
186194
}
187195
return Inputs.Pure(input.value, input.type === 'pure' ? BCS.U64 : BCS.ADDRESS);
@@ -221,20 +229,28 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
221229
}
222230

223231
/**
224-
* Extracts the objects that were provided as inputs while building the transaction
232+
* Extracts the objects that were provided as inputs while building the transaction.
233+
* Handles both the simple case (MergeCoins/SplitCoins first) and the address-balance
234+
* case where MoveCall(redeem_funds) may precede MergeCoins/SplitCoins.
225235
* @param tx
226236
* @returns {SuiObjectRef[]} Objects that are inputs for the transaction
227237
*/
228238
private getInputObjectsFromTx(tx: TokenTransferProgrammableTransaction): SuiObjectRef[] {
229239
const inputs = tx.inputs;
230-
const transaction = tx.transactions[0] as SuiTransactionBlockType;
231240

241+
// Scan all transactions to find the first MergeCoins or SplitCoins,
242+
// which holds references to the coin object inputs.
232243
let args: TransactionArgument[] = [];
233-
if (transaction.kind === 'MergeCoins') {
234-
const { destination, sources } = transaction;
235-
args = [destination, ...sources];
236-
} else if (transaction.kind === 'SplitCoins') {
237-
args = [transaction.coin];
244+
for (const txn of tx.transactions) {
245+
const transaction = txn as SuiTransactionBlockType;
246+
if (transaction.kind === 'MergeCoins') {
247+
const { destination, sources } = transaction;
248+
args = [destination, ...sources];
249+
break;
250+
} else if (transaction.kind === 'SplitCoins') {
251+
args = [transaction.coin];
252+
break;
253+
}
238254
}
239255

240256
const inputObjects: SuiObjectRef[] = [];

0 commit comments

Comments
 (0)