diff --git a/modules/sdk-coin-sui/src/lib/iface.ts b/modules/sdk-coin-sui/src/lib/iface.ts index cb532f7c5f..a5325f34f5 100644 --- a/modules/sdk-coin-sui/src/lib/iface.ts +++ b/modules/sdk-coin-sui/src/lib/iface.ts @@ -103,6 +103,7 @@ export interface SuiTransaction { sender: string; tx: T; gasData: GasData; + inputObjects?: SuiObjectRef[]; } export interface RequestAddStake { diff --git a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts index da8e28c444..30ca466688 100644 --- a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts @@ -27,6 +27,7 @@ export abstract class TransactionBuilder extends protected _type: SuiTransactionType; protected _sender: string; protected _gasData: GasData; + protected _inputObjects: SuiObjectRef[]; protected constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -93,6 +94,12 @@ export abstract class TransactionBuilder extends return this; } + inputObjects(inputObjects: SuiObjectRef[]): this { + this.validateInputObjectsBase(inputObjects); + this._inputObjects = inputObjects; + return this; + } + /** * Initialize the transaction builder fields using the decoded transaction data * @@ -148,6 +155,14 @@ export abstract class TransactionBuilder extends }); } + protected validateInputObjectsBase(inputObjects: SuiObjectRef[]): void { + if (inputObjects && inputObjects.length > 0) { + inputObjects.forEach((inputObject) => { + this.validateSuiObjectRef(inputObject, 'input object'); + }); + } + } + validateSuiObjectRef(suiObjectRef: SuiObjectRef, field: string): void { if (!suiObjectRef.hasOwnProperty('objectId')) { throw new BuildTransactionError(`Invalid ${field}, missing objectId`); diff --git a/modules/sdk-coin-sui/src/lib/transferBuilder.ts b/modules/sdk-coin-sui/src/lib/transferBuilder.ts index 32de1c2afe..7bfa411549 100644 --- a/modules/sdk-coin-sui/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transferBuilder.ts @@ -9,6 +9,7 @@ import { Inputs, Transactions as TransactionsConstructor, TransactionBlock as ProgrammingTransactionBlockBuilder, + TransactionArgument, } from './mystenlab/builder'; import utils from './utils'; import { MAX_COMMAND_ARGS, MAX_GAS_OBJECTS } from './constants'; @@ -88,6 +89,10 @@ export class TransferBuilder extends TransactionBuilder 0) { + this.validateInputObjectsBase(this._inputObjects); + } } /** @@ -115,47 +125,73 @@ export class TransferBuilder extends TransactionBuilder= MAX_GAS_OBJECTS) { - const gasPaymentObjects = this._gasData.payment - .slice(MAX_GAS_OBJECTS - 1) - .map((object) => Inputs.ObjectRef(object)); - - // limit for total number of `args: CallArg[]` for a single command is MAX_COMMAND_ARGS so the max length of - // `sources[]` for a `mergeCoins(destination, sources[])` command is MAX_COMMAND_ARGS - 1 (1 used up for - // `destination`). We need to create a total of `gasPaymentObjects/(MAX_COMMAND_ARGS - 1)` merge commands to - // merge all the objects - while (gasPaymentObjects.length > 0) { - programmableTxBuilder.mergeCoins( - programmableTxBuilder.gas, - gasPaymentObjects.splice(0, MAX_COMMAND_ARGS - 1).map((object) => programmableTxBuilder.object(object)) - ); + if (this._sender !== this._gasData.owner && this._inputObjects && this._inputObjects.length > 0) { + const inputObjects = this._inputObjects.map((object) => programmableTxBuilder.object(Inputs.ObjectRef(object))); + const mergedObject = inputObjects.shift() as TransactionArgument; + if (inputObjects.length > 0) { + programmableTxBuilder.mergeCoins(mergedObject, inputObjects); } - } - - this._recipients.forEach((recipient) => { - const coin = programmableTxBuilder.add( - TransactionsConstructor.SplitCoins(programmableTxBuilder.gas, [ + this._recipients.forEach((recipient) => { + const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ programmableTxBuilder.pure(Number(recipient.amount)), - ]) - ); - programmableTxBuilder.add( - TransactionsConstructor.TransferObjects([coin], programmableTxBuilder.object(recipient.address)) - ); - }); - const txData = programmableTxBuilder.blockData; - return { - type: this._type, - sender: this._sender, - tx: { - inputs: [...txData.inputs], - transactions: [...txData.transactions], - }, - gasData: { - ...this._gasData, - payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), - }, - }; + ]); + programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); + }); + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + }, + }; + } else { + // number of objects passed as gas payment should be strictly less than `MAX_GAS_OBJECTS`. When the transaction + // requires a larger number of inputs we use the merge command to merge the rest of the objects into the gasCoin + if (this._gasData.payment.length >= MAX_GAS_OBJECTS) { + const gasPaymentObjects = this._gasData.payment + .slice(MAX_GAS_OBJECTS - 1) + .map((object) => Inputs.ObjectRef(object)); + + // limit for total number of `args: CallArg[]` for a single command is MAX_COMMAND_ARGS so the max length of + // `sources[]` for a `mergeCoins(destination, sources[])` command is MAX_COMMAND_ARGS - 1 (1 used up for + // `destination`). We need to create a total of `gasPaymentObjects/(MAX_COMMAND_ARGS - 1)` merge commands to + // merge all the objects + while (gasPaymentObjects.length > 0) { + programmableTxBuilder.mergeCoins( + programmableTxBuilder.gas, + gasPaymentObjects.splice(0, MAX_COMMAND_ARGS - 1).map((object) => programmableTxBuilder.object(object)) + ); + } + } + + this._recipients.forEach((recipient) => { + const coin = programmableTxBuilder.add( + TransactionsConstructor.SplitCoins(programmableTxBuilder.gas, [ + programmableTxBuilder.pure(Number(recipient.amount)), + ]) + ); + programmableTxBuilder.add( + TransactionsConstructor.TransferObjects([coin], programmableTxBuilder.object(recipient.address)) + ); + }); + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), + }, + }; + } } } diff --git a/modules/sdk-coin-sui/src/lib/transferTransaction.ts b/modules/sdk-coin-sui/src/lib/transferTransaction.ts index fef384c6fe..eff8af6b04 100644 --- a/modules/sdk-coin-sui/src/lib/transferTransaction.ts +++ b/modules/sdk-coin-sui/src/lib/transferTransaction.ts @@ -13,8 +13,14 @@ import { MAX_GAS_OBJECTS, SUI_ADDRESS_LENGTH, UNAVAILABLE_TEXT } from './constan import { Buffer } from 'buffer'; import { Transaction } from './transaction'; import { CallArg, SuiObjectRef, normalizeSuiAddress } from './mystenlab/types'; -import utils from './utils'; -import { builder, Inputs, TransactionBlockInput } from './mystenlab/builder'; +import utils, { isImmOrOwnedObj } from './utils'; +import { + builder, + Inputs, + TransactionArgument, + TransactionBlockInput, + TransactionType as SuiTransactionBlockType, +} from './mystenlab/builder'; import { BCS } from '@mysten/bcs'; import BigNumber from 'bignumber.js'; @@ -70,6 +76,17 @@ export class TransferTransaction extends Transaction { + if (arg.kind === 'Input') { + let input = inputs[arg.index]; + if ('value' in input) { + input = input.value; + } + if ('Object' in input && isImmOrOwnedObj(input.Object)) { + inputObjects.push(input.Object.ImmOrOwned); + } + } + }); + + return inputObjects; + } } diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index 2017bc2284..d3ff991564 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -46,6 +46,61 @@ describe('Sui Transfer Builder', () => { should.equal(rawTx, testData.TRANSFER); }); + it('should build a sponsored transfer tx with inputObjects', async function () { + const inputObjects = [ + { + objectId: '0000000000000000000000001234567890abcdef1234567890abcdef12345678', + version: 100, + digest: '2B8XKQJ7mfxQPUWqJJAGjzBAzivkWKq2cEa3W8LLz1yB', + }, + { + objectId: '000000000000000000000000abcdef1234567890abcdef1234567890abcdef12', + version: 200, + digest: 'DoJwXuz9oU5Y5v5vBRiTgisVTQuZQLmHZWeqJzzD5QUE', + }, + ]; + + // Create gas data with different owner (sponsor) + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, // Different from sender + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); // Sender + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasData); // Gas paid by sponsor + txBuilder.inputObjects(inputObjects); // Required for sponsored tx + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + + // Verify transaction structure for sponsored transaction + const programmableTx = suiTx.suiTransaction.tx; + programmableTx.transactions.length.should.be.greaterThan(0); + + // Verify sponsored transaction characteristics + suiTx.suiTransaction.sender.should.equal(testData.sender.address); + suiTx.suiTransaction.gasData.owner.should.equal(testData.feePayer.address); + + // Should have transactions for merging/splitting and transferring when using inputObjects + programmableTx.transactions.length.should.be.greaterThan(0); + + // Verify we have transfer operations (the exact structure varies with API versions) + should.exist(programmableTx.transactions); + + // Verify inputObjects are not used in gas payment (they're separate) + suiTx.suiTransaction.gasData.payment.should.not.containDeep(inputObjects); + + const rawTx = tx.toBroadcastFormat(); + should.exist(rawTx); + should.equal(typeof rawTx, 'string'); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); + it('should build a split coin tx', async function () { const txBuilder = factory.getTransferBuilder(); txBuilder.type(SuiTransactionType.Transfer); @@ -184,5 +239,74 @@ describe('Sui Transfer Builder', () => { }; should(() => builder.gasData(invalidGasPayment)).throwError('Invalid payment, invalid or missing version'); }); + + it('should fail for invalid inputObjects', function () { + const builder = factory.getTransferBuilder(); + const invalidInputObjects = [ + { + objectId: '', + version: -1, + digest: '', + }, + ]; + should(() => builder.inputObjects(invalidInputObjects)).throwError( + 'Invalid input object, invalid or missing version' + ); + }); + + it('should build transfer with different gas owner but no inputObjects', async function () { + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, // Different from sender + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); // Sender + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasData); // Gas paid by sponsor + // Note: NOT providing inputObjects - should still work (fee sponsorship without coin sponsorship) + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + should.not.exist(suiTx.suiTransaction.inputObjects); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + }); + + it('should extract inputObjects from transaction data via toJson', async function () { + const inputObjects = [ + { + objectId: '0x1234567890abcdef1234567890abcdef12345678', + version: 100, + digest: '2B8XKQJ7mfxQPUWqJJAGjzBAzivkWKq2cEa3W8LLz1yB', + }, + ]; + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, // Different from sender + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(sponsoredGasData); + txBuilder.inputObjects(inputObjects); + + const tx = await txBuilder.build(); + + // Test that toJson extracts inputObjects from transaction structure + const txData = (tx as any).toJson(); + should.exist(txData.inputObjects); + + // The toJson should extract the inputObjects from the transaction structure + // This tests our new getInputObjectsFromTx method + should.exist(txData.inputObjects); + Array.isArray(txData.inputObjects).should.equal(true); + }); }); });