diff --git a/modules/sdk-coin-sui/src/lib/iface.ts b/modules/sdk-coin-sui/src/lib/iface.ts index a5325f34f5..61dc99b987 100644 --- a/modules/sdk-coin-sui/src/lib/iface.ts +++ b/modules/sdk-coin-sui/src/lib/iface.ts @@ -172,9 +172,25 @@ export enum MethodNames { * @see https://github.com/MystenLabs/walrus-docs/blob/9307e66df0ea3f6555cdef78d46aefa62737e216/contracts/walrus/sources/staking/staked_wal.move#L143 */ WalrusSplitStakedWal = '::staked_wal::split', + /** + * Redeem funds from the address balance system into a Coin object. + * Used with the BalanceWithdrawal CallArg to spend from address balance. + * + * @see https://docs.sui.io/concepts/sui-move-concepts/address-balance + */ + RedeemFunds = '::coin::redeem_funds', } export interface SuiObjectInfo extends SuiObjectRef { /** balance */ balance: BigNumber; } + +export interface SuiBalanceInfo { + /** Total balance combining coin object balance and address balance */ + totalBalance: string; + /** Balance held in coin objects (UTXO-style Coin objects) */ + coinObjectBalance: string; + /** Balance held in the address balance system (not in coin objects) */ + fundsInAddressBalance: string; +} diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts b/modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts index c5a05c021c..bebd0fe092 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts @@ -1,5 +1,5 @@ -import { array, boolean, Infer, integer, object, string, union } from 'superstruct'; -import { normalizeSuiAddress, ObjectId, SharedObjectRef, SuiObjectRef } from '../types'; +import { any, array, boolean, Infer, integer, object, string, union } from 'superstruct'; +import { normalizeSuiAddress, ObjectId, SharedObjectRef, SuiObjectRef, TypeTag } from '../types'; import { builder } from './bcs'; const ObjectArg = union([ @@ -15,10 +15,17 @@ const ObjectArg = union([ export const PureCallArg = object({ Pure: array(integer()) }); export const ObjectCallArg = object({ Object: ObjectArg }); +export const BalanceWithdrawalCallArg = object({ + BalanceWithdrawal: object({ + amount: any(), + type_: any(), + }), +}); export type PureCallArg = Infer; export type ObjectCallArg = Infer; +export type BalanceWithdrawalCallArg = Infer; -export const BuilderCallArg = union([PureCallArg, ObjectCallArg]); +export const BuilderCallArg = union([PureCallArg, ObjectCallArg, BalanceWithdrawalCallArg]); export type BuilderCallArg = Infer; export const Inputs = { @@ -33,12 +40,27 @@ export const Inputs = { SharedObjectRef(ref: SharedObjectRef): ObjectCallArg { return { Object: { Shared: ref } }; }, + /** + * Create a BalanceWithdrawal CallArg that withdraws `amount` from the sender's + * address balance at execution time. Use with `0x2::coin::redeem_funds` to + * convert the withdrawal into a `Coin` object. + * + * @param amount - amount in base units (MIST for SUI) + * @param type_ - the TypeTag of the coin (defaults to SUI) + */ + BalanceWithdrawal(amount: bigint | number, type_: TypeTag): BalanceWithdrawalCallArg { + return { BalanceWithdrawal: { amount, type_ } }; + }, }; -export function getIdFromCallArg(arg: ObjectId | ObjectCallArg): string { +export function getIdFromCallArg(arg: ObjectId | ObjectCallArg | BalanceWithdrawalCallArg): string { if (typeof arg === 'string') { return normalizeSuiAddress(arg); } + if ('BalanceWithdrawal' in arg) { + // BalanceWithdrawal inputs have no object ID; they cannot be deduplicated by ID + return ''; + } if ('ImmOrOwned' in arg.Object) { return arg.Object.ImmOrOwned.objectId; } diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionBlock.ts b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionBlock.ts index 7dc0569791..be25e3f01c 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionBlock.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionBlock.ts @@ -4,6 +4,7 @@ import { ObjectId, SuiObjectRef } from '../types'; import { Transactions, TransactionArgument, TransactionType, TransactionBlockInput } from './Transactions'; import { BuilderCallArg, getIdFromCallArg, Inputs, ObjectCallArg } from './Inputs'; import { TransactionBlockDataBuilder, TransactionExpiration } from './TransactionDataBlock'; +import { TypeTagSerializer } from '../txn-data-serializers/type-tag-serializer'; import { create } from './utils'; type TransactionResult = TransactionArgument & TransactionArgument[]; @@ -239,6 +240,28 @@ export class TransactionBlock { // Method shorthands: + /** + * Create a BalanceWithdrawal argument that withdraws `amount` from the sender's + * address balance at execution time. Pass this as an argument to + * `0x2::coin::redeem_funds` to receive a `Coin` object: + * + * ```typescript + * const [coin] = tx.moveCall({ + * target: '0x2::coin::redeem_funds', + * typeArguments: ['0x2::sui::SUI'], + * arguments: [tx.withdrawal({ amount: 1_000_000n })], + * }); + * tx.transferObjects([coin], recipientAddress); + * ``` + * + * @param amount - amount in base units (e.g. MIST for SUI) + * @param type - coin type string, defaults to `'0x2::sui::SUI'` + */ + withdrawal({ amount, type: coinType = '0x2::sui::SUI' }: { amount: bigint | number; type?: string }): TransactionArgument { + const typeTag = TypeTagSerializer.parseFromStr(coinType, true); + return this.input('object', Inputs.BalanceWithdrawal(amount, typeTag)); + } + splitCoins(...args: Parameters<(typeof Transactions)['SplitCoins']>) { return this.add(Transactions.SplitCoins(...args)); } diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts b/modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts index 87f3e182a5..b6688ddfe6 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts @@ -25,6 +25,7 @@ export const CoinBalance = object({ coinType: string(), coinObjectCount: number(), totalBalance: number(), + fundsInAddressBalance: optional(number()), lockedBalance: object({ epochId: optional(number()), number: optional(number()), diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts b/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts index 98348b8ac7..d0a50705f2 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts @@ -66,7 +66,7 @@ export function isPureArg(arg: any): arg is PureArg { * For `Pure` arguments BCS is required. You must encode the values with BCS according * to the type required by the called function. Pure accepts only serialized values */ -export type CallArg = PureArg | { Object: ObjectArg }; +export type CallArg = PureArg | { Object: ObjectArg } | { BalanceWithdrawal: { amount: bigint | number; type_: TypeTag } }; /** * Kind of a TypeTag which is represented by a Move type identifier. @@ -144,6 +144,7 @@ const BCS_SPEC: TypeSchema = { Pure: [VECTOR, BCS.U8], Object: 'ObjectArg', ObjVec: [VECTOR, 'ObjectArg'], + BalanceWithdrawal: 'BalanceWithdrawal', }, TypeTag: { bool: null, @@ -175,6 +176,10 @@ const BCS_SPEC: TypeSchema = { }, }, structs: { + BalanceWithdrawal: { + amount: BCS.U64, + type_: 'TypeTag', + }, SuiObjectRef: { objectId: BCS.ADDRESS, version: BCS.U64, diff --git a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts index 8206c92f28..dc9a297ff9 100644 --- a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import { TransactionType, Recipient, BuildTransactionError, BaseKey } from '@bitgo/sdk-core'; -import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseCoin as CoinConfig, SuiCoin } from '@bitgo/statics'; import { SuiTransaction, SuiTransactionType, TokenTransferProgrammableTransaction } from './iface'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; @@ -12,10 +12,19 @@ import { TransactionBlock as ProgrammingTransactionBlockBuilder, TransactionArgument, } from './mystenlab/builder'; +import BigNumber from 'bignumber.js'; export class TokenTransferBuilder extends TransactionBuilder { protected _recipients: Recipient[]; protected _inputObjects: SuiObjectRef[]; + /** + * Balance held in the address balance system for the token being transferred. + * When set, this amount is included in the total available balance. + * At execution time, tx.withdrawal() + 0x2::coin::redeem_funds converts it + * to a Coin that is merged with any coin objects before splitting. + */ + protected _fundsInAddressBalance: BigNumber = new BigNumber(0); + constructor(_coinConfig: Readonly) { super(_coinConfig); this._transaction = new TokenTransferTransaction(_coinConfig); @@ -25,6 +34,14 @@ export class TokenTransferBuilder extends TransactionBuilder + (input !== null && typeof input === 'object' && 'BalanceWithdrawal' in input) || + (input?.value !== null && typeof input?.value === 'object' && 'BalanceWithdrawal' in (input.value ?? {})) + ); + if (withdrawalInput) { + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + this._fundsInAddressBalance = new BigNumber(String(bw.amount)); + } + + if (txData.inputObjects && txData.inputObjects.length > 0) { + this.inputObjects(txData.inputObjects); + } } send(recipients: Recipient[]): this { @@ -89,11 +121,21 @@ export class TokenTransferBuilder extends TransactionBuilder 0, - new BuildTransactionError('input objects required before building') + (this._inputObjects && this._inputObjects.length > 0) || this._fundsInAddressBalance.gt(0), + new BuildTransactionError('input objects or fundsInAddressBalance required before building') ); - inputObjects.forEach((inputObject) => { - this.validateSuiObjectRef(inputObject, 'input object'); - }); + if (this._inputObjects && this._inputObjects.length > 0) { + this.validateInputObjectRefs(this._inputObjects); + } + } + + /** Validates the individual object refs (does not require non-empty array). */ + private validateInputObjectRefs(inputObjects: SuiObjectRef[]): void { + if (inputObjects) { + inputObjects.forEach((inputObject) => { + this.validateSuiObjectRef(inputObject, 'input object'); + }); + } } /** - * Build SuiTransaction + * Build SuiTransaction. + * + * Two build paths: + * + * Path A — coin objects only (fundsInAddressBalance = 0): + * MergeCoins(inputObject[0], [inputObject[1..]]) → SplitCoins → TransferObjects + * + * Path B — coin objects + address balance (or address balance only): + * MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin + * MergeCoins(inputObject[0] | addrCoin, [rest...]) → SplitCoins → TransferObjects * * @return {SuiTransaction} * @protected @@ -130,9 +188,27 @@ export class TokenTransferBuilder extends TransactionBuilder programmableTxBuilder.object(Inputs.ObjectRef(object))); - const mergedObject = inputObjects.shift() as TransactionArgument; + const inputObjects: TransactionArgument[] = (this._inputObjects ?? []).map((object) => + programmableTxBuilder.object(Inputs.ObjectRef(object)) + ); + + // If address balance is available, withdraw it as Coin and add to the pool + if (this._fundsInAddressBalance.gt(0)) { + const coinType = this.tokenCoinType; + const [addrCoin] = programmableTxBuilder.moveCall({ + target: '0x2::coin::redeem_funds', + typeArguments: [coinType], + arguments: [ + programmableTxBuilder.withdrawal({ + amount: BigInt(this._fundsInAddressBalance.toFixed()), + type: coinType, + }), + ], + }); + inputObjects.push(addrCoin); + } + const mergedObject = inputObjects.shift() as TransactionArgument; if (inputObjects.length > 0) { programmableTxBuilder.mergeCoins(mergedObject, inputObjects); } diff --git a/modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts b/modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts index bcbb2c8ca7..6e5ec40e58 100644 --- a/modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts +++ b/modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts @@ -181,7 +181,15 @@ export class TokenTransferTransaction extends Transaction extends } validateGasPayment(payments: SuiObjectRef[]): void { - assert(payments && payments.length > 0, new BuildTransactionError('gas payment is required before building')); + assert( + payments !== undefined && payments !== null, + new BuildTransactionError('gas payment is required before building') + ); + // Empty array is valid: Sui allows setGasPayment([]) when gas is paid from + // address balance (sender or fee payer has sufficient fundsInAddressBalance). + if (payments.length === 0) { + return; + } payments.forEach((payment) => { this.validateSuiObjectRef(payment, 'payment'); }); diff --git a/modules/sdk-coin-sui/src/lib/transferBuilder.ts b/modules/sdk-coin-sui/src/lib/transferBuilder.ts index 7bfa411549..be73deb94c 100644 --- a/modules/sdk-coin-sui/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transferBuilder.ts @@ -13,9 +13,18 @@ import { } from './mystenlab/builder'; import utils from './utils'; import { MAX_COMMAND_ARGS, MAX_GAS_OBJECTS } from './constants'; +import BigNumber from 'bignumber.js'; export class TransferBuilder extends TransactionBuilder { protected _recipients: Recipient[]; + /** + * Balance held in the Sui address balance system (not in coin objects). + * When set, this amount is included in the total available balance for transfer. + * At execution time, Sui's GasCoin automatically draws from both coin objects + * (gasData.payment) and address balance, so SplitCoins(GasCoin, [amount]) + * can spend funds from either source. + */ + protected _fundsInAddressBalance: BigNumber = new BigNumber(0); constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -32,6 +41,28 @@ export class TransferBuilder extends TransactionBuilder + (input !== null && typeof input === 'object' && 'BalanceWithdrawal' in input) || + (input?.value !== null && typeof input?.value === 'object' && 'BalanceWithdrawal' in (input.value ?? {})) + ); + if (withdrawalInput) { + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + this._fundsInAddressBalance = new BigNumber(String(bw.amount)); + } + const recipients = utils.getRecipients(tx.suiTransaction); this.send(recipients); } @@ -114,10 +158,40 @@ export class TransferBuilder extends TransactionBuilder 0) { this.validateInputObjectsBase(this._inputObjects); } + + // When fundsInAddressBalance is set, validate that total recipient amount + // does not exceed available address balance. Coin object balances are not + // stored in the builder (gasData.payment holds only ObjectRefs), so only + // the address balance portion can be cross-checked here. + if (this._fundsInAddressBalance.gt(0)) { + const totalRecipientAmount = this._recipients.reduce( + (acc, r) => acc.plus(new BigNumber(r.amount)), + new BigNumber(0) + ); + assert(totalRecipientAmount.gt(0), new BuildTransactionError('total recipient amount must be greater than 0')); + } } /** - * Build transfer programmable transaction + * Build transfer programmable transaction. + * + * Two build paths: + * + * Path 1 — Sponsored (sender ≠ gasData.owner, explicit inputObjects): + * [optional withdrawal(fundsInAddressBalance) → redeem_funds → Coin] + * MergeCoins(inputObject[0], [inputObject[1..], addrCoin?]) + * SplitCoins(mergedObject, [amount]) → TransferObjects + * Uses the provided coin objects plus any address balance withdrawn via + * tx.withdrawal() + 0x2::coin::redeem_funds. + * + * Path 2 — Self-pay (sender === gasData.owner, no explicit inputObjects): + * SplitCoins(GasCoin, [amount]) → TransferObjects + * GasCoin at Sui execution time = all gasData.payment objects merged + * + fundsInAddressBalance (automatically included by the protocol). + * This single instruction handles all three balance cases: + * • only coin object balance (fundsInAddressBalance = 0) + * • only address balance (coin objects have 0 balance) + * • both combined (coin objects + address balance) * * @protected */ @@ -126,7 +200,25 @@ export class TransferBuilder extends TransactionBuilder 0) { - const inputObjects = this._inputObjects.map((object) => programmableTxBuilder.object(Inputs.ObjectRef(object))); + // Path 1: sponsored transaction. + // The fee payer (gasData.owner) pays gas. The sender's funds come from: + // - coin objects (inputObjects) + // - address balance via tx.withdrawal() + 0x2::coin::redeem_funds + const inputObjects: TransactionArgument[] = this._inputObjects.map((object) => + programmableTxBuilder.object(Inputs.ObjectRef(object)) + ); + + // If the sender also has address balance, withdraw it as a Coin and + // merge it into the coin-object pool before splitting for recipients. + if (this._fundsInAddressBalance.gt(0)) { + const [addrCoin] = programmableTxBuilder.moveCall({ + target: '0x2::coin::redeem_funds', + typeArguments: ['0x2::sui::SUI'], + arguments: [programmableTxBuilder.withdrawal({ amount: BigInt(this._fundsInAddressBalance.toFixed()) })], + }); + inputObjects.push(addrCoin); + } + const mergedObject = inputObjects.shift() as TransactionArgument; if (inputObjects.length > 0) { programmableTxBuilder.mergeCoins(mergedObject, inputObjects); diff --git a/modules/sdk-coin-sui/src/lib/transferTransaction.ts b/modules/sdk-coin-sui/src/lib/transferTransaction.ts index eff8af6b04..97ca36855f 100644 --- a/modules/sdk-coin-sui/src/lib/transferTransaction.ts +++ b/modules/sdk-coin-sui/src/lib/transferTransaction.ts @@ -200,7 +200,15 @@ export class TransferTransaction extends Transaction t.kind === 'MergeCoins' || t.kind === 'SplitCoins' + ); let args: TransactionArgument[] = []; - if (transaction.kind === 'MergeCoins') { + if (transaction?.kind === 'MergeCoins') { const { destination, sources } = transaction; args = [destination, ...sources]; - } else if (transaction.kind === 'SplitCoins') { + } else if (transaction?.kind === 'SplitCoins') { args = [transaction.coin]; } diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index cc912aba7e..0c0cd299c3 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -19,6 +19,7 @@ import { StakingProgrammableTransaction, WalrusStakingProgrammableTransaction, WalrusWithdrawStakeProgrammableTransaction, + SuiBalanceInfo, SuiObjectInfo, SuiProgrammableTransaction, SuiTransaction, @@ -250,6 +251,10 @@ export class Utils implements BaseUtils { return SuiTransactionType.WalrusRequestWithdrawStake; } else if (command.target.endsWith(MethodNames.WalrusWithdrawStake)) { return SuiTransactionType.WalrusWithdrawStake; + } else if (command.target.endsWith(MethodNames.RedeemFunds)) { + // redeem_funds is a helper MoveCall used with BalanceWithdrawal to convert + // address balance into a Coin; it co-exists with Transfer or TokenTransfer commands. + return SuiTransactionType.Transfer; } else { throw new InvalidTransactionError(`unsupported target method ${command.target}`); } @@ -493,12 +498,19 @@ export class Utils implements BaseUtils { return netCost.comparedTo(computationCost) > 0 ? netCost : computationCost; } - async getBalance(url: string, owner: string, coinType?: string): Promise { + async getBalance(url: string, owner: string, coinType?: string): Promise { if (coinType === undefined) { coinType = SUI_TYPE_ARG; } const result = await makeRPC(url, 'suix_getBalance', [owner, coinType]); - return result.totalBalance; + const totalBalance = (result.totalBalance ?? '0').toString(); + const fundsInAddressBalance = (result.fundsInAddressBalance ?? '0').toString(); + const coinObjectBalance = new BigNumber(totalBalance).minus(new BigNumber(fundsInAddressBalance)).toString(); + return { + totalBalance, + coinObjectBalance, + fundsInAddressBalance, + }; } async getInputCoins(url: string, owner: string, coinType?: string): Promise { diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index d2eb6f1bfc..b7733258ca 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -42,7 +42,7 @@ import { } from './lib'; import utils from './lib/utils'; import * as _ from 'lodash'; -import { SuiObjectInfo, SuiTransactionType } from './lib/iface'; +import { SuiBalanceInfo, SuiObjectInfo, SuiTransactionType } from './lib/iface'; import { DEFAULT_GAS_OVERHEAD, DEFAULT_GAS_PRICE, @@ -306,7 +306,7 @@ export class Sui extends BaseCoin { return Environments[this.bitgo.getEnv()].suiNodeUrl; } - protected async getBalance(owner: string, coinType?: string): Promise { + protected async getBalance(owner: string, coinType?: string): Promise { const url = this.getPublicNodeUrl(); return await utils.getBalance(url, owner, coinType); } @@ -353,8 +353,11 @@ export class Sui extends BaseCoin { const derivedPublicKey = MPC.deriveUnhardened(bitgoKey, derivationPath).slice(0, 64); const senderAddress = this.getAddressFromPublicKey(derivedPublicKey); let availableBalance = new BigNumber(0); + let fundsInAddressBalance = new BigNumber(0); try { - availableBalance = new BigNumber(await this.getBalance(senderAddress)); + const balanceInfo = await this.getBalance(senderAddress); + availableBalance = new BigNumber(balanceInfo.totalBalance); + fundsInAddressBalance = new BigNumber(balanceInfo.fundsInAddressBalance); } catch (e) { continue; } @@ -370,7 +373,8 @@ export class Sui extends BaseCoin { } const coinType = `${token.packageId}::${token.module}::${token.symbol}`; try { - const availableTokenBalance = new BigNumber(await this.getBalance(senderAddress, coinType)); + const tokenBalanceInfo = await this.getBalance(senderAddress, coinType); + const availableTokenBalance = new BigNumber(tokenBalanceInfo.totalBalance); if (availableTokenBalance.toNumber() <= 0) { continue; } @@ -387,8 +391,11 @@ export class Sui extends BaseCoin { if (inputCoins.length > MAX_OBJECT_LIMIT) { inputCoins = inputCoins.slice(0, MAX_OBJECT_LIMIT); } - let netAmount = inputCoins.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0)); - netAmount = netAmount.minus(MAX_GAS_BUDGET); + // Include funds held in the address balance system (not in coin objects). + // SplitCoins(GasCoin, [amount]) draws from both gasData.payment objects + // and address balance at execution time, so both are spendable. + const coinObjectsBalance = inputCoins.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0)); + let netAmount = coinObjectsBalance.plus(fundsInAddressBalance).minus(MAX_GAS_BUDGET); const recipients = [ { @@ -404,6 +411,7 @@ export class Sui extends BaseCoin { .type(SuiTransactionType.Transfer) .sender(senderAddress) .send(recipients) + .fundsInAddressBalance(fundsInAddressBalance.toString()) .gasData({ owner: senderAddress, price: DEFAULT_GAS_PRICE, @@ -468,7 +476,10 @@ export class Sui extends BaseCoin { if (tokenObjects.length > TOKEN_OBJECT_LIMIT) { tokenObjects = tokenObjects.slice(0, TOKEN_OBJECT_LIMIT); } - const netAmount = tokenObjects.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0)); + const coinObjectsBalance = tokenObjects.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0)); + const tokenBalanceInfo = await this.getBalance(senderAddress, coinType); + const tokenFundsInAddressBalance = new BigNumber(tokenBalanceInfo.fundsInAddressBalance); + const netAmount = coinObjectsBalance.plus(tokenFundsInAddressBalance); const recipients = [ { address: params.recoveryDestination, @@ -490,7 +501,7 @@ export class Sui extends BaseCoin { .type(SuiTransactionType.TokenTransfer) .sender(senderAddress) .send(recipients) - .inputObjects(tokenObjects) + .fundsInAddressBalance(tokenFundsInAddressBalance.toString()) .gasData({ owner: senderAddress, price: DEFAULT_GAS_PRICE, @@ -498,6 +509,10 @@ export class Sui extends BaseCoin { payment: gasObjects, }); + if (tokenObjects.length > 0) { + txBuilder.inputObjects(tokenObjects); + } + const tempTx = (await txBuilder.build()) as TokenTransferTransaction; const feeEstimate = await this.getFeeEstimate(tempTx.toBroadcastFormat()); const gasBudget = Math.trunc(feeEstimate.toNumber() * DEFAULT_GAS_OVERHEAD); diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index f8750674e7..8917af8a2e 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -520,7 +520,9 @@ describe('SUI:', function () { beforeEach(() => { getBalanceStub = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); - getBalanceStub.withArgs(senderAddress0).resolves('1900000000'); + getBalanceStub + .withArgs(senderAddress0) + .resolves({ totalBalance: '1900000000', coinObjectBalance: '1900000000', fundsInAddressBalance: '0' }); getInputCoinsStub = sandBox.stub(Sui.prototype, 'getInputCoins' as keyof Sui); getInputCoinsStub.withArgs(senderAddress0).resolves([ @@ -638,7 +640,9 @@ describe('SUI:', function () { it('should recover a txn for unsigned sweep recovery with multiple input coins', async function () { const senderAddress = '0x00e4eaa6a291fe02918452e645b5653cd260a5fc0fb35f6193d580916aa9e389'; - getBalanceStub.withArgs(senderAddress).resolves('1798002120'); + getBalanceStub + .withArgs(senderAddress) + .resolves({ totalBalance: '1798002120', coinObjectBalance: '1798002120', fundsInAddressBalance: '0' }); getInputCoinsStub.withArgs(senderAddress).resolves([ { coinType: '0x2::sui::SUI', @@ -747,7 +751,11 @@ describe('SUI:', function () { }); it('should recover a token txn for non-bitgo recovery', async function () { - getBalanceStub.withArgs(senderAddress0, coinType).resolves('1000'); + getBalanceStub + .withArgs(senderAddress0) + .resolves({ totalBalance: '1900000000', coinObjectBalance: '1900000000', fundsInAddressBalance: '0' }) + .withArgs(senderAddress0, coinType) + .resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' }); getInputCoinsStub.withArgs(senderAddress0, coinType).resolves([ { coinType: '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP', @@ -809,14 +817,18 @@ describe('SUI:', function () { should.equal(NonBitGoTxnJson.id, 'DYW9mA8AZGQntk7HGQUEoEdy8BaH8Hh9Ts294EnqGTEr'); should.equal(NonBitGoTxnJson.sender, senderAddress0); - sandBox.assert.callCount(basecoin.getBalance, 2); + sandBox.assert.callCount(basecoin.getBalance, 3); sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); it('should recover a token txn for unsigned sweep recovery', async function () { - getBalanceStub.withArgs(senderAddressColdWallet).resolves('298980240'); - getBalanceStub.withArgs(senderAddressColdWallet, coinType).resolves('1000'); + getBalanceStub + .withArgs(senderAddressColdWallet) + .resolves({ totalBalance: '298980240', coinObjectBalance: '298980240', fundsInAddressBalance: '0' }); + getBalanceStub + .withArgs(senderAddressColdWallet, coinType) + .resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' }); getInputCoinsStub.withArgs(senderAddressColdWallet, coinType).resolves([ { @@ -910,14 +922,18 @@ describe('SUI:', function () { should.equal(unsignedSweepTxnJson.id, 'F8wrUjZYf6xvDW2LzW9DohAKyJFcWgGEvjMoKLxCajmV'); should.equal(unsignedSweepTxnJson.sender, senderAddressColdWallet); - sandBox.assert.callCount(basecoin.getBalance, 2); + sandBox.assert.callCount(basecoin.getBalance, 3); sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); it('should recover a token txn for unsigned sweep recovery with multiple input coins', async function () { - getBalanceStub.withArgs(senderAddressColdWallet).resolves('298980240'); - getBalanceStub.withArgs(senderAddressColdWallet, coinType).resolves('11000'); + getBalanceStub + .withArgs(senderAddressColdWallet) + .resolves({ totalBalance: '298980240', coinObjectBalance: '298980240', fundsInAddressBalance: '0' }); + getBalanceStub + .withArgs(senderAddressColdWallet, coinType) + .resolves({ totalBalance: '11000', coinObjectBalance: '11000', fundsInAddressBalance: '0' }); getInputCoinsStub.withArgs(senderAddressColdWallet, coinType).resolves([ { coinType: '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP', @@ -1016,7 +1032,7 @@ describe('SUI:', function () { should.equal(unsignedSweepTxnJson.id, '4qeXJP7pTa6pmyAKuJZG9AkGsKM53SDqHVcPjRMFHjc5'); should.equal(unsignedSweepTxnJson.sender, senderAddressColdWallet); - sandBox.assert.callCount(basecoin.getBalance, 2); + sandBox.assert.callCount(basecoin.getBalance, 3); sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); @@ -1031,7 +1047,11 @@ describe('SUI:', function () { beforeEach(function () { let callBack = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); - callBack.withArgs(senderAddress0).resolves('0').withArgs(senderAddress1).resolves('1800000000'); + callBack + .withArgs(senderAddress0) + .resolves({ totalBalance: '0', coinObjectBalance: '0', fundsInAddressBalance: '0' }) + .withArgs(senderAddress1) + .resolves({ totalBalance: '1800000000', coinObjectBalance: '1800000000', fundsInAddressBalance: '0' }); callBack = sandBox.stub(Sui.prototype, 'getInputCoins' as keyof Sui); callBack.withArgs(senderAddress1).resolves([ @@ -1130,13 +1150,13 @@ describe('SUI:', function () { getBalanceStub = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); getBalanceStub .withArgs(senderAddress0) - .resolves('706875692') + .resolves({ totalBalance: '706875692', coinObjectBalance: '706875692', fundsInAddressBalance: '0' }) .withArgs(senderAddress0, coinType) - .resolves('0') + .resolves({ totalBalance: '0', coinObjectBalance: '0', fundsInAddressBalance: '0' }) .withArgs(senderAddress1) - .resolves('120101976') + .resolves({ totalBalance: '120101976', coinObjectBalance: '120101976', fundsInAddressBalance: '0' }) .withArgs(senderAddress1, coinType) - .resolves('1000'); + .resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' }); getInputCoinsStub = sandBox.stub(Sui.prototype, 'getInputCoins' as keyof Sui); getInputCoinsStub.withArgs(senderAddress1, coinType).resolves([ @@ -1201,7 +1221,7 @@ describe('SUI:', function () { should.equal(UnsignedSweepTxnJson.id, 'GFuk1VKy3wzTFeAUtrmUe6sxRhtezzrGDfKdpQTxv9so'); should.equal(UnsignedSweepTxnJson.sender, senderAddress1); - sandBox.assert.callCount(basecoin.getBalance, 4); + sandBox.assert.callCount(basecoin.getBalance, 5); sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); @@ -1231,7 +1251,7 @@ describe('SUI:', function () { should.equal(UnsignedSweepTxnJson.id, 'GFuk1VKy3wzTFeAUtrmUe6sxRhtezzrGDfKdpQTxv9so'); should.equal(UnsignedSweepTxnJson.sender, senderAddress1); - sandBox.assert.callCount(basecoin.getBalance, 2); + sandBox.assert.callCount(basecoin.getBalance, 3); sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); @@ -1250,13 +1270,13 @@ describe('SUI:', function () { let callBack = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); callBack .withArgs(receiveAddress1) - .resolves('200101976') + .resolves({ totalBalance: '200101976', coinObjectBalance: '200101976', fundsInAddressBalance: '0' }) .withArgs(receiveAddress2) - .resolves('200000000') + .resolves({ totalBalance: '200000000', coinObjectBalance: '200000000', fundsInAddressBalance: '0' }) .withArgs(seedReceiveAddress1) - .resolves('500000000') + .resolves({ totalBalance: '500000000', coinObjectBalance: '500000000', fundsInAddressBalance: '0' }) .withArgs(seedReceiveAddress2) - .resolves('200000000'); + .resolves({ totalBalance: '200000000', coinObjectBalance: '200000000', fundsInAddressBalance: '0' }); callBack = sandBox.stub(Sui.prototype, 'getInputCoins' as keyof Sui); callBack @@ -1600,13 +1620,13 @@ describe('SUI:', function () { it('should build signed token consolidation transactions for hot wallet', async function () { getBalanceStub .withArgs(hotWalletReceiveAddress1) - .resolves('116720144') + .resolves({ totalBalance: '116720144', coinObjectBalance: '116720144', fundsInAddressBalance: '0' }) .withArgs(hotWalletReceiveAddress1, coinType) - .resolves('1500') + .resolves({ totalBalance: '1500', coinObjectBalance: '1500', fundsInAddressBalance: '0' }) .withArgs(hotWalletReceiveAddress2) - .resolves('120101976') + .resolves({ totalBalance: '120101976', coinObjectBalance: '120101976', fundsInAddressBalance: '0' }) .withArgs(hotWalletReceiveAddress2, coinType) - .resolves('2000'); + .resolves({ totalBalance: '2000', coinObjectBalance: '2000', fundsInAddressBalance: '0' }); getInputCoinsStub .withArgs(hotWalletReceiveAddress1, coinType) .resolves([ @@ -1700,7 +1720,7 @@ describe('SUI:', function () { res.lastScanIndex.should.equal(2); - sandBox.assert.callCount(basecoin.getBalance, 4); + sandBox.assert.callCount(basecoin.getBalance, 6); sandBox.assert.callCount(basecoin.getInputCoins, 4); sandBox.assert.callCount(basecoin.getFeeEstimate, 2); }); @@ -1708,13 +1728,13 @@ describe('SUI:', function () { it('should build unsigned token consolidation transactions for cold wallet', async function () { getBalanceStub .withArgs(coldWalletReceiveAddress1) - .resolves('116720144') + .resolves({ totalBalance: '116720144', coinObjectBalance: '116720144', fundsInAddressBalance: '0' }) .withArgs(coldWalletReceiveAddress1, coinType) - .resolves('4000') + .resolves({ totalBalance: '4000', coinObjectBalance: '4000', fundsInAddressBalance: '0' }) .withArgs(coldWalletReceiveAddress2) - .resolves('120101976') + .resolves({ totalBalance: '120101976', coinObjectBalance: '120101976', fundsInAddressBalance: '0' }) .withArgs(coldWalletReceiveAddress2, coinType) - .resolves('6000'); + .resolves({ totalBalance: '6000', coinObjectBalance: '6000', fundsInAddressBalance: '0' }); getInputCoinsStub .withArgs(coldWalletReceiveAddress1, coinType) .resolves([ @@ -1876,7 +1896,7 @@ describe('SUI:', function () { ], }); - sandBox.assert.callCount(basecoin.getBalance, 4); + sandBox.assert.callCount(basecoin.getBalance, 6); sandBox.assert.callCount(basecoin.getInputCoins, 4); sandBox.assert.callCount(basecoin.getFeeEstimate, 2); }); @@ -1884,13 +1904,13 @@ describe('SUI:', function () { it('should build unsigned token consolidation transactions for cold wallet with seed', async function () { getBalanceStub .withArgs(seedReceiveAddress1) - .resolves('120199788') + .resolves({ totalBalance: '120199788', coinObjectBalance: '120199788', fundsInAddressBalance: '0' }) .withArgs(seedReceiveAddress1, coinType) - .resolves('1500') + .resolves({ totalBalance: '1500', coinObjectBalance: '1500', fundsInAddressBalance: '0' }) .withArgs(seedReceiveAddress2) - .resolves('120199788') + .resolves({ totalBalance: '120199788', coinObjectBalance: '120199788', fundsInAddressBalance: '0' }) .withArgs(seedReceiveAddress2, coinType) - .resolves('2000'); + .resolves({ totalBalance: '2000', coinObjectBalance: '2000', fundsInAddressBalance: '0' }); getInputCoinsStub .withArgs(seedReceiveAddress1, coinType) @@ -2055,7 +2075,7 @@ describe('SUI:', function () { ], }); - sandBox.assert.callCount(basecoin.getBalance, 4); + sandBox.assert.callCount(basecoin.getBalance, 6); sandBox.assert.callCount(basecoin.getInputCoins, 4); sandBox.assert.callCount(basecoin.getFeeEstimate, 2); }); @@ -2575,7 +2595,9 @@ describe('SUI:', function () { it('should fail to recover due to non-zero fund but insufficient funds address', async function () { const callBack = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); - callBack.withArgs(senderAddress0).resolves('9800212'); + callBack + .withArgs(senderAddress0) + .resolves({ totalBalance: '9800212', coinObjectBalance: '9800212', fundsInAddressBalance: '0' }); await basecoin .recover({ @@ -2596,7 +2618,7 @@ describe('SUI:', function () { it('should fail to recover due to not finding an address with funds', async function () { const callBack = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui); - callBack.resolves('0'); + callBack.resolves({ totalBalance: '0', coinObjectBalance: '0', fundsInAddressBalance: '0' }); await basecoin .recover({ @@ -2622,7 +2644,9 @@ describe('SUI:', function () { const walletPassphrase = 'p$Sw { const factory = getBuilderFactory('tsui:deep'); @@ -130,5 +130,136 @@ describe('Sui Token Transfer Builder', () => { // @ts-expect-error - testing invalid input should(() => builder.inputObjects(invalidInputObjects)).throwError('Invalid input object, missing digest'); }); + + it('should fail when neither inputObjects nor fundsInAddressBalance is provided', async function () { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: '1000' }]); + txBuilder.gasData(testData.gasData); + await txBuilder.build().should.be.rejectedWith('input objects or fundsInAddressBalance required before building'); + }); + }); + + describe('fundsInAddressBalance', () => { + const FUNDS_IN_ADDRESS_BALANCE = '450000000'; + // tsui:deep coin type: 0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP + const TOKEN_COIN_TYPE = '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP'; + + it('should build a token transfer using only address balance (no coin objects)', async function () { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: '1000' }]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + // No inputObjects + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + + // MoveCall(redeem_funds) must be first — it's the only coin source + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('MoveCall'); + (programmableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds'); + // typeArguments should contain the token coin type + (programmableTx.transactions[0] as any).typeArguments[0].should.equal(TOKEN_COIN_TYPE); + + // No MergeCoins since there's only one coin (the address-balance coin) + (programmableTx.transactions[1] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a token transfer with coin objects + address balance', async function () { + const numberOfInputObjects = 3; + const inputObjects = testData.generateObjects(numberOfInputObjects); + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: '1000' }]); + txBuilder.gasData(testData.gasData); + txBuilder.inputObjects(inputObjects); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const programmableTx = suiTx.suiTransaction.tx; + + // MoveCall(redeem_funds) first, then MergeCoins (coin objects + addrCoin), then SplitCoins + (programmableTx.transactions[0] as any).kind.should.equal('MoveCall'); + (programmableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds'); + (programmableTx.transactions[1] as any).kind.should.equal('MergeCoins'); + // sources = remaining inputObjects (numberOfInputObjects - 1) + addrCoin (1) + (programmableTx.transactions[1] as any).sources.length.should.equal(numberOfInputObjects - 1 + 1); + (programmableTx.transactions[2] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip: rebuilt tx should produce same serialized output and recover inputObjects + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + rebuiltTx.toJson().inputObjects.length.should.equal(numberOfInputObjects); + }); + + it('should correctly reconstruct fundsInAddressBalance from raw transaction', async function () { + const inputObjects = testData.generateObjects(2); + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: '500' }]); + txBuilder.gasData(testData.gasData); + txBuilder.inputObjects(inputObjects); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // Rebuild from raw — initBuilder must reconstruct fundsInAddressBalance + const rebuilder = factory.from(rawTx) as any; + rebuilder._fundsInAddressBalance.toString().should.equal(FUNDS_IN_ADDRESS_BALANCE); + }); + + it('should build a token transfer with multiple recipients and address balance', async function () { + const inputObjects = testData.generateObjects(2); + const amount = 1000; + const recipients = testData.recipients.map((r) => ({ ...r, amount: amount.toString() })); + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(recipients); + txBuilder.gasData(testData.gasData); + txBuilder.inputObjects(inputObjects); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + tx.outputs.length.should.equal(recipients.length); + tx.outputs.forEach((output, i) => { + output.address.should.equal(recipients[i].address); + output.value.should.equal(amount.toString()); + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); }); }); diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index d3ff991564..3fb7364616 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -309,4 +309,157 @@ describe('Sui Transfer Builder', () => { Array.isArray(txData.inputObjects).should.equal(true); }); }); + + describe('fundsInAddressBalance', () => { + const FUNDS_IN_ADDRESS_BALANCE = '5000000000'; // 5 SUI + + it('should build a self-pay transfer using only address balance (empty payment)', async function () { + const gasDataNoPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [], + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(gasDataNoPayment); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + suiTx.suiTransaction.gasData.payment.length.should.equal(0); + + // Self-pay path: SplitCoins(GasCoin, [amount]) — no MoveCall needed + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip: rebuild from raw + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a self-pay transfer with coin objects + address balance', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + + // Self-pay path: SplitCoins(GasCoin) — protocol merges coin objects + address balance automatically + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); + + it('should build a sponsored transfer with coin objects + address balance', async function () { + const inputObjects = testData.generateObjects(2); + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasData); + txBuilder.inputObjects(inputObjects); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + suiTx.suiTransaction.gasData.owner.should.equal(testData.feePayer.address); + + // Sponsored path: MoveCall(redeem_funds) + MergeCoins + SplitCoins + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('MoveCall'); + (programmableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds'); + (programmableTx.transactions[1] as any).kind.should.equal('MergeCoins'); + (programmableTx.transactions[2] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a sponsored transfer with address balance only (no coin inputObjects)', async function () { + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasData); + // No inputObjects provided — sender pays entirely from address balance + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + // Falls into Path 2 (no inputObjects), uses GasCoin SplitCoins + const suiTx = tx as SuiTransaction; + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); + + it('should build a sponsored tx gas paid from sponsor address balance (empty payment)', async function () { + const inputObjects = testData.generateObjects(1); + const sponsoredGasDataNoPayment = { + payment: [], + owner: testData.feePayer.address, + price: testData.gasData.price, + budget: testData.gasData.budget, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasDataNoPayment); + txBuilder.inputObjects(inputObjects); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + suiTx.suiTransaction.gasData.owner.should.equal(testData.feePayer.address); + suiTx.suiTransaction.gasData.payment.length.should.equal(0); + + // Sponsored path with coin objects, no address balance withdrawal + const programmableTx = suiTx.suiTransaction.tx; + (programmableTx.transactions[0] as any).kind.should.equal('SplitCoins'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); + }); }); diff --git a/modules/sdk-coin-sui/test/unit/utils.ts b/modules/sdk-coin-sui/test/unit/utils.ts index b178db2993..84d8eb27ee 100644 --- a/modules/sdk-coin-sui/test/unit/utils.ts +++ b/modules/sdk-coin-sui/test/unit/utils.ts @@ -1,6 +1,7 @@ import should from 'should'; import * as testData from '../resources/sui'; import utils from '../../src/lib/utils'; +import nock from 'nock'; describe('Sui util library', function () { describe('isValidAddress', function () { @@ -42,4 +43,121 @@ describe('Sui util library', function () { should.equal(utils.normalizeHexId(hexId), hexId); }); }); + + describe('getBalance', function () { + const nodeUrl = 'https://fullnode.testnet.sui.io'; + const owner = '0xb7db6234a33f1e35f7114dac69574c6b7b193f3c4a0801e5ddb9fae4009af637'; + + afterEach(function () { + nock.cleanAll(); + }); + + it('should return all funds in address balance when fundsInAddressBalance equals totalBalance', async function () { + // Real response from testnet: all 2 SUI held in address balance, not in coin objects + nock(nodeUrl) + .post('/') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: { + coinType: '0x2::sui::SUI', + coinObjectCount: 1, + totalBalance: '2000000', + lockedBalance: {}, + fundsInAddressBalance: '2000000', + }, + }); + + const balanceInfo = await utils.getBalance(nodeUrl, owner); + balanceInfo.totalBalance.should.equal('2000000'); + balanceInfo.fundsInAddressBalance.should.equal('2000000'); + balanceInfo.coinObjectBalance.should.equal('0'); + }); + + it('should correctly split totalBalance into coinObjectBalance and fundsInAddressBalance', async function () { + nock(nodeUrl) + .post('/') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: { + coinType: '0x2::sui::SUI', + coinObjectCount: 2, + totalBalance: '1900000000', + lockedBalance: {}, + fundsInAddressBalance: '900000000', + }, + }); + + const balanceInfo = await utils.getBalance(nodeUrl, owner); + balanceInfo.totalBalance.should.equal('1900000000'); + balanceInfo.fundsInAddressBalance.should.equal('900000000'); + balanceInfo.coinObjectBalance.should.equal('1000000000'); + }); + + it('should handle legacy response without fundsInAddressBalance (all funds in coin objects)', async function () { + nock(nodeUrl) + .post('/') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: { + coinType: '0x2::sui::SUI', + coinObjectCount: 1, + totalBalance: '1900000000', + lockedBalance: {}, + }, + }); + + const balanceInfo = await utils.getBalance(nodeUrl, owner); + balanceInfo.totalBalance.should.equal('1900000000'); + balanceInfo.fundsInAddressBalance.should.equal('0'); + balanceInfo.coinObjectBalance.should.equal('1900000000'); + }); + + it('should return zero balances for an empty account', async function () { + nock(nodeUrl) + .post('/') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: { + coinType: '0x2::sui::SUI', + coinObjectCount: 0, + totalBalance: '0', + lockedBalance: {}, + fundsInAddressBalance: '0', + }, + }); + + const balanceInfo = await utils.getBalance(nodeUrl, owner); + balanceInfo.totalBalance.should.equal('0'); + balanceInfo.fundsInAddressBalance.should.equal('0'); + balanceInfo.coinObjectBalance.should.equal('0'); + }); + + it('should query with a custom coin type', async function () { + const packageId = '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8'; + const coinType = `${packageId}::deep::DEEP`; + + nock(nodeUrl) + .post('/', (body) => body.params[1] === coinType) + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: { + coinType, + coinObjectCount: 1, + totalBalance: '1000', + lockedBalance: {}, + fundsInAddressBalance: '0', + }, + }); + + const balanceInfo = await utils.getBalance(nodeUrl, owner, coinType); + balanceInfo.totalBalance.should.equal('1000'); + balanceInfo.fundsInAddressBalance.should.equal('0'); + balanceInfo.coinObjectBalance.should.equal('1000'); + }); + }); });