From 5e9a6d0b2c2ca72691cfcfd9b6d55fb29f4fcb3b Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Mon, 23 Mar 2026 14:45:12 +0530 Subject: [PATCH] feat(sdk-coin-sui): update balance querying to handle fundsInAddressBalance field TICKET: CSHLD-407 --- modules/sdk-coin-sui/src/lib/iface.ts | 19 ++ .../src/lib/mystenlab/builder/Inputs.ts | 30 +++- .../lib/mystenlab/builder/TransactionBlock.ts | 23 +++ .../mystenlab/builder/TransactionDataBlock.ts | 8 +- .../src/lib/mystenlab/types/coin.ts | 1 + .../src/lib/mystenlab/types/sui-bcs.ts | 28 ++- .../src/lib/tokenTransferBuilder.ts | 114 ++++++++++-- .../src/lib/tokenTransferTransaction.ts | 36 ++-- modules/sdk-coin-sui/src/lib/transaction.ts | 33 +++- .../src/lib/transactionBuilder.ts | 22 ++- .../sdk-coin-sui/src/lib/transferBuilder.ts | 134 ++++++++++++++- .../src/lib/transferTransaction.ts | 28 ++- modules/sdk-coin-sui/src/lib/utils.ts | 29 +++- modules/sdk-coin-sui/src/sui.ts | 49 +++++- modules/sdk-coin-sui/test/unit/sui.ts | 102 ++++++----- .../tokenTransferBuilder.ts | 133 +++++++++++++- .../transactionBuilder/transferBuilder.ts | 162 ++++++++++++++++++ modules/sdk-coin-sui/test/unit/utils.ts | 118 +++++++++++++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 6 + 19 files changed, 979 insertions(+), 96 deletions(-) diff --git a/modules/sdk-coin-sui/src/lib/iface.ts b/modules/sdk-coin-sui/src/lib/iface.ts index a5325f34f5..ec97b88b2a 100644 --- a/modules/sdk-coin-sui/src/lib/iface.ts +++ b/modules/sdk-coin-sui/src/lib/iface.ts @@ -46,6 +46,7 @@ export interface TxData { ProgrammableTransaction: SuiProgrammableTransaction; }; inputObjects?: SuiObjectRef[]; + fundsInAddressBalance?: string; } export type TransferProgrammableTransaction = @@ -103,7 +104,9 @@ export interface SuiTransaction { sender: string; tx: T; gasData: GasData; + expiration?: TransactionExpiration; inputObjects?: SuiObjectRef[]; + fundsInAddressBalance?: string; } export interface RequestAddStake { @@ -172,9 +175,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/builder/TransactionDataBlock.ts b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts index 5e22c7bf00..1bb90d77ba 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts @@ -21,7 +21,13 @@ import { BuilderCallArg, PureCallArg } from './Inputs'; import { create } from './utils'; export const TransactionExpiration = optional( - nullable(union([object({ Epoch: integer() }), object({ None: union([literal(true), literal(null)]) })])) + nullable( + union([ + object({ Epoch: integer() }), + object({ None: union([literal(true), literal(null)]) }), + object({ ValidDuring: object({ minEpoch: integer(), maxEpoch: integer(), chain: string(), nonce: integer() }) }), + ]) + ) ); export type TransactionExpiration = Infer; 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..9a58c65642 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. @@ -106,12 +106,24 @@ export type GasData = { budget: number; }; +/** + * ValidDuring expiration — used when gasData.payment is empty (address-balance-funded gas). + * Both minEpoch and maxEpoch must be set; maxEpoch must equal minEpoch or minEpoch + 1. + * The nonce (u32) prevents duplicate transaction digests across same-epoch builds. + */ +export type ValidDuringExpiration = { + minEpoch: number; + maxEpoch: number; + chain: string; + nonce: number; +}; + /** * TransactionExpiration * * Indications the expiration time for a transaction. */ -export type TransactionExpiration = { None: null } | { Epoch: number }; +export type TransactionExpiration = { None: null } | { Epoch: number } | { ValidDuring: ValidDuringExpiration }; // Move name of the Vector type. const VECTOR = 'vector'; @@ -144,6 +156,7 @@ const BCS_SPEC: TypeSchema = { Pure: [VECTOR, BCS.U8], Object: 'ObjectArg', ObjVec: [VECTOR, 'ObjectArg'], + BalanceWithdrawal: 'BalanceWithdrawal', }, TypeTag: { bool: null, @@ -169,12 +182,23 @@ const BCS_SPEC: TypeSchema = { TransactionExpiration: { None: null, Epoch: BCS.U64, + ValidDuring: 'ValidDuringExpiration', }, TransactionData: { V1: 'TransactionDataV1', }, }, structs: { + BalanceWithdrawal: { + amount: BCS.U64, + type_: 'TypeTag', + }, + ValidDuringExpiration: { + minEpoch: BCS.U64, + maxEpoch: BCS.U64, + chain: BCS.STRING, + nonce: BCS.U32, + }, 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..da9e2e6e6f 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 +127,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 +194,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); } @@ -155,6 +237,8 @@ export class TokenTransferBuilder extends TransactionBuilder extends BaseTransaction { price: Number(transactionBlock.gasConfig.price as string), budget: Number(transactionBlock.gasConfig.budget as string), }, + // Normalize None expiration (the BCS default) to undefined so that pre-build + // and post-deserialization SuiTransaction objects compare equal. + expiration: + transactionBlock.expiration && !('None' in transactionBlock.expiration) + ? (transactionBlock.expiration as TransactionExpiration) + : undefined, }; } @@ -245,6 +257,23 @@ export abstract class Transaction extends BaseTransaction { if (transactions.some((tx) => utils.getSuiTransactionType(tx) === SuiTransactionType.WithdrawStake)) { return SuiTransactionType.WithdrawStake; } + // Sponsored Transfer with fundsInAddressBalance uses a redeem_funds MoveCall alongside + // MergeCoins/SplitCoins on non-GasCoin objects, which would otherwise be misclassified + // as TokenTransfer. Distinguish from TokenTransfer+fundsInAddressBalance by checking + // that the redeem_funds typeArgument is the native SUI coin type (0x2::sui::SUI). + const suiNativeAddress = normalizeSuiAddress('0x2'); + if ( + transactions.some((tx) => { + if (tx.kind !== 'MoveCall') return false; + const moveCall = tx as any; + if (!moveCall.target?.endsWith(MethodNames.RedeemFunds)) return false; + const typeArg: string = moveCall.typeArguments?.[0] ?? ''; + const [addr = '', mod = '', name = ''] = typeArg.split('::'); + return normalizeSuiAddress(addr) === suiNativeAddress && mod === 'sui' && name === 'SUI'; + }) + ) { + return SuiTransactionType.Transfer; + } if (transactions.some((tx) => utils.getSuiTransactionType(tx) === SuiTransactionType.TokenTransfer)) { return SuiTransactionType.TokenTransfer; } diff --git a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts index 30ca466688..3bcc6b2502 100644 --- a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts @@ -17,7 +17,7 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { SuiProgrammableTransaction, SuiTransactionType } from './iface'; import { DUMMY_SUI_GAS_PRICE } from './constants'; import { KeyPair } from './keyPair'; -import { GasData, SuiObjectRef } from './mystenlab/types'; +import { GasData, SuiObjectRef, TransactionExpiration } from './mystenlab/types'; export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; @@ -28,6 +28,7 @@ export abstract class TransactionBuilder extends protected _sender: string; protected _gasData: GasData; protected _inputObjects: SuiObjectRef[]; + protected _expiration?: TransactionExpiration; protected constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -100,6 +101,15 @@ export abstract class TransactionBuilder extends return this; } + /** + * Sets the transaction expiration. Use ValidDuring (with nonce) only for Case 2 + * self-funded transactions where gasData.payment is empty. + */ + expiration(expiration: TransactionExpiration): this { + this._expiration = expiration; + return this; + } + /** * Initialize the transaction builder fields using the decoded transaction data * @@ -149,7 +159,15 @@ export abstract class TransactionBuilder 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..8ff1a244aa 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 +162,41 @@ 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. + * + * Three build paths: + * + * Path 1a — Sponsored with coin objects (sender ≠ gasData.owner, inputObjects provided): + * [optional withdrawal(fundsInAddressBalance) → redeem_funds → Coin] + * MergeCoins(inputObject[0], [inputObject[1..], addrCoin?]) + * SplitCoins(mergedObject, [amount]) → TransferObjects + * Handles Cases 4 (coins only), 6 (coins, sponsor addr-bal gas), 8 (coins+addr-bal). + * + * Path 1b — Sponsored, address balance only (sender ≠ gasData.owner, no inputObjects): + * withdrawal(fundsInAddressBalance) → redeem_funds → Coin + * SplitCoins(addrCoin, [amount]) → TransferObjects + * Handles Case 5 (sponsor coin-object gas) and Case 7/Phase-4b (sponsor addr-bal gas). + * Caller must set ValidDuring expiration when gasData.payment = [] (Cases 7, 9). + * + * Path 2 — Self-pay (sender === gasData.owner): + * SplitCoins(GasCoin, [amount]) → TransferObjects + * GasCoin at Sui execution time = gasData.payment objects merged + fundsInAddressBalance. + * Handles Case 1 (coins), Case 2 (addr-bal only, caller sets ValidDuring), Case 3 (mixed). * * @protected */ @@ -126,7 +205,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); @@ -148,6 +245,37 @@ export class TransferBuilder extends TransactionBuilder → SplitCoins → TransferObjects + const [addrCoin] = programmableTxBuilder.moveCall({ + target: '0x2::coin::redeem_funds', + typeArguments: ['0x2::sui::SUI'], + arguments: [programmableTxBuilder.withdrawal({ amount: BigInt(this._fundsInAddressBalance.toFixed()) })], + }); + this._recipients.forEach((recipient) => { + const splitObject = programmableTxBuilder.splitCoins(addrCoin, [ + programmableTxBuilder.pure(Number(recipient.amount)), + ]); + programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); + }); + const txData1b = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData1b.inputs], + transactions: [...txData1b.transactions], + }, + gasData: { + ...this._gasData, + }, + expiration: this._expiration, + fundsInAddressBalance: this._fundsInAddressBalance.toFixed(), }; } else { // number of objects passed as gas payment should be strictly less than `MAX_GAS_OBJECTS`. When the transaction @@ -191,6 +319,8 @@ export class TransferBuilder extends TransactionBuilder 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..abe80663e4 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,32 @@ export class Utils implements BaseUtils { return netCost.comparedTo(computationCost) > 0 ? netCost : computationCost; } - async getBalance(url: string, owner: string, coinType?: string): Promise { + /** + * Returns the current epoch and chain identifier of the Sui network by making RPC calls to the provided URL. + * @param {string} url - The URL of the Sui network RPC endpoint. + * @returns {Promise<{ epoch: number; chainId: string }>} - The current epoch and chain identifier. + */ + async getChainContext(url: string): Promise<{ epoch: number; chainId: string }> { + const [systemState, chainId] = await Promise.all([ + makeRPC(url, 'suix_getLatestSuiSystemState', []), + makeRPC(url, 'sui_getChainIdentifier', []), + ]); + return { epoch: Number(systemState.epoch), chainId: String(chainId) }; + } + + 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..e2f4282938 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { BaseBroadcastTransactionOptions, BaseBroadcastTransactionResult, @@ -42,7 +43,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 +307,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 +354,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 +374,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 +392,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 = [ { @@ -397,6 +405,18 @@ export class Sui extends BaseCoin { }, ]; + // Case 2 self-funded: all balance is in address balance, no coin objects. + // gasData.payment must be [] and a ValidDuring expiration is required to + // prevent replay attacks when there are no gas coin objects to anchor uniqueness. + let validDuringExpiration: + | { ValidDuring: { minEpoch: number; maxEpoch: number; chain: string; nonce: number } } + | undefined; + if (fundsInAddressBalance.gt(0) && coinObjectsBalance.eq(0)) { + const { epoch, chainId } = await utils.getChainContext(this.getPublicNodeUrl()); + const nonce = crypto.randomBytes(4).readUInt32BE(0); + validDuringExpiration = { ValidDuring: { minEpoch: epoch, maxEpoch: epoch + 1, chain: chainId, nonce } }; + } + // first build the unsigned txn const factory = new TransactionBuilderFactory(coins.get(this.getChain())); const txBuilder = factory @@ -404,6 +424,7 @@ export class Sui extends BaseCoin { .type(SuiTransactionType.Transfer) .sender(senderAddress) .send(recipients) + .fundsInAddressBalance(fundsInAddressBalance.toString()) .gasData({ owner: senderAddress, price: DEFAULT_GAS_PRICE, @@ -411,6 +432,10 @@ export class Sui extends BaseCoin { payment: inputCoins, }); + if (validDuringExpiration) { + txBuilder.expiration(validDuringExpiration); + } + const tempTx = (await txBuilder.build()) as TransferTransaction; const feeEstimate = await this.getFeeEstimate(tempTx.toBroadcastFormat()); const gasBudget = Math.trunc(feeEstimate.toNumber() * DEFAULT_GAS_OVERHEAD); @@ -468,7 +493,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 +518,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 +526,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); @@ -815,6 +847,7 @@ export class Sui extends BaseCoin { /** inherited doc */ setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { intent.unspents = params.unspents; + intent.fundsInAddressBalance = params.fundsInAddressBalance; } /** inherited doc */ 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..8fc943cc7c 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -309,4 +309,166 @@ 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); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + // Path 1b: withdrawal → redeem_funds → SplitCoins(addrCoin) → TransferObjects + // Sponsor's gas coins remain in gasData.payment; tx.gas is NOT used for the transfer amount. + const suiTx = tx as SuiTransaction; + suiTx.suiTransaction.gasData.owner.should.equal(testData.feePayer.address); + 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('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 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'); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 2277a879ba..a740729846 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -242,6 +242,11 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase custodianTransactionId?: string; receiveAddress?: string; unspents?: string[]; + /** + * MIST to redeem from the sender's address balance (SIP-58 / SUI address balances). + * When set, the PTB includes tx.withdrawal() + redeem_funds() before the transfer. + */ + fundsInAddressBalance?: string; /** * The receive address from which funds will be withdrawn. * This feature is supported only for specific coins, like ADA. @@ -315,6 +320,7 @@ export interface PopulatedIntent extends PopulatedIntentBase { token?: string; enableTokens?: TokenEnablement[]; unspents?: string[]; + fundsInAddressBalance?: string; /** * The receive address from which funds will be withdrawn. * This feature is supported only for specific coins, like ADA.