Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions modules/sdk-coin-sui/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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<T> objects) */
coinObjectBalance: string;
/** Balance held in the address balance system (not in coin objects) */
fundsInAddressBalance: string;
}
30 changes: 26 additions & 4 deletions modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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<typeof PureCallArg>;
export type ObjectCallArg = Infer<typeof ObjectCallArg>;
export type BalanceWithdrawalCallArg = Infer<typeof BalanceWithdrawalCallArg>;

export const BuilderCallArg = union([PureCallArg, ObjectCallArg]);
export const BuilderCallArg = union([PureCallArg, ObjectCallArg, BalanceWithdrawalCallArg]);
export type BuilderCallArg = Infer<typeof BuilderCallArg>;

export const Inputs = {
Expand All @@ -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<T>` 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<T>` 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));
}
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-sui/src/lib/mystenlab/types/coin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
7 changes: 6 additions & 1 deletion modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -144,6 +144,7 @@ const BCS_SPEC: TypeSchema = {
Pure: [VECTOR, BCS.U8],
Object: 'ObjectArg',
ObjVec: [VECTOR, 'ObjectArg'],
BalanceWithdrawal: 'BalanceWithdrawal',
},
TypeTag: {
bool: null,
Expand Down Expand Up @@ -175,6 +176,10 @@ const BCS_SPEC: TypeSchema = {
},
},
structs: {
BalanceWithdrawal: {
amount: BCS.U64,
type_: 'TypeTag',
},
SuiObjectRef: {
objectId: BCS.ADDRESS,
version: BCS.U64,
Expand Down
106 changes: 91 additions & 15 deletions modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,10 +12,19 @@ import {
TransactionBlock as ProgrammingTransactionBlockBuilder,
TransactionArgument,
} from './mystenlab/builder';
import BigNumber from 'bignumber.js';

export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgrammableTransaction> {
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<T> that is merged with any coin objects before splitting.
*/
protected _fundsInAddressBalance: BigNumber = new BigNumber(0);

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
this._transaction = new TokenTransferTransaction(_coinConfig);
Expand All @@ -25,6 +34,14 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
return TransactionType.Send;
}

/**
* The full coin type string derived from the coin config (e.g. `0xabc::my_token::MY_TOKEN`).
*/
private get tokenCoinType(): string {
const config = this._coinConfig as SuiCoin;
return `${config.packageId}::${config.module}::${config.symbol}`;
}

/** @inheritdoc */
validateTransaction(transaction: TokenTransferTransaction): void {
if (!transaction.suiTransaction) {
Expand Down Expand Up @@ -78,8 +95,23 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
this.gasData(txData.gasData);
const recipients = utils.getRecipients(tx.suiTransaction);
this.send(recipients);
assert(txData.inputObjects);
this.inputObjects(txData.inputObjects);

// Reconstruct fundsInAddressBalance from BalanceWithdrawal input if present.
// After BCS deserialization inputs are CallArg format: { BalanceWithdrawal: {...} }
// During building they are TransactionBlockInput format: { kind:'Input', value: { BalanceWithdrawal: {...} } }
const withdrawalInput = (tx.suiTransaction?.tx?.inputs as any[])?.find(
(input: any) =>
(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 {
Expand All @@ -89,11 +121,21 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
}

inputObjects(inputObject: SuiObjectRef[]): this {
this.validateInputObjects(inputObject);
this.validateInputObjectRefs(inputObject);
this._inputObjects = inputObject;
return this;
}

/**
* Set the amount of token funds held in the Sui address balance system for this sender.
*
* @param {string} amount - amount in base units held in address balance
*/
fundsInAddressBalance(amount: string): this {
this._fundsInAddressBalance = new BigNumber(amount);
return this;
}

/**
* Validates all fields are defined correctly
*/
Expand All @@ -106,21 +148,37 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
);
assert(this._gasData, new BuildTransactionError('gasData is required before building'));
this.validateGasData(this._gasData);
this.validateInputObjects(this._inputObjects);
}

private validateInputObjects(inputObjects: SuiObjectRef[]): void {
// Must have at least coin objects OR address balance
assert(
inputObjects && inputObjects.length > 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<T>
* MergeCoins(inputObject[0] | addrCoin, [rest...]) → SplitCoins → TransferObjects
*
* @return {SuiTransaction<TokenTransferProgrammableTransaction>}
* @protected
Expand All @@ -130,9 +188,27 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra

const programmableTxBuilder = new ProgrammingTransactionBlockBuilder();

const inputObjects = this._inputObjects.map((object) => 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<T> 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);
}
Expand Down
32 changes: 24 additions & 8 deletions modules/sdk-coin-sui/src/lib/tokenTransferTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,15 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
return Inputs.Pure(amount, BCS.U64);
}
}
if (input.kind === 'Input' && (input.value.hasOwnProperty('Object') || input.value.hasOwnProperty('Pure'))) {
if (input.hasOwnProperty('BalanceWithdrawal')) {
return input;
}
if (
input.kind === 'Input' &&
(input.value.hasOwnProperty('Object') ||
input.value.hasOwnProperty('Pure') ||
input.value.hasOwnProperty('BalanceWithdrawal'))
) {
return input.value;
}
return Inputs.Pure(input.value, input.type === 'pure' ? BCS.U64 : BCS.ADDRESS);
Expand Down Expand Up @@ -221,20 +229,28 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
}

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

// Scan all transactions to find the first MergeCoins or SplitCoins,
// which holds references to the coin object inputs.
let args: TransactionArgument[] = [];
if (transaction.kind === 'MergeCoins') {
const { destination, sources } = transaction;
args = [destination, ...sources];
} else if (transaction.kind === 'SplitCoins') {
args = [transaction.coin];
for (const txn of tx.transactions) {
const transaction = txn as SuiTransactionBlockType;
if (transaction.kind === 'MergeCoins') {
const { destination, sources } = transaction;
args = [destination, ...sources];
break;
} else if (transaction.kind === 'SplitCoins') {
args = [transaction.coin];
break;
}
}

const inputObjects: SuiObjectRef[] = [];
Expand Down
10 changes: 9 additions & 1 deletion modules/sdk-coin-sui/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,15 @@ export abstract class TransactionBuilder<T = SuiProgrammableTransaction> 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');
});
Expand Down
Loading
Loading