From 73914386b29ae80ef8be14ae39925b88f3e4d6e1 Mon Sep 17 00:00:00 2001 From: Paras Garg Date: Thu, 4 Dec 2025 15:03:17 +0530 Subject: [PATCH] feat(sdk-coin-iota): refactor tx builders for better readability Ticket: WIN-8153 --- modules/sdk-coin-iota/src/lib/transaction.ts | 327 +++-- .../src/lib/transactionBuilder.ts | 144 ++- .../src/lib/transactionBuilderFactory.ts | 122 +- .../sdk-coin-iota/src/lib/transferBuilder.ts | 60 +- .../src/lib/transferTransaction.ts | 266 +++- .../test/unit/helpers/testHelpers.ts | 146 +++ .../transactionBuilder/transactionBuilder.ts | 363 ++---- .../transactionBuilderFactory.ts | 169 +-- .../transactionBuilder/transferBuilder.ts | 1152 +++++------------ .../test/unit/transferTransaction.ts | 440 ++----- 10 files changed, 1432 insertions(+), 1757 deletions(-) create mode 100644 modules/sdk-coin-iota/test/unit/helpers/testHelpers.ts diff --git a/modules/sdk-coin-iota/src/lib/transaction.ts b/modules/sdk-coin-iota/src/lib/transaction.ts index e18a59eb52..71cabd85ed 100644 --- a/modules/sdk-coin-iota/src/lib/transaction.ts +++ b/modules/sdk-coin-iota/src/lib/transaction.ts @@ -16,34 +16,35 @@ import { import { TxData, TransactionObjectInput, TransactionExplanation } from './iface'; import { toBase64 } from '@iota/iota-sdk/utils'; import blake2b from '@bitgo/blake2b'; -import { - MAX_GAS_BUDGET, - MAX_GAS_PAYMENT_OBJECTS, - MAX_GAS_PRICE, - IOTA_KEY_BYTES_LENGTH, - IOTA_SIGNATURE_LENGTH, -} from './constants'; +import { MAX_GAS_BUDGET, MAX_GAS_PAYMENT_OBJECTS, MAX_GAS_PRICE } from './constants'; import utils from './utils'; +/** + * Base class for IOTA transactions. + * Manages transaction state, gas data, signatures, and building/serialization. + */ export abstract class Transaction extends BaseTransaction { - static EMPTY_PUBLIC_KEY = Buffer.alloc(IOTA_KEY_BYTES_LENGTH); - static EMPTY_SIGNATURE = Buffer.alloc(IOTA_SIGNATURE_LENGTH); - + // Transaction state management protected _rebuildRequired: boolean; protected _type: TransactionType; protected _iotaTransaction: IotaTransaction; + // Gas and payment data private _gasBudget?: number; private _gasPaymentObjects?: TransactionObjectInput[]; private _gasPrice?: number; private _gasSponsor?: string; + + // Transaction identifiers and data private _sender: string; + private _txDataBytes?: Uint8Array; + private _isSimulateTx: boolean; + + // Signature data private _signature?: Signature; private _serializedSignature?: string; private _gasSponsorSignature?: Signature; private _serializedGasSponsorSignature?: string; - private _txDataBytes?: Uint8Array; - private _isSimulateTx: boolean; protected constructor(coinConfig: Readonly) { super(coinConfig); @@ -52,99 +53,150 @@ export abstract class Transaction extends BaseTransaction { this._isSimulateTx = true; } + // Gas budget getter/setter - marks rebuild required when changed get gasBudget(): number | undefined { return this._gasBudget; } set gasBudget(value: number | undefined) { this._gasBudget = value; - this._rebuildRequired = true; + this.markRebuildRequired(); } + // Gas payment objects getter/setter - marks rebuild required when changed get gasPaymentObjects(): TransactionObjectInput[] | undefined { return this._gasPaymentObjects; } set gasPaymentObjects(value: TransactionObjectInput[] | undefined) { this._gasPaymentObjects = value; - this._rebuildRequired = true; + this.markRebuildRequired(); } + // Gas price getter/setter - marks rebuild required when changed get gasPrice(): number | undefined { return this._gasPrice; } set gasPrice(value: number | undefined) { this._gasPrice = value; - this._rebuildRequired = true; + this.markRebuildRequired(); } + // Gas sponsor getter/setter - marks rebuild required when changed get gasSponsor(): string | undefined { return this._gasSponsor; } set gasSponsor(value: string | undefined) { this._gasSponsor = value; - this._rebuildRequired = true; + this.markRebuildRequired(); } + // Transaction sender getter/setter - marks rebuild required when changed get sender(): string { return this._sender; } set sender(value: string) { this._sender = value; - this._rebuildRequired = true; + this.markRebuildRequired(); } + /** + * Indicates whether this is a simulate transaction (dry run) or a real transaction. + * Simulate transactions use maximum gas values for estimation purposes. + */ get isSimulateTx(): boolean { return this._isSimulateTx; } set isSimulateTx(value: boolean) { if (!value) { - try { - this.validateTxData(); - this._rebuildRequired = true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Tx data validation failed: ${errorMessage}. {Cause: ${error}}`); - } + this.validateTxDataForRealTransaction(); + this.markRebuildRequired(); } this._isSimulateTx = value; } + /** + * Marks that the transaction needs to be rebuilt before it can be signed or broadcast. + */ + private markRebuildRequired(): void { + this._rebuildRequired = true; + } + + /** + * Validates transaction data when switching from simulate to real transaction mode. + */ + private validateTxDataForRealTransaction(): void { + try { + this.validateTxData(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Tx data validation failed: ${errorMessage}. {Cause: ${error}}`); + } + } + + /** + * Returns the signable payload for this transaction. + * This is the Blake2b hash of the transaction data with intent message. + * @throws Error if transaction is in simulate mode or not built + */ get signablePayload(): Buffer { if (this.isSimulateTx) { throw new Error('Cannot sign a simulate tx'); } - if (this._txDataBytes === undefined || this._rebuildRequired) { - throw new Error('Tx not built or a rebuild is required'); - } - const intentMessage = this.messageWithIntent(this._txDataBytes); + this.ensureTransactionIsBuilt(); + + const intentMessage = this.messageWithIntent(this._txDataBytes as Uint8Array); return Buffer.from(blake2b(32).update(intentMessage).digest('binary')); } - /** @inheritDoc **/ + /** + * Returns the transaction digest (ID). + * @throws Error if transaction is not built or needs rebuilding + */ get id(): string { + this.ensureTransactionIsBuilt(); + return IotaTransactionDataBuilder.getDigestFromBytes(this._txDataBytes as Uint8Array); + } + + /** + * Ensures the transaction is built and doesn't need rebuilding. + * @throws Error if transaction is not built or rebuild is required + */ + private ensureTransactionIsBuilt(): void { if (this._txDataBytes === undefined || this._rebuildRequired) { throw new Error('Tx not built or a rebuild is required'); } - return IotaTransactionDataBuilder.getDigestFromBytes(this._txDataBytes); } + /** + * Adds a signature from the transaction sender. + */ addSignature(publicKey: PublicKey, signature: Buffer): void { this._signature = { publicKey, signature }; } + /** + * Adds a signature from the gas sponsor (if different from sender). + */ addGasSponsorSignature(publicKey: PublicKey, signature: Buffer): void { this._gasSponsorSignature = { publicKey, signature }; } + /** + * Checks if this transaction can be signed. + * Only real transactions (not simulate) can be signed. + */ canSign(_key: BaseKey): boolean { return !this.isSimulateTx; } + /** + * Returns the transaction fee (gas budget). + */ getFee(): string | undefined { return this.gasBudget?.toString(); } @@ -157,28 +209,42 @@ export abstract class Transaction extends BaseTransaction { return this._serializedSignature; } + /** + * Serializes all signatures for the transaction. + * Includes both sender signature and gas sponsor signature if present. + */ serializeSignatures(): void { this._signatures = []; + if (this._signature) { - this._serializedSignature = this.serializeSignature(this._signature as Signature); + this._serializedSignature = this.serializeSignature(this._signature); this._signatures.push(this._serializedSignature); } + if (this._gasSponsorSignature) { - this._serializedGasSponsorSignature = this.serializeSignature(this._gasSponsorSignature as Signature); + this._serializedGasSponsorSignature = this.serializeSignature(this._gasSponsorSignature); this._signatures.push(this._serializedGasSponsorSignature); } } + /** + * Converts the transaction to broadcast format (base64 encoded). + */ async toBroadcastFormat(): Promise { - const txDataBytes: Uint8Array = await this.build(); + const txDataBytes = await this.build(); return toBase64(txDataBytes); } + /** + * Builds the transaction bytes. + * If in simulate mode, builds a dry run transaction with max gas values. + * Otherwise, builds a real transaction with actual gas data. + */ async build(): Promise> { if (this.isSimulateTx) { return this.buildDryRunTransaction(); } - return this.buildTransaction(); + return this.buildRealTransaction(); } toJson(): TxData { @@ -202,31 +268,49 @@ export abstract class Transaction extends BaseTransaction { } } + /** + * Parses transaction data from its broadcast format (base64 or raw bytes). + * Extracts sender, gas data, and gas sponsor information. + */ parseFromBroadcastTx(tx: string | Uint8Array): void { const txData = IotaTransaction.from(tx).getData(); + + this.parseSender(txData); + this.parseGasData(txData); + } + + /** + * Parses the sender address from transaction data. + */ + private parseSender(txData: ReturnType): void { if (txData.sender) { this.sender = txData.sender; } - if (txData.gasData?.budget) { - this.gasBudget = Number(txData.gasData.budget); - } else { + } + + /** + * Parses gas-related data from transaction data. + */ + private parseGasData(txData: ReturnType): void { + const gasData = txData.gasData; + + if (!gasData) { this.gasBudget = undefined; - } - if (txData.gasData?.price) { - this.gasPrice = Number(txData.gasData.price); - } else { this.gasPrice = undefined; - } - if (txData.gasData?.payment && txData.gasData.payment.length > 0) { - this.gasPaymentObjects = txData.gasData.payment.map((payment) => payment as TransactionObjectInput); - } else { this.gasPaymentObjects = undefined; - } - if (txData.gasData?.owner) { - this.gasSponsor = txData.gasData.owner; - } else { this.gasSponsor = undefined; + return; } + + this.gasBudget = gasData.budget ? Number(gasData.budget) : undefined; + this.gasPrice = gasData.price ? Number(gasData.price) : undefined; + + this.gasPaymentObjects = + gasData.payment && gasData.payment.length > 0 + ? gasData.payment.map((payment) => payment as TransactionObjectInput) + : undefined; + + this.gasSponsor = gasData.owner || undefined; } /** @@ -253,37 +337,35 @@ export abstract class Transaction extends BaseTransaction { return this.explainTransactionImplementation(result, explanationResult); } + /** + * Updates the simulate transaction flag based on gas data availability. + * If all gas data is present, switches to real transaction mode. + */ protected updateIsSimulateTx(): void { - if (this.gasBudget && this.gasPrice && this.gasPaymentObjects && this.gasPaymentObjects?.length > 0) { - this.isSimulateTx = false; - } else { - this.isSimulateTx = true; - } + const hasAllGasData = + this.gasBudget && this.gasPrice && this.gasPaymentObjects && this.gasPaymentObjects.length > 0; + + this.isSimulateTx = !hasAllGasData; } + // Abstract methods to be implemented by child classes protected abstract messageWithIntent(message: Uint8Array): Uint8Array; protected abstract populateTxInputsAndCommands(): void; protected abstract validateTxDataImplementation(): void; - - /** - * Add the input and output entries for this transaction. - */ abstract addInputsAndOutputs(): void; - - /** - * Returns a complete explanation for a transfer transaction - * @param {TxData} json The transaction data in json format - * @param {TransactionExplanation} explanationResult The transaction explanation to be completed - * @returns {TransactionExplanation} - */ protected abstract explainTransactionImplementation( json: TxData, explanationResult: TransactionExplanation ): TransactionExplanation; + /** + * Builds a dry run (simulate) transaction with maximum gas values. + * Used for gas estimation without committing the transaction. + */ private async buildDryRunTransaction(): Promise> { this.validateTxDataImplementation(); await this.populateTxData(); + const txDataBuilder = new IotaTransactionDataBuilder(this._iotaTransaction.getData() as IotaTransactionData); return txDataBuilder.build({ overrides: { @@ -296,46 +378,97 @@ export abstract class Transaction extends BaseTransaction { }); } - private async buildTransaction(): Promise> { + /** + * Builds a real transaction with actual gas data. + * Only builds if necessary (first time or rebuild required). + */ + private async buildRealTransaction(): Promise> { if (this._txDataBytes === undefined || this._rebuildRequired) { this.validateTxData(); await this.populateTxData(); - this._iotaTransaction.setGasPrice(this.gasPrice as number); - this._iotaTransaction.setGasBudget(this.gasBudget as number); - this._iotaTransaction.setGasPayment( - this.gasPaymentObjects?.slice(0, MAX_GAS_PAYMENT_OBJECTS - 1) as TransactionObjectInput[] - ); + this.setGasDataOnTransaction(); this._txDataBytes = await this._iotaTransaction.build(); this._rebuildRequired = false; } + this.serializeSignatures(); return this._txDataBytes; } + /** + * Sets gas data on the IOTA transaction object. + */ + private setGasDataOnTransaction(): void { + this._iotaTransaction.setGasPrice(this.gasPrice!); + this._iotaTransaction.setGasBudget(this.gasBudget!); + this._iotaTransaction.setGasPayment( + this.gasPaymentObjects!.slice(0, MAX_GAS_PAYMENT_OBJECTS - 1) as TransactionObjectInput[] + ); + } + + /** + * Populates the IOTA transaction with inputs, commands, and gas sponsor if applicable. + */ private async populateTxData(): Promise { this._iotaTransaction = new IotaTransaction(); this.populateTxInputsAndCommands(); - if (this.gasSponsor && this._sender !== this.gasSponsor) { - this._iotaTransaction = IotaTransaction.fromKind( - await this._iotaTransaction.build({ onlyTransactionKind: true }) - ); - this._iotaTransaction.setGasOwner(this._gasSponsor as string); + + // If gas sponsor is different from sender, set up sponsored transaction + if (this.hasDifferentGasSponsor()) { + await this.setupGasSponsoredTransaction(); } + this._iotaTransaction.setSender(this.sender); } + /** + * Checks if the transaction has a gas sponsor different from the sender. + */ + private hasDifferentGasSponsor(): boolean { + return Boolean(this.gasSponsor && this._sender !== this.gasSponsor); + } + + /** + * Sets up a gas-sponsored transaction by building the transaction kind + * and setting the gas owner. + */ + private async setupGasSponsoredTransaction(): Promise { + const transactionKind = await this._iotaTransaction.build({ onlyTransactionKind: true }); + this._iotaTransaction = IotaTransaction.fromKind(transactionKind); + this._iotaTransaction.setGasOwner(this._gasSponsor!); + } + + /** + * Serializes a signature into IOTA's expected format. + * Format: [signature_scheme_flag (1 byte), signature, public_key] + * Currently hardcoded to EDDSA (0x00) as IOTA only supports this scheme. + */ private serializeSignature(signature: Signature): string { + const SIGNATURE_SCHEME_EDDSA = 0x00; const pubKey = Buffer.from(signature.publicKey.pub, 'hex'); - const serialized_sig = new Uint8Array(1 + signature.signature.length + pubKey.length); - serialized_sig.set([0x00]); //Hardcoding the signature scheme flag since we only support EDDSA for iota - serialized_sig.set(signature.signature, 1); - serialized_sig.set(pubKey, 1 + signature.signature.length); - return toBase64(serialized_sig); + const serializedSignature = new Uint8Array(1 + signature.signature.length + pubKey.length); + + serializedSignature.set([SIGNATURE_SCHEME_EDDSA]); + serializedSignature.set(signature.signature, 1); + serializedSignature.set(pubKey, 1 + signature.signature.length); + + return toBase64(serializedSignature); } + /** + * Validates all transaction data required for a real (non-simulate) transaction. + */ private validateTxData(): void { this.validateTxDataImplementation(); - if (!this.sender || this.sender === '') { + this.validateCommonTxData(); + this.validateSignatures(); + } + + /** + * Validates common transaction data (sender, gas data). + */ + private validateCommonTxData(): void { + if (!this.sender) { throw new InvalidTransactionError('Transaction sender is required'); } @@ -347,28 +480,28 @@ export abstract class Transaction extends BaseTransaction { throw new InvalidTransactionError('Gas budget is required'); } - if (!this.gasPaymentObjects || this.gasPaymentObjects?.length === 0) { + if (!this.gasPaymentObjects || this.gasPaymentObjects.length === 0) { throw new InvalidTransactionError('Gas payment objects are required'); } + } - if ( - this._signature && - !( - utils.isValidPublicKey(this._signature.publicKey.pub) && - utils.isValidSignature(toBase64(this._signature.signature)) - ) - ) { + /** + * Validates sender and gas sponsor signatures if present. + */ + private validateSignatures(): void { + if (this._signature && !this.isValidSignature(this._signature)) { throw new InvalidTransactionError('Invalid sender signature'); } - if ( - this._gasSponsorSignature && - !( - utils.isValidPublicKey(this._gasSponsorSignature.publicKey.pub) && - utils.isValidSignature(toBase64(this._gasSponsorSignature.signature)) - ) - ) { + if (this._gasSponsorSignature && !this.isValidSignature(this._gasSponsorSignature)) { throw new InvalidTransactionError('Invalid gas sponsor signature'); } } + + /** + * Checks if a signature has valid public key and signature data. + */ + private isValidSignature(signature: Signature): boolean { + return utils.isValidPublicKey(signature.publicKey.pub) && utils.isValidSignature(toBase64(signature.signature)); + } } diff --git a/modules/sdk-coin-iota/src/lib/transactionBuilder.ts b/modules/sdk-coin-iota/src/lib/transactionBuilder.ts index 27479d6801..13499c4a51 100644 --- a/modules/sdk-coin-iota/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-iota/src/lib/transactionBuilder.ts @@ -15,6 +15,10 @@ import utils from './utils'; import { TransactionObjectInput, GasData } from './iface'; import { toBase64 } from '@iota/iota-sdk/utils'; +/** + * Base class for IOTA transaction builders. + * Provides common functionality for building and validating IOTA transactions. + */ export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; @@ -23,12 +27,18 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * Initialize the transaction builder fields using the decoded transaction data - * - * @param {Transaction} tx the transaction data + * Initializes the transaction builder with data from an existing transaction. + * Copies sender, gas data, and gas sponsor information. */ initBuilder(tx: Transaction): void { this.validateTransaction(tx); + this.copyTransactionData(tx); + } + + /** + * Copies transaction data from the source transaction to the builder's transaction. + */ + private copyTransactionData(tx: Transaction): void { this.transaction.sender = tx.sender; this.transaction.gasPrice = tx.gasPrice; this.transaction.gasBudget = tx.gasBudget; @@ -40,25 +50,18 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this.transaction.type; } - /** - * @inheritdoc - * */ get transaction(): Transaction { return this._transaction; } - /** - * @inheritdoc - * */ protected set transaction(transaction: Transaction) { this._transaction = transaction; } /** - * Sets the sender of this transaction. - * - * @param {string} senderAddress the account that is sending this transaction - * @returns {TransactionBuilder} This transaction builder + * Sets the sender address for this transaction. + * @param senderAddress - The IOTA address that is sending this transaction + * @returns This transaction builder for method chaining */ sender(senderAddress: string): this { this.validateAddress({ address: senderAddress }); @@ -67,24 +70,30 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * Sets the gasData for this transaction. - * - * @param {string} gasData the gas details for this transaction - * @returns {TransactionBuilder} This transaction builder + * Sets the gas data for this transaction (budget, price, and payment objects). + * @param gasData - The gas configuration including budget, price, and payment objects + * @returns This transaction builder for method chaining */ gasData(gasData: GasData): this { this.validateGasData(gasData); + this.setGasDataOnTransaction(gasData); + return this; + } + + /** + * Sets gas data fields on the transaction. + */ + private setGasDataOnTransaction(gasData: GasData): void { this.transaction.gasPrice = gasData.gasPrice; this.transaction.gasBudget = gasData.gasBudget; this.transaction.gasPaymentObjects = gasData.gasPaymentObjects as TransactionObjectInput[]; - return this; } /** - * Sets the gasSponsor of this transaction. - * - * @param {string} sponsorAddress the account that is sponsoring this transaction's gas - * @returns {TransactionBuilder} This transaction builder + * Sets the gas sponsor for this transaction. + * The gas sponsor pays for transaction fees instead of the sender. + * @param sponsorAddress - The IOTA address sponsoring this transaction's gas fees + * @returns This transaction builder for method chaining */ gasSponsor(sponsorAddress: string): this { this.validateAddress({ address: sponsorAddress }); @@ -92,18 +101,35 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this; } + /** + * Adds a signature from the transaction sender. + * @param publicKey - The sender's public key + * @param signature - The signature bytes + * @throws BuildTransactionError if the signature or public key is invalid + */ addSignature(publicKey: PublicKey, signature: Buffer): void { - if (!utils.isValidPublicKey(publicKey.pub) || !utils.isValidSignature(toBase64(signature))) { - throw new BuildTransactionError('Invalid transaction signature'); - } + this.validateSignatureData(publicKey, signature); this.transaction.addSignature(publicKey, signature); } + /** + * Adds a signature from the gas sponsor. + * @param publicKey - The gas sponsor's public key + * @param signature - The signature bytes + * @throws BuildTransactionError if the signature or public key is invalid + */ addGasSponsorSignature(publicKey: PublicKey, signature: Buffer): void { + this.validateSignatureData(publicKey, signature); + this.transaction.addGasSponsorSignature(publicKey, signature); + } + + /** + * Validates that the signature and public key are in valid formats. + */ + private validateSignatureData(publicKey: PublicKey, signature: Buffer): void { if (!utils.isValidPublicKey(publicKey.pub) || !utils.isValidSignature(toBase64(signature))) { throw new BuildTransactionError('Invalid transaction signature'); } - this.transaction.addGasSponsorSignature(publicKey, signature); } validateKey(key: BaseKey): void { @@ -111,8 +137,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * @inheritdoc - * */ + * Validates an IOTA address format. + * @throws BuildTransactionError if address is invalid + */ validateAddress(address: BaseAddress, addressFormat?: string): void { if (!utils.isValidAddress(address.address)) { throw new BuildTransactionError('Invalid address ' + address.address); @@ -120,17 +147,22 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * @inheritdoc - * */ + * Validates that a numeric value is valid (not NaN and not negative). + * @throws BuildTransactionError if value is invalid + */ validateValue(value: BigNumber): void { if (value.isNaN()) { throw new BuildTransactionError('Invalid amount format'); - } else if (value.isLessThan(0)) { + } + if (value.isLessThan(0)) { throw new BuildTransactionError('Value cannot be less than zero'); } } - /** @inheritdoc */ + /** + * Validates that a raw transaction string is properly formatted. + * @throws ParseTransactionError if raw transaction is invalid + */ validateRawTransaction(rawTransaction: string): void { if (!rawTransaction) { throw new ParseTransactionError('Invalid raw transaction: Undefined'); @@ -141,26 +173,32 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * @inheritdoc - * */ + * Validates a transaction object has all required fields. + * @throws Error if transaction is undefined or has invalid data + */ validateTransaction(transaction?: Transaction): void { if (!transaction) { throw new Error('transaction not defined'); } + this.validateAddress({ address: transaction.sender }); this.validateGasData({ gasBudget: transaction.gasBudget, gasPrice: transaction.gasPrice, gasPaymentObjects: transaction.gasPaymentObjects, }); + if (transaction.gasSponsor) { this.validateAddress({ address: transaction.gasSponsor }); } } /** - * @inheritdoc - * */ + * Creates a transaction object from a raw transaction string or bytes. + * @param rawTransaction - Raw transaction in base64 string or Uint8Array format + * @returns The parsed transaction object + * @throws BuildTransactionError if raw transaction is invalid + */ fromImplementation(rawTransaction: string | Uint8Array): Transaction { if (!utils.isValidRawTransaction(rawTransaction)) { throw new BuildTransactionError('Invalid transaction'); @@ -170,32 +208,50 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } /** - * @inheritdoc - * */ + * Sign implementation - not supported for IOTA transactions. + * IOTA transactions must be signed externally. + */ protected signImplementation(key: BaseKey): BaseTransaction { throw new Error('Method not implemented.'); } /** - * @inheritdoc - * */ + * Builds the transaction and prepares it for broadcast. + * Automatically switches from simulate to real transaction mode if gas data is present. + */ protected async buildImplementation(): Promise { - // If gas data is provided, this is not a simulate transaction - if (this.transaction.gasPrice && this.transaction.gasBudget && this.transaction.gasPaymentObjects) { - this.transaction.isSimulateTx = false; - } + this.updateTransactionMode(); await this.transaction.build(); this.transaction.addInputsAndOutputs(); return this.transaction; } + /** + * Updates the transaction mode based on gas data availability. + * Switches to real transaction mode if all gas data is provided. + */ + private updateTransactionMode(): void { + const hasCompleteGasData = + this.transaction.gasPrice && this.transaction.gasBudget && this.transaction.gasPaymentObjects; + + if (hasCompleteGasData) { + this.transaction.isSimulateTx = false; + } + } + + /** + * Validates gas data values and presence. + * @throws BuildTransactionError if gas data is invalid + */ private validateGasData(gasData: GasData): void { if (gasData.gasBudget) { this.validateValue(new BigNumber(gasData.gasBudget)); } + if (gasData.gasPrice) { this.validateValue(new BigNumber(gasData.gasPrice)); } + if (gasData.gasPaymentObjects && gasData.gasPaymentObjects.length === 0) { throw new BuildTransactionError('Gas input objects list is empty'); } diff --git a/modules/sdk-coin-iota/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-iota/src/lib/transactionBuilderFactory.ts index 855a0d5689..17923f609e 100644 --- a/modules/sdk-coin-iota/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-iota/src/lib/transactionBuilderFactory.ts @@ -7,47 +7,139 @@ import utils from './utils'; import { Transaction as IotaTransaction } from '@iota/iota-sdk/transactions'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; +/** + * Factory class for creating IOTA transaction builders. + * + * This factory provides methods to create transaction builders for different + * transaction types and to reconstruct transactions from raw transaction data. + * + * @example + * ```typescript + * const factory = new TransactionBuilderFactory(coins.get('tiota')); + * + * // Create a new transfer builder + * const builder = factory.getTransferBuilder(); + * + * // Rebuild from raw transaction + * const rebuiltBuilder = factory.from(rawTxHex); + * ``` + */ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(coinConfig: Readonly) { super(coinConfig); } + /** + * Wallet initialization is not implemented for IOTA. + * IOTA wallets are initialized through the TSS flow. + * + * @throws Error always - not implemented + */ public getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); } - /** @inheritdoc */ + /** + * Creates a transfer transaction builder. + * Optionally initializes the builder with data from an existing transaction. + * + * @param tx - Optional existing transaction to initialize the builder + * @returns A new TransferBuilder instance + * + * @example + * ```typescript + * // Create a new transfer builder + * const builder = factory.getTransferBuilder(); + * + * // Initialize from existing transaction + * const existingTx = await builder.build(); + * const newBuilder = factory.getTransferBuilder(existingTx); + * ``` + */ getTransferBuilder(tx?: Transaction): TransferBuilder { return this.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); } + /** + * Reconstructs a transaction builder from raw transaction data. + * Automatically identifies the transaction type and creates the appropriate builder. + * + * @param rawTx - Raw transaction data (hex string or Uint8Array) + * @returns TransactionBuilder appropriate for the transaction type + * @throws InvalidTransactionError if the transaction type is not supported + * + * @example + * ```typescript + * // From hex string + * const builder = factory.from('0x1234...'); + * + * // From Uint8Array + * const builder = factory.from(new Uint8Array([...])); + * + * // Rebuild and access the transaction + * const tx = await builder.build(); + * ``` + */ from(rawTx: string | Uint8Array): TransactionBuilder { - let builder: TransactionBuilder; const rawTxBase64 = utils.getBase64String(rawTx); - const txType: TransactionType = this.identifyTxTypeFromRawTx(rawTxBase64); - switch (txType) { + const transactionType = this.identifyTransactionType(rawTxBase64); + + switch (transactionType) { case TransactionType.Send: - builder = new TransferBuilder(this._coinConfig); - builder.fromImplementation(rawTxBase64); - return builder; + return this.createTransferBuilderFromRawTx(rawTxBase64); + default: + throw new InvalidTransactionError(`Unsupported transaction type: ${transactionType}`); } - throw new InvalidTransactionError('Unsupported transaction'); } - private identifyTxTypeFromRawTx(rawTx: string | Uint8Array): TransactionType { + /** + * Identifies the transaction type by analyzing its commands. + * Currently supports transfer transactions (Send type). + * + * @param rawTx - Raw transaction in base64 format + * @returns The identified transaction type + * @throws InvalidTransactionError if transaction contains unsupported commands + */ + private identifyTransactionType(rawTx: string | Uint8Array): TransactionType { const txData = IotaTransaction.from(rawTx).getData(); - if (txData.commands.filter((command) => !TRANSFER_TRANSACTION_COMMANDS.includes(command.$kind)).length === 0) { + + if (this.isTransferTransaction(txData)) { return TransactionType.Send; } - throw new InvalidTransactionError('Unsupported commands in the transaction'); + + throw new InvalidTransactionError('Transaction contains unsupported commands'); + } + + /** + * Checks if a transaction is a transfer transaction by validating its commands. + * Transfer transactions only contain: SplitCoins, MergeCoins, and TransferObjects commands. + * + * @param txData - The parsed transaction data + * @returns true if all commands are valid transfer commands + */ + private isTransferTransaction(txData: ReturnType): boolean { + return txData.commands.every((command) => TRANSFER_TRANSACTION_COMMANDS.includes(command.$kind)); + } + + /** + * Creates a TransferBuilder from raw transaction data. + * + * @param rawTxBase64 - Raw transaction in base64 format + * @returns Initialized TransferBuilder + */ + private createTransferBuilderFromRawTx(rawTxBase64: string): TransferBuilder { + const builder = new TransferBuilder(this._coinConfig); + builder.fromImplementation(rawTxBase64); + return builder; } /** - * Initialize the builder with the given transaction + * Initializes a builder with data from an existing transaction. + * If no transaction is provided, returns the builder as-is. * - * @param {Transaction | undefined} tx - the transaction used to initialize the builder - * @param {TransactionBuilder} builder - the builder to be initialized - * @returns {TransactionBuilder} the builder initialized + * @param tx - Optional transaction to initialize from + * @param builder - The builder to initialize + * @returns The initialized builder */ private initializeBuilder(tx: Transaction | undefined, builder: T): T { if (tx) { diff --git a/modules/sdk-coin-iota/src/lib/transferBuilder.ts b/modules/sdk-coin-iota/src/lib/transferBuilder.ts index 07493f4029..e99d6f6719 100644 --- a/modules/sdk-coin-iota/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-iota/src/lib/transferBuilder.ts @@ -6,18 +6,35 @@ import BigNumber from 'bignumber.js'; import { Transaction } from './transaction'; import { TransactionObjectInput } from './iface'; +/** + * Builder for IOTA transfer transactions. + * Handles transactions that transfer IOTA tokens from the sender to one or more recipients. + */ export class TransferBuilder extends TransactionBuilder { constructor(_coinConfig: Readonly) { super(_coinConfig); this._transaction = new TransferTransaction(_coinConfig); } + /** + * Sets the recipients for this transfer transaction. + * @param recipients - Array of recipients with their addresses and amounts + * @returns This builder for method chaining + * @throws BuildTransactionError if any recipient is invalid + */ recipients(recipients: TransactionRecipient[]): this { this.validateRecipients(recipients); this.transferTransaction.recipients = recipients; return this; } + /** + * Sets the payment objects (coin objects) to be used for this transfer. + * These are the source coins that will be split and transferred to recipients. + * @param paymentObjects - Array of IOTA coin objects to use for payment + * @returns This builder for method chaining + * @throws BuildTransactionError if payment objects array is empty + */ paymentObjects(paymentObjects: TransactionObjectInput[]): this { if (paymentObjects.length === 0) { throw new BuildTransactionError('No Objects provided for payment'); @@ -26,31 +43,62 @@ export class TransferBuilder extends TransactionBuilder { return this; } + /** + * Initializes the builder with data from an existing transfer transaction. + * @param tx - The source transaction to copy data from + * @throws BuildTransactionError if transaction is not a TransferTransaction + */ initBuilder(tx: Transaction): void { + this.validateTransactionType(tx); + super.initBuilder(tx); + this.copyTransferData(tx as TransferTransaction); + } + + /** + * Validates that the transaction is of the correct type (TransferTransaction). + */ + private validateTransactionType(tx: Transaction): void { if (!(tx instanceof TransferTransaction)) { throw new BuildTransactionError('Transaction must be of type TransferTransaction'); } - super.initBuilder(tx); + } + + /** + * Copies transfer-specific data from source transaction. + */ + private copyTransferData(tx: TransferTransaction): void { this.transferTransaction.recipients = tx.recipients; this.transferTransaction.paymentObjects = tx.paymentObjects; } + /** + * Validates a complete transfer transaction. + * @param transaction - The transaction to validate + * @throws BuildTransactionError if transaction is invalid or not a TransferTransaction + */ validateTransaction(transaction?: Transaction): void { if (!transaction) { throw new BuildTransactionError('Transaction is required for validation'); } - if (!(transaction instanceof TransferTransaction)) { - throw new BuildTransactionError('Transaction must be of type TransferTransaction'); - } + + this.validateTransactionType(transaction); super.validateTransaction(transaction); - this.validateRecipients(transaction.recipients); + this.validateRecipients((transaction as TransferTransaction).recipients); } + /** + * Returns the transaction as a TransferTransaction type. + */ protected get transferTransaction(): TransferTransaction { return this._transaction as TransferTransaction; } - private validateRecipients(recipients: TransactionRecipient[]) { + /** + * Validates all recipients have valid addresses and amounts. + * @param recipients - Array of recipients to validate + * @throws BuildTransactionError if any recipient has invalid address or amount + */ + private validateRecipients(recipients: TransactionRecipient[]): void { recipients.forEach((recipient) => { this.validateAddress({ address: recipient.address }); this.validateValue(new BigNumber(recipient.amount)); diff --git a/modules/sdk-coin-iota/src/lib/transferTransaction.ts b/modules/sdk-coin-iota/src/lib/transferTransaction.ts index c9aeded5c8..fc39073674 100644 --- a/modules/sdk-coin-iota/src/lib/transferTransaction.ts +++ b/modules/sdk-coin-iota/src/lib/transferTransaction.ts @@ -80,22 +80,57 @@ export class TransferTransaction extends Transaction { this.updateIsSimulateTx(); } + /** + * Parses a transfer transaction from its broadcast format (base64 or raw bytes). + * Extracts recipients, amounts, and payment objects from the transaction data. + */ parseFromBroadcastTx(tx: string | Uint8Array): void { const txData = IotaTransaction.from(tx).getData(); - if (txData.commands.filter((command) => !TRANSFER_TRANSACTION_COMMANDS.includes(command.$kind)).length > 0) { + this.validateTransferCommands(txData); + + super.parseFromBroadcastTx(tx); + + const { inputObjects, amounts, receivers } = this.parseTransactionInputs(txData); + this.validateAmountsMatchReceivers(amounts, receivers); + this.assignParsedObjects(inputObjects); + this.assignRecipients(receivers, amounts); + this.updateIsSimulateTx(); + } + + /** + * Validates that the transaction only contains supported transfer commands. + */ + private validateTransferCommands(txData: ReturnType): void { + const hasUnsupportedCommands = txData.commands.some( + (command) => !TRANSFER_TRANSACTION_COMMANDS.includes(command.$kind) + ); + + if (hasUnsupportedCommands) { throw new InvalidTransactionError('Unsupported commands in the transaction'); } - super.parseFromBroadcastTx(tx); + } + + /** + * Parses transaction inputs to extract objects, amounts, and receiver addresses. + */ + private parseTransactionInputs(txData: ReturnType): { + inputObjects: TransactionObjectInput[]; + amounts: string[]; + receivers: string[]; + } { const inputObjects: TransactionObjectInput[] = []; const amounts: string[] = []; const receivers: string[] = []; + txData.inputs.forEach((input) => { if (input.$kind === 'Object' && 'ImmOrOwnedObject' in input.Object) { inputObjects.push(input.Object.ImmOrOwnedObject as TransactionObjectInput); } + if (input.$kind === 'Pure' && 'bytes' in input.Pure) { const value = fromBase64(input.Pure.bytes); const hexValue = '0x' + toHex(value); + if (utils.isValidAddress(hexValue)) { receivers.push(hexValue); } else { @@ -103,44 +138,94 @@ export class TransferTransaction extends Transaction { } } }); + + return { inputObjects, amounts, receivers }; + } + + /** + * Validates that the number of amounts matches the number of receivers. + */ + private validateAmountsMatchReceivers(amounts: string[], receivers: string[]): void { if (amounts.length !== receivers.length) { - throw new InvalidTransactionError('count of amounts does not match count of receivers'); + throw new InvalidTransactionError('Count of amounts does not match count of receivers'); + } + } + + /** + * Assigns parsed input objects to either gas payment objects or payment objects. + * If no gas objects exist and sender pays own gas, objects become gas objects. + * Otherwise, they become payment objects. + */ + private assignParsedObjects(inputObjects: TransactionObjectInput[]): void { + if (inputObjects.length === 0) { + return; } - if ( - (!this.gasPaymentObjects || this.gasPaymentObjects.length === 0) && - (!this.gasSponsor || this.sender === this.gasSponsor) - ) { + + const noGasObjectsExist = !this.gasPaymentObjects || this.gasPaymentObjects.length === 0; + const senderPaysOwnGas = !this.gasSponsor || this.sender === this.gasSponsor; + + if (noGasObjectsExist && senderPaysOwnGas) { this.gasPaymentObjects = inputObjects; - } else if (inputObjects.length > 0) { + } else { this._paymentObjects = inputObjects; } - this.recipients = []; - receivers.forEach((recipient, index) => { - this._recipients.push({ - address: recipient, - amount: amounts[index], - }); - }); - this.updateIsSimulateTx(); + } + + /** + * Creates and assigns recipients from parsed addresses and amounts. + */ + private assignRecipients(receivers: string[], amounts: string[]): void { + this._recipients = receivers.map((address, index) => ({ + address, + amount: amounts[index], + })); } protected messageWithIntent(message: Uint8Array): Uint8Array { return iotaMessageWithIntent('TransactionData', message); } + /** + * Populates the IOTA transaction with inputs and commands for the transfer. + * This determines which objects to use for payment (either payment objects or gas objects), + * consolidates them into a single coin, and then splits/transfers to recipients. + */ protected populateTxInputsAndCommands(): void { - const consolidatedCoin = this.shouldUseGasObjectsForPayment() - ? this.consolidateGasObjects() - : this.consolidatePaymentObjects(); - this.splitAndTransferToRecipients(consolidatedCoin); + const sourceCoin = this.getConsolidatedSourceCoin(); + this.splitAndTransferToRecipients(sourceCoin); } - private shouldUseGasObjectsForPayment(): boolean { - const paymentObjectExists = this.paymentObjects && this.paymentObjects.length > 0; + /** + * Determines which objects to use as the payment source and consolidates them. + * If payment objects are provided, use those. Otherwise, if the sender is paying + * their own gas, use the gas objects for payment. + */ + private getConsolidatedSourceCoin(): TransactionObjectArgument { + if (this.hasPaymentObjects()) { + return this.consolidatePaymentObjects(); + } + return this.consolidateGasObjects(); + } + + /** + * Checks if payment objects exist and if gas objects should be used instead. + * Gas objects are used when: no payment objects exist AND sender pays their own gas. + */ + private hasPaymentObjects(): boolean { + const hasPaymentObjects = this.paymentObjects && this.paymentObjects.length > 0; + if (hasPaymentObjects) { + return true; + } + + // If no payment objects, only use gas objects if sender pays own gas const senderPaysOwnGas = !this.gasSponsor || this.gasSponsor === this.sender; - return !paymentObjectExists && senderPaysOwnGas && Boolean(this.gasPaymentObjects); + return !senderPaysOwnGas; } + /** + * Consolidates payment objects into a single coin object. + * If multiple payment objects exist, they are merged in batches. + */ private consolidatePaymentObjects(): TransactionObjectArgument { if (!this.paymentObjects || this.paymentObjects.length === 0) { throw new InvalidTransactionError('Payment objects are required'); @@ -148,13 +233,19 @@ export class TransferTransaction extends Transaction { const firstObject = this._iotaTransaction.object(IotaInputs.ObjectRef(this.paymentObjects[0])); + // Single object doesn't need consolidation if (this.paymentObjects.length === 1) { return firstObject; } + // Merge remaining objects into the first one return this.mergeObjectsInBatches(firstObject, this.paymentObjects.slice(1), MAX_INPUT_OBJECTS); } + /** + * Consolidates gas payment objects into a single coin object. + * If the number of gas objects exceeds the maximum, they are merged in batches. + */ private consolidateGasObjects(): TransactionObjectArgument { if (!this.gasPaymentObjects || this.gasPaymentObjects.length === 0) { throw new InvalidTransactionError('Gas payment objects are required'); @@ -162,13 +253,24 @@ export class TransferTransaction extends Transaction { const gasObject = this._iotaTransaction.gas; + // If within the limit, no consolidation needed if (this.gasPaymentObjects.length <= MAX_GAS_PAYMENT_OBJECTS) { return gasObject; } + // Merge excess gas objects to stay within limits return this.mergeObjectsInBatches(gasObject, this.gasPaymentObjects, MAX_INPUT_OBJECTS); } + /** + * Merges multiple coin objects into a target coin in batches. + * This is necessary because IOTA has limits on the number of objects per merge command. + * + * @param targetCoin - The coin to merge into + * @param objectsToMerge - Array of objects to merge + * @param batchSize - Maximum number of objects to merge per batch + * @returns The consolidated coin object + */ private mergeObjectsInBatches( targetCoin: TransactionObjectArgument, objectsToMerge: TransactionObjectInput[], @@ -186,6 +288,11 @@ export class TransferTransaction extends Transaction { return consolidatedCoin; } + /** + * Splits the source coin into the amounts needed for each recipient and transfers them. + * This creates split coin commands for all recipient amounts, then transfer commands + * to send each split coin to the corresponding recipient. + */ private splitAndTransferToRecipients(sourceCoin: TransactionObjectArgument): void { const recipientAmounts = this._recipients.map((recipient) => recipient.amount); const splitCoins = this._iotaTransaction.splitCoins(sourceCoin, recipientAmounts); @@ -195,8 +302,21 @@ export class TransferTransaction extends Transaction { }); } + /** + * Validates all transfer transaction data before building. + * Checks recipients, payment objects, and ensures no duplicate object IDs. + */ protected validateTxDataImplementation(): void { - if (!this.recipients || this.recipients?.length === 0) { + this.validateRecipientsList(); + this.validatePaymentObjectsExist(); + this.validateNoDuplicateObjects(); + } + + /** + * Validates that recipients exist and don't exceed the maximum allowed. + */ + private validateRecipientsList(): void { + if (!this.recipients || this.recipients.length === 0) { throw new InvalidTransactionError('Transaction recipients are required'); } @@ -205,55 +325,80 @@ export class TransferTransaction extends Transaction { `Recipients count (${this.recipients.length}) exceeds maximum allowed (${MAX_RECIPIENTS})` ); } + } + + /** + * Validates that either payment objects or gas objects exist for funding the transfer. + * When a gas sponsor is used, payment objects are required. + * Otherwise, either payment objects or gas objects can be used. + */ + private validatePaymentObjectsExist(): void { + const hasPaymentObjects = this.paymentObjects && this.paymentObjects.length > 0; + if (hasPaymentObjects) { + return; // Payment objects exist, validation passes + } - if (!this.paymentObjects || this.paymentObjects?.length === 0) { - if (!this.gasSponsor || this.gasSponsor === this.sender) { - if (!this.gasPaymentObjects || this.gasPaymentObjects?.length === 0) { - throw new InvalidTransactionError('Payment or Gas objects are required'); - } - } else { - throw new InvalidTransactionError('Payment objects are required'); - } + // No payment objects - check if gas objects can be used instead + const hasGasSponsor = this.gasSponsor && this.gasSponsor !== this.sender; + if (hasGasSponsor) { + throw new InvalidTransactionError('Payment objects are required when using a gas sponsor'); } - // Check for duplicate object IDs in payment objects + // No gas sponsor - gas objects must exist + const hasGasObjects = this.gasPaymentObjects && this.gasPaymentObjects.length > 0; + if (!hasGasObjects) { + throw new InvalidTransactionError('Payment or Gas objects are required'); + } + } + + /** + * Validates that there are no duplicate object IDs within payment objects, + * gas payment objects, or between the two groups. + */ + private validateNoDuplicateObjects(): void { const paymentObjectIds = this.paymentObjects?.map((obj) => obj.objectId) ?? []; - const uniquePaymentIds = new Set(paymentObjectIds); - if (uniquePaymentIds.size !== paymentObjectIds.length) { - throw new InvalidTransactionError('Duplicate object IDs found in payment objects'); + + // Check for duplicates within payment objects + this.checkForDuplicateIds(paymentObjectIds, 'payment objects'); + + if (!this.gasPaymentObjects || this.gasPaymentObjects.length === 0) { + return; } - if (this.gasPaymentObjects && this.gasPaymentObjects.length > 0) { - const gasObjectIds = this.gasPaymentObjects.map((gas) => gas.objectId); + const gasObjectIds = this.gasPaymentObjects.map((gas) => gas.objectId); - // Check for duplicate object IDs in gas payment objects - const uniqueGasIds = new Set(gasObjectIds); - if (uniqueGasIds.size !== gasObjectIds.length) { - throw new InvalidTransactionError('Duplicate object IDs found in gas payment objects'); - } + // Check for duplicates within gas payment objects + this.checkForDuplicateIds(gasObjectIds, 'gas payment objects'); - const duplicates = paymentObjectIds.filter((payment) => gasObjectIds.includes(payment)); - if (duplicates.length > 0) { - throw new InvalidTransactionError( - 'Payment objects cannot be the same as gas payment objects: ' + duplicates.join(', ') - ); - } + // Check for overlaps between payment and gas objects + const overlappingIds = paymentObjectIds.filter((id) => gasObjectIds.includes(id)); + if (overlappingIds.length > 0) { + throw new InvalidTransactionError( + 'Payment objects cannot be the same as gas payment objects: ' + overlappingIds.join(', ') + ); + } + } + + /** + * Helper to check for duplicate IDs in an array. + */ + private checkForDuplicateIds(ids: string[], objectType: string): void { + const uniqueIds = new Set(ids); + if (uniqueIds.size !== ids.length) { + throw new InvalidTransactionError(`Duplicate object IDs found in ${objectType}`); } } /** * @inheritDoc + * Provides a human-readable explanation of the transfer transaction. */ protected explainTransactionImplementation( - json: TxData, + _json: TxData, explanationResult: TransactionExplanation ): TransactionExplanation { - const outputs: TransactionRecipient[] = this.recipients.map((recipient) => recipient); - const outputAmountBN = this.recipients.reduce( - (accumulator, current) => accumulator.plus(current.amount), - new BigNumber(0) - ); - const outputAmount = outputAmountBN.toString(); + const outputs = this.recipients.map((recipient) => recipient); + const outputAmount = this.calculateTotalOutputAmount(); return { ...explanationResult, @@ -261,4 +406,13 @@ export class TransferTransaction extends Transaction { outputs, }; } + + /** + * Calculates the total amount being transferred to all recipients. + */ + private calculateTotalOutputAmount(): string { + return this.recipients + .reduce((accumulator, current) => accumulator.plus(current.amount), new BigNumber(0)) + .toString(); + } } diff --git a/modules/sdk-coin-iota/test/unit/helpers/testHelpers.ts b/modules/sdk-coin-iota/test/unit/helpers/testHelpers.ts new file mode 100644 index 0000000000..3d03a13c8f --- /dev/null +++ b/modules/sdk-coin-iota/test/unit/helpers/testHelpers.ts @@ -0,0 +1,146 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, TransferBuilder, TransferTransaction } from '../../../src'; +import * as testData from '../../resources/iota'; +import { TransactionRecipient } from '@bitgo/sdk-core'; +import { TransactionObjectInput } from '../../../src/lib/iface'; +import should from 'should'; + +/** + * Common test helpers and fixtures for IOTA transaction tests. + * Reduces duplication and improves test readability. + */ + +const factory = new TransactionBuilderFactory(coins.get('tiota')); + +/** + * Creates a basic transfer builder with sender, recipients, and payment objects. + */ +export function createBasicTransferBuilder(): TransferBuilder { + return factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .paymentObjects(testData.paymentObjects); +} + +/** + * Creates a transfer builder with gas data for real (non-simulate) transactions. + */ +export function createTransferBuilderWithGas(): TransferBuilder { + return createBasicTransferBuilder().gasData(testData.gasData); +} + +/** + * Creates a transfer builder with a gas sponsor. + */ +export function createTransferBuilderWithSponsor(): TransferBuilder { + return createTransferBuilderWithGas().gasSponsor(testData.gasSponsor.address); +} + +/** + * Creates a builder with custom recipients. + */ +export function createBuilderWithRecipients(recipients: TransactionRecipient[]): TransferBuilder { + return factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(recipients) + .paymentObjects(testData.paymentObjects) + .gasData(testData.gasData); +} + +/** + * Creates a builder that uses gas objects for payment (no payment objects). + */ +export function createBuilderWithGasObjectsOnly(): TransferBuilder { + return factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .gasData(testData.gasData); +} + +/** + * Creates a builder with custom gas objects. + */ +export function createBuilderWithGasObjects(gasObjects: TransactionObjectInput[]): TransferBuilder { + return factory.getTransferBuilder().sender(testData.sender.address).recipients(testData.recipients).gasData({ + gasBudget: testData.GAS_BUDGET, + gasPrice: testData.GAS_PRICE, + gasPaymentObjects: gasObjects, + }); +} + +/** + * Asserts that a transaction has the expected basic properties. + */ +export function assertBasicTransactionProperties(tx: TransferTransaction): void { + should.exist(tx); + should.equal(tx.sender, testData.sender.address); + should.deepEqual(tx.recipients, testData.recipients); + should.deepEqual(tx.paymentObjects, testData.paymentObjects); +} + +/** + * Asserts that a transaction has valid gas data. + */ +export function assertGasData(tx: TransferTransaction): void { + should.equal(tx.gasBudget, testData.GAS_BUDGET); + should.equal(tx.gasPrice, testData.GAS_PRICE); + should.deepEqual(tx.gasPaymentObjects, testData.gasPaymentObjects); +} + +/** + * Asserts that a transaction has the expected inputs and outputs. + */ +export function assertInputsAndOutputs(tx: TransferTransaction, expectedRecipients: TransactionRecipient[]): void { + const totalAmount = expectedRecipients.reduce((sum, r) => sum + Number(r.amount), 0); + + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender.address, + value: totalAmount.toString(), + coin: 'tiota', + }); + + tx.outputs.length.should.equal(expectedRecipients.length); + expectedRecipients.forEach((recipient, index) => { + tx.outputs[index].should.deepEqual({ + address: recipient.address, + value: recipient.amount, + coin: 'tiota', + }); + }); +} + +/** + * Verifies that a raw transaction is valid. + */ +export async function assertValidRawTransaction(tx: TransferTransaction): Promise { + const rawTx = await tx.toBroadcastFormat(); + should.exist(rawTx); + should.equal(typeof rawTx, 'string'); + should.equal(rawTx.length > 0, true); +} + +/** + * Creates an array of recipients with the specified count and amount. + */ +export function createRecipients(count: number, amount = '1000'): TransactionRecipient[] { + return Array.from({ length: count }, (_, i) => ({ + address: testData.addresses.validAddresses[i % testData.addresses.validAddresses.length], + amount, + })); +} + +/** + * Gets the transaction factory instance. + */ +export function getFactory(): TransactionBuilderFactory { + return factory; +} + +/** + * Test data re-exports for convenience. + */ +export { testData }; diff --git a/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilder.ts b/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilder.ts index a7e4503f7f..83bd776ee9 100644 --- a/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilder.ts +++ b/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilder.ts @@ -1,302 +1,203 @@ import should from 'should'; -import { coins } from '@bitgo/statics'; -import { TransactionBuilderFactory, TransferTransaction } from '../../../src'; -import * as testData from '../../resources/iota'; +import { TransferTransaction } from '../../../src'; import BigNumber from 'bignumber.js'; +import { createBasicTransferBuilder, createTransferBuilderWithGas, getFactory, testData } from '../helpers/testHelpers'; describe('Iota Transaction Builder', () => { - const factory = new TransactionBuilderFactory(coins.get('tiota')); + const factory = getFactory(); - describe('Validation Methods', () => { - describe('validateAddress', () => { - it('should validate correct addresses', function () { - const builder = factory.getTransferBuilder(); - should.doesNotThrow(() => builder.validateAddress({ address: testData.sender.address })); - should.doesNotThrow(() => builder.validateAddress({ address: testData.addresses.validAddresses[0] })); - }); - - it('should throw error for invalid address', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateAddress({ address: 'invalidAddress' })).throwError( - 'Invalid address invalidAddress' - ); - }); - - it('should throw error for empty address', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateAddress({ address: '' })).throwError(/Invalid address/); - }); - - it('should validate addresses with 0x prefix', function () { - const builder = factory.getTransferBuilder(); - testData.addresses.validAddresses.forEach((address) => { - should.doesNotThrow(() => builder.validateAddress({ address })); - }); - }); - - it('should reject addresses without proper format', function () { - const builder = factory.getTransferBuilder(); - testData.addresses.invalidAddresses.forEach((address) => { - should(() => builder.validateAddress({ address })).throwError(); - }); + describe('Address Validation', () => { + it('should validate correct addresses', function () { + const builder = factory.getTransferBuilder(); + testData.addresses.validAddresses.forEach((address) => { + should.doesNotThrow(() => builder.validateAddress({ address })); }); }); - describe('validateValue', () => { - it('should validate positive values', function () { - const builder = factory.getTransferBuilder(); - should.doesNotThrow(() => builder.validateValue(new BigNumber(1000))); - should.doesNotThrow(() => builder.validateValue(new BigNumber(1))); - should.doesNotThrow(() => builder.validateValue(new BigNumber(999999999))); + it('should reject invalid addresses', function () { + const builder = factory.getTransferBuilder(); + testData.addresses.invalidAddresses.forEach((address) => { + should(() => builder.validateAddress({ address })).throwError(); }); + }); - it('should validate zero value', function () { - const builder = factory.getTransferBuilder(); - should.doesNotThrow(() => builder.validateValue(new BigNumber(0))); - }); + it('should throw error for empty address', function () { + const builder = factory.getTransferBuilder(); + should(() => builder.validateAddress({ address: '' })).throwError(/Invalid address/); + }); + }); - it('should throw error for negative values', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateValue(new BigNumber(-1))).throwError('Value cannot be less than zero'); - should(() => builder.validateValue(new BigNumber(-1000))).throwError('Value cannot be less than zero'); - }); + describe('Value Validation', () => { + const validCases = [ + { value: new BigNumber(0), description: 'zero value' }, + { value: new BigNumber(1000), description: 'positive values' }, + { value: new BigNumber('1000000000000000000'), description: 'large values' }, + { value: new BigNumber(100.5), description: 'decimal values' }, + ]; - it('should throw error for NaN values', function () { + validCases.forEach(({ value, description }) => { + it(`should validate ${description}`, function () { const builder = factory.getTransferBuilder(); - should(() => builder.validateValue(new BigNumber(NaN))).throwError('Invalid amount format'); + should.doesNotThrow(() => builder.validateValue(value)); }); + }); - it('should validate large values', function () { - const builder = factory.getTransferBuilder(); - should.doesNotThrow(() => builder.validateValue(new BigNumber('1000000000000000000'))); - }); + const invalidCases = [ + { value: new BigNumber(-1), error: 'Value cannot be less than zero', description: 'negative values' }, + { value: new BigNumber(NaN), error: 'Invalid amount format', description: 'NaN values' }, + ]; - it('should validate decimal values', function () { + invalidCases.forEach(({ value, error, description }) => { + it(`should reject ${description}`, function () { const builder = factory.getTransferBuilder(); - should.doesNotThrow(() => builder.validateValue(new BigNumber(100.5))); - should.doesNotThrow(() => builder.validateValue(new BigNumber(0.001))); + should(() => builder.validateValue(value)).throwError(error); }); }); + }); - describe('validateRawTransaction', () => { - it('should validate proper raw transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = await txBuilder.build(); - const rawTx = await tx.toBroadcastFormat(); - - const newBuilder = factory.getTransferBuilder(); - should.doesNotThrow(() => newBuilder.validateRawTransaction(rawTx)); - }); + describe('Raw Transaction Validation', () => { + it('should validate proper raw transaction', async function () { + const tx = await createTransferBuilderWithGas().build(); + const rawTx = await tx.toBroadcastFormat(); - it('should throw error for invalid raw transaction', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateRawTransaction('invalidRawTx')).throwError('Invalid raw transaction'); - }); + const newBuilder = factory.getTransferBuilder(); + should.doesNotThrow(() => newBuilder.validateRawTransaction(rawTx)); + }); - it('should throw error for empty raw transaction', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateRawTransaction('')).throwError('Invalid raw transaction: Undefined'); - }); + const invalidRawTxCases = [ + { value: 'invalidRawTx', error: 'Invalid raw transaction', description: 'invalid string' }, + { value: '', error: 'Invalid raw transaction: Undefined', description: 'empty string' }, + { value: undefined as any, error: 'Invalid raw transaction: Undefined', description: 'undefined' }, + ]; - it('should throw error for undefined raw transaction', function () { + invalidRawTxCases.forEach(({ value, error, description }) => { + it(`should reject ${description}`, function () { const builder = factory.getTransferBuilder(); - should(() => builder.validateRawTransaction(undefined as any)).throwError('Invalid raw transaction: Undefined'); + should(() => builder.validateRawTransaction(value)).throwError(error); }); }); + }); - describe('validateTransaction', () => { - it('should validate complete transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - const newBuilder = factory.getTransferBuilder(); - should.doesNotThrow(() => newBuilder.validateTransaction(tx)); - }); - - it('should throw error for undefined transaction', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.validateTransaction(undefined)).throwError(/Transaction is required for validation/); - }); + describe('Transaction Validation', () => { + it('should validate complete transaction', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + const newBuilder = factory.getTransferBuilder(); + should.doesNotThrow(() => newBuilder.validateTransaction(tx)); + }); - it('should throw error for transaction with invalid sender', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should throw error for undefined transaction', function () { + const builder = factory.getTransferBuilder(); + should(() => builder.validateTransaction(undefined)).throwError(/Transaction is required for validation/); + }); - const tx = (await txBuilder.build()) as TransferTransaction; - tx.sender = 'invalidAddress'; + it('should throw error for transaction with invalid sender', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + tx.sender = 'invalidAddress'; - const newBuilder = factory.getTransferBuilder(); - should(() => newBuilder.validateTransaction(tx)).throwError(); - }); + const newBuilder = factory.getTransferBuilder(); + should(() => newBuilder.validateTransaction(tx)).throwError(); }); }); - describe('Builder Methods', () => { - describe('sender', () => { - it('should set sender correctly', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - should.equal(builder.transaction.sender, testData.sender.address); - }); + describe('Builder Setter Methods', () => { + it('should set and change sender correctly', function () { + const builder = factory.getTransferBuilder(); + builder.sender(testData.sender.address); + should.equal(builder.transaction.sender, testData.sender.address); - it('should allow changing sender', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.sender(testData.gasSponsor.address); - should.equal(builder.transaction.sender, testData.gasSponsor.address); - }); + builder.sender(testData.gasSponsor.address); + should.equal(builder.transaction.sender, testData.gasSponsor.address); + }); - it('should validate sender address', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.sender('invalidAddress')).throwError('Invalid address invalidAddress'); - }); + it('should validate sender address when setting', function () { + const builder = factory.getTransferBuilder(); + should(() => builder.sender('invalidAddress')).throwError('Invalid address invalidAddress'); }); - describe('gasData', () => { - it('should set all gas parameters', function () { - const builder = factory.getTransferBuilder(); - builder.gasData(testData.gasData); + it('should set all gas parameters', function () { + const builder = factory.getTransferBuilder(); + builder.gasData(testData.gasData); - should.equal(builder.transaction.gasBudget, testData.GAS_BUDGET); - should.equal(builder.transaction.gasPrice, testData.GAS_PRICE); - should.deepEqual(builder.transaction.gasPaymentObjects, testData.gasPaymentObjects); - }); + should.equal(builder.transaction.gasBudget, testData.GAS_BUDGET); + should.equal(builder.transaction.gasPrice, testData.GAS_PRICE); + should.deepEqual(builder.transaction.gasPaymentObjects, testData.gasPaymentObjects); + }); - it('should validate gas budget', function () { - const builder = factory.getTransferBuilder(); - should(() => - builder.gasData({ - gasBudget: -1000, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: testData.gasPaymentObjects, - }) - ).throwError(); - }); + const invalidGasDataCases = [ + { gasBudget: -1000, description: 'negative gas budget' }, + { gasPrice: -100, description: 'negative gas price' }, + ]; - it('should validate gas price', function () { + invalidGasDataCases.forEach(({ gasBudget, gasPrice, description }) => { + it(`should reject ${description}`, function () { const builder = factory.getTransferBuilder(); should(() => builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: -100, + gasBudget: gasBudget ?? testData.GAS_BUDGET, + gasPrice: gasPrice ?? testData.GAS_PRICE, gasPaymentObjects: testData.gasPaymentObjects, }) ).throwError(); }); - - it('should throw error for empty gas payment objects', function () { - const builder = factory.getTransferBuilder(); - should(() => - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: [], - }) - ).throwError('Gas input objects list is empty'); - }); }); - describe('gasSponsor', () => { - it('should set gas sponsor correctly', function () { - const builder = factory.getTransferBuilder(); - builder.gasSponsor(testData.gasSponsor.address); - should.equal(builder.transaction.gasSponsor, testData.gasSponsor.address); - }); + it('should throw error for empty gas payment objects', function () { + const builder = factory.getTransferBuilder(); + should(() => + builder.gasData({ + gasBudget: testData.GAS_BUDGET, + gasPrice: testData.GAS_PRICE, + gasPaymentObjects: [], + }) + ).throwError('Gas input objects list is empty'); + }); - it('should validate gas sponsor address', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.gasSponsor('invalidAddress')).throwError('Invalid address invalidAddress'); - }); + it('should set gas sponsor correctly', function () { + const builder = factory.getTransferBuilder(); + builder.gasSponsor(testData.gasSponsor.address); + should.equal(builder.transaction.gasSponsor, testData.gasSponsor.address); + }); - it('should allow sender to be gas sponsor', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.gasSponsor(testData.sender.address); - should.equal(builder.transaction.gasSponsor, testData.sender.address); - }); + it('should validate gas sponsor address', function () { + const builder = factory.getTransferBuilder(); + should(() => builder.gasSponsor('invalidAddress')).throwError('Invalid address invalidAddress'); }); - describe('initBuilder', () => { - it('should initialize builder from existing transaction', async function () { - const originalBuilder = factory.getTransferBuilder(); - originalBuilder.sender(testData.sender.address); - originalBuilder.recipients(testData.recipients); - originalBuilder.paymentObjects(testData.paymentObjects); - originalBuilder.gasData(testData.gasData); + it('should allow sender to be gas sponsor', function () { + const builder = factory.getTransferBuilder(); + builder.sender(testData.sender.address); + builder.gasSponsor(testData.sender.address); + should.equal(builder.transaction.gasSponsor, testData.sender.address); + }); + }); - const originalTx = (await originalBuilder.build()) as TransferTransaction; + describe('Builder Initialization', () => { + it('should initialize builder from existing transaction', async function () { + const originalTx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + const newBuilder = factory.getTransferBuilder(originalTx); - const newBuilder = factory.getTransferBuilder(originalTx); - should.equal((newBuilder.transaction as TransferTransaction).sender, originalTx.sender); - should.equal((newBuilder.transaction as TransferTransaction).gasBudget, originalTx.gasBudget); - }); + should.equal((newBuilder.transaction as TransferTransaction).sender, originalTx.sender); + should.equal((newBuilder.transaction as TransferTransaction).gasBudget, originalTx.gasBudget); }); }); describe('Transaction Building', () => { it('should build transaction with all required fields', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; should.exist(tx); should.equal(tx.sender, testData.sender.address); + should.equal(tx.isSimulateTx, false); }); it('should build simulation transaction without gas data', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); + const tx = (await createBasicTransferBuilder().build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; should.exist(tx); should.equal(tx.isSimulateTx, true); }); - it('should set isSimulateTx to false when gas data provided', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.isSimulateTx, false); - }); - }); - - describe('Builder Chaining', () => { it('should support method chaining', async function () { - const tx = (await factory - .getTransferBuilder() - .sender(testData.sender.address) - .recipients(testData.recipients) - .paymentObjects(testData.paymentObjects) - .gasData(testData.gasData) - .build()) as TransferTransaction; - - should.exist(tx); - should.equal(tx.sender, testData.sender.address); - }); - - it('should support chaining with gas sponsor', async function () { const tx = (await factory .getTransferBuilder() .sender(testData.sender.address) @@ -307,18 +208,14 @@ describe('Iota Transaction Builder', () => { .build()) as TransferTransaction; should.exist(tx); + should.equal(tx.sender, testData.sender.address); should.equal(tx.gasSponsor, testData.gasSponsor.address); }); }); describe('Transaction Type', () => { it('should return correct transaction type', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - + const builder = createTransferBuilderWithGas(); const tx = await builder.build(); should.equal(builder.transactionType, tx.type); }); diff --git a/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilderFactory.ts b/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilderFactory.ts index e7aa2acb70..a5850cf709 100644 --- a/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilderFactory.ts +++ b/modules/sdk-coin-iota/test/unit/transactionBuilder/transactionBuilderFactory.ts @@ -2,6 +2,7 @@ import should from 'should'; import { coins } from '@bitgo/statics'; import { TransactionBuilderFactory, TransferBuilder, TransferTransaction } from '../../../src'; import * as testData from '../../resources/iota'; +import { createTransferBuilderWithGas } from '../helpers/testHelpers'; describe('Iota Transaction Builder Factory', () => { const factory = new TransactionBuilderFactory(coins.get('tiota')); @@ -14,12 +15,7 @@ describe('Iota Transaction Builder Factory', () => { }); it('should create a transfer builder with existing transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - + const txBuilder = createTransferBuilderWithGas(); const tx = (await txBuilder.build()) as TransferTransaction; const newBuilder = factory.getTransferBuilder(tx); @@ -33,50 +29,36 @@ describe('Iota Transaction Builder Factory', () => { }); describe('from', () => { - it('should create builder from raw transaction data', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - const txHex = Buffer.from(rawTx, 'base64').toString('hex'); - - const rebuiltBuilder = factory.from(txHex); - should.exist(rebuiltBuilder); - should(rebuiltBuilder instanceof TransferBuilder).be.true(); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - rebuiltTx.sender.should.equal(testData.sender.address); - rebuiltTx.recipients.length.should.equal(testData.recipients.length); - rebuiltTx.recipients[0].address.should.equal(testData.recipients[0].address); - rebuiltTx.recipients[0].amount.should.equal(testData.recipients[0].amount); - should.exist(rebuiltTx.paymentObjects); - rebuiltTx.paymentObjects?.length.should.equal(testData.paymentObjects.length); - }); - - it('should handle Uint8Array format', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - const rawTxBytes = Buffer.from(rawTx, 'base64'); - - const rebuiltBuilder = factory.from(rawTxBytes); - should.exist(rebuiltBuilder); - should(rebuiltBuilder instanceof TransferBuilder).be.true(); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - rebuiltTx.sender.should.equal(testData.sender.address); - rebuiltTx.recipients.length.should.equal(testData.recipients.length); - rebuiltTx.recipients[0].address.should.equal(testData.recipients[0].address); - rebuiltTx.recipients[0].amount.should.equal(testData.recipients[0].amount); - should.exist(rebuiltTx.paymentObjects); - rebuiltTx.paymentObjects?.length.should.equal(testData.paymentObjects.length); + const formats = [ + { + name: 'hex string', + convert: (rawTx: string) => Buffer.from(rawTx, 'base64').toString('hex'), + }, + { + name: 'Uint8Array', + convert: (rawTx: string) => Buffer.from(rawTx, 'base64'), + }, + ]; + + formats.forEach(({ name, convert }) => { + it(`should create builder from raw transaction in ${name} format`, async function () { + const txBuilder = createTransferBuilderWithGas(); + const tx = (await txBuilder.build()) as TransferTransaction; + const rawTx = await tx.toBroadcastFormat(); + const convertedTx = convert(rawTx); + + const rebuiltBuilder = factory.from(convertedTx); + should.exist(rebuiltBuilder); + should(rebuiltBuilder instanceof TransferBuilder).be.true(); + + const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; + rebuiltTx.sender.should.equal(testData.sender.address); + rebuiltTx.recipients.length.should.equal(testData.recipients.length); + rebuiltTx.recipients[0].address.should.equal(testData.recipients[0].address); + rebuiltTx.recipients[0].amount.should.equal(testData.recipients[0].amount); + should.exist(rebuiltTx.paymentObjects); + rebuiltTx.paymentObjects?.length.should.equal(testData.paymentObjects.length); + }); }); }); @@ -102,17 +84,11 @@ describe('Iota Transaction Builder Factory', () => { }); describe('Transaction Rebuilding and Modification', () => { - it('should allow creating transactions with modifications', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - + it('should allow creating transactions with modified gas budget', async function () { + const txBuilder = createTransferBuilderWithGas(); const tx = (await txBuilder.build()) as TransferTransaction; should.equal(tx.gasBudget, testData.GAS_BUDGET); - // Create a new builder with modified gas budget const modifiedBuilder = factory.getTransferBuilder(); modifiedBuilder.sender(testData.sender.address); modifiedBuilder.recipients(testData.recipients); @@ -127,64 +103,30 @@ describe('Iota Transaction Builder Factory', () => { should.equal(modifiedTx.gasBudget, testData.GAS_BUDGET * 2); }); - it('should allow adding gas sponsor to transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should build transaction with gas sponsor', async function () { + const txBuilder = createTransferBuilderWithGas(); txBuilder.gasSponsor(testData.gasSponsor.address); const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.sender, testData.sender.address); should.equal(tx.gasSponsor, testData.gasSponsor.address); + should.notEqual(tx.sender, tx.gasSponsor); }); it('should maintain transaction ID for identical transactions', async function () { - const txBuilder1 = factory.getTransferBuilder(); - txBuilder1.sender(testData.sender.address); - txBuilder1.recipients(testData.recipients); - txBuilder1.paymentObjects(testData.paymentObjects); - txBuilder1.gasData(testData.gasData); - + const txBuilder1 = createTransferBuilderWithGas(); const tx1 = (await txBuilder1.build()) as TransferTransaction; - const id1 = tx1.id; - - const txBuilder2 = factory.getTransferBuilder(); - txBuilder2.sender(testData.sender.address); - txBuilder2.recipients(testData.recipients); - txBuilder2.paymentObjects(testData.paymentObjects); - txBuilder2.gasData(testData.gasData); + const txBuilder2 = createTransferBuilderWithGas(); const tx2 = (await txBuilder2.build()) as TransferTransaction; - const id2 = tx2.id; - should.equal(id1, id2); - }); - - it('should correctly build transaction with gas sponsor', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - const tx = (await txBuilder.build()) as TransferTransaction; - - should.equal(tx.sender, testData.sender.address); - should.equal(tx.gasSponsor, testData.gasSponsor.address); - should.notEqual(tx.sender, tx.gasSponsor); + should.equal(tx1.id, tx2.id); }); }); describe('Builder Initialization', () => { it('should initialize builder with transaction correctly', async function () { - const originalBuilder = factory.getTransferBuilder(); - originalBuilder.sender(testData.sender.address); - originalBuilder.recipients(testData.recipients); - originalBuilder.paymentObjects(testData.paymentObjects); - originalBuilder.gasData(testData.gasData); - + const originalBuilder = createTransferBuilderWithGas(); const originalTx = (await originalBuilder.build()) as TransferTransaction; const newBuilder = factory.getTransferBuilder(originalTx); @@ -195,22 +137,11 @@ describe('Iota Transaction Builder Factory', () => { should.equal(newTx.gasBudget, originalTx.gasBudget); should.equal(newTx.gasPrice, originalTx.gasPrice); }); - - it('should allow creating builder without initial transaction', function () { - const builder = factory.getTransferBuilder(); - should.exist(builder); - should(builder instanceof TransferBuilder).be.true(); - }); }); describe('Round Trip Conversion', () => { it('should handle JSON round trip', async function () { - const originalBuilder = factory.getTransferBuilder(); - originalBuilder.sender(testData.sender.address); - originalBuilder.recipients(testData.recipients); - originalBuilder.paymentObjects(testData.paymentObjects); - originalBuilder.gasData(testData.gasData); - + const originalBuilder = createTransferBuilderWithGas(); const originalTx = (await originalBuilder.build()) as TransferTransaction; const json = originalTx.toJson(); @@ -224,21 +155,11 @@ describe('Iota Transaction Builder Factory', () => { }); it('should serialize to consistent format', async function () { - const builder1 = factory.getTransferBuilder(); - builder1.sender(testData.sender.address); - builder1.recipients(testData.recipients); - builder1.paymentObjects(testData.paymentObjects); - builder1.gasData(testData.gasData); - + const builder1 = createTransferBuilderWithGas(); const tx1 = (await builder1.build()) as TransferTransaction; const serialized1 = await tx1.toBroadcastFormat(); - const builder2 = factory.getTransferBuilder(); - builder2.sender(testData.sender.address); - builder2.recipients(testData.recipients); - builder2.paymentObjects(testData.paymentObjects); - builder2.gasData(testData.gasData); - + const builder2 = createTransferBuilderWithGas(); const tx2 = (await builder2.build()) as TransferTransaction; const serialized2 = await tx2.toBroadcastFormat(); diff --git a/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts index 35d6f3807a..75f36b6946 100644 --- a/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts @@ -1,131 +1,91 @@ import should from 'should'; -import { coins } from '@bitgo/statics'; -import { TransactionBuilderFactory, TransferTransaction } from '../../../src'; -import * as testData from '../../resources/iota'; import { TransactionType } from '@bitgo/sdk-core'; -import utils from '../../../src/lib/utils'; import { MAX_GAS_PAYMENT_OBJECTS, MAX_RECIPIENTS } from '../../../src/lib/constants'; import { TransactionObjectInput } from '../../../src/lib/iface'; +import { + createBasicTransferBuilder, + createTransferBuilderWithGas, + createTransferBuilderWithSponsor, + createBuilderWithRecipients, + createBuilderWithGasObjectsOnly, + createBuilderWithGasObjects, + assertBasicTransactionProperties, + assertGasData, + assertInputsAndOutputs, + assertValidRawTransaction, + createRecipients, + getFactory, + testData, +} from '../helpers/testHelpers'; +import { TransferTransaction } from '../../../src'; describe('Iota Transfer Builder', () => { - const factory = new TransactionBuilderFactory(coins.get('tiota')); - - describe('Succeed', () => { - it('should build a transfer tx with simulate mode', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); + const factory = getFactory(); + describe('Basic Transaction Building', () => { + it('should build a transfer transaction in simulate mode', async function () { + const txBuilder = createBasicTransferBuilder(); const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.type, TransactionType.Send); - should.equal(tx.sender, testData.sender.address); should.equal(tx.isSimulateTx, true); - should.deepEqual(tx.recipients, testData.recipients); - should.deepEqual(tx.paymentObjects, testData.paymentObjects); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + assertBasicTransactionProperties(tx); + await assertValidRawTransaction(tx); }); - it('should build a transfer tx with gas data', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - + it('should build a transfer transaction with gas data', async function () { + const txBuilder = createTransferBuilderWithGas(); const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.type, TransactionType.Send); - should.equal(tx.sender, testData.sender.address); should.equal(tx.isSimulateTx, false); - should.equal(tx.gasBudget, testData.GAS_BUDGET); - should.equal(tx.gasPrice, testData.GAS_PRICE); - should.deepEqual(tx.gasPaymentObjects, testData.gasPaymentObjects); - - tx.inputs.length.should.equal(1); - tx.inputs[0].should.deepEqual({ - address: testData.sender.address, - value: (testData.AMOUNT + testData.AMOUNT * 2).toString(), - coin: 'tiota', - }); - - tx.outputs.length.should.equal(2); - tx.outputs[0].should.deepEqual({ - address: testData.recipients[0].address, - value: testData.recipients[0].amount, - coin: 'tiota', - }); - tx.outputs[1].should.deepEqual({ - address: testData.recipients[1].address, - value: testData.recipients[1].amount, - coin: 'tiota', - }); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + assertBasicTransactionProperties(tx); + assertGasData(tx); + assertInputsAndOutputs(tx, testData.recipients); + await assertValidRawTransaction(tx); }); - it('should build a transfer tx with gas sponsor', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - + it('should build a transfer transaction with gas sponsor', async function () { + const txBuilder = createTransferBuilderWithSponsor(); const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.type, TransactionType.Send); should.equal(tx.sender, testData.sender.address); should.equal(tx.gasSponsor, testData.gasSponsor.address); should.equal(tx.isSimulateTx, false); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + await assertValidRawTransaction(tx); }); - it('should build a transfer tx with multiple recipients', async function () { - const amount = 1000; - const numberOfRecipients = 10; - - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - - const recipients = new Array(numberOfRecipients).fill({ - address: testData.addresses.validAddresses[0], - amount: amount.toString(), - }); - - txBuilder.recipients(recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - + it('should build transaction using gas objects when no payment objects', async function () { + const txBuilder = createBuilderWithGasObjectsOnly(); const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.inputs.length.should.equal(1); - tx.inputs[0].should.deepEqual({ - address: testData.sender.address, - value: (amount * numberOfRecipients).toString(), - coin: 'tiota', - }); - tx.outputs.length.should.equal(numberOfRecipients); - tx.outputs.forEach((output) => - output.should.deepEqual({ - address: testData.addresses.validAddresses[0], - value: amount.toString(), - coin: 'tiota', - }) - ); + should.equal(tx.type, TransactionType.Send); + should.equal(tx.isSimulateTx, false); + should.equal(tx.paymentObjects, undefined); + should.exist(tx.gasPaymentObjects); + await assertValidRawTransaction(tx); }); - it('should parse from JSON and rebuild transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should support method chaining', async function () { + const tx = (await factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .paymentObjects(testData.paymentObjects) + .gasData(testData.gasData) + .gasSponsor(testData.gasSponsor.address) + .build()) as TransferTransaction; + + should.exist(tx); + should.equal(tx.sender, testData.sender.address); + should.equal(tx.gasSponsor, testData.gasSponsor.address); + }); + }); + describe('Serialization and Deserialization', () => { + it('should serialize to JSON and rebuild transaction', async function () { + const txBuilder = createTransferBuilderWithGas(); const tx = (await txBuilder.build()) as TransferTransaction; const txJson = tx.toJson(); @@ -140,52 +100,17 @@ describe('Iota Transfer Builder', () => { }); it('should serialize to broadcast format consistently', async function () { - const txBuilder1 = factory.getTransferBuilder(); - txBuilder1.sender(testData.sender.address); - txBuilder1.recipients(testData.recipients); - txBuilder1.paymentObjects(testData.paymentObjects); - txBuilder1.gasData(testData.gasData); - - const tx1 = (await txBuilder1.build()) as TransferTransaction; + const tx1 = (await createTransferBuilderWithGas().build()) as TransferTransaction; const rawTx1 = await tx1.toBroadcastFormat(); - const txBuilder2 = factory.getTransferBuilder(); - txBuilder2.sender(testData.sender.address); - txBuilder2.recipients(testData.recipients); - txBuilder2.paymentObjects(testData.paymentObjects); - txBuilder2.gasData(testData.gasData); - - const tx2 = (await txBuilder2.build()) as TransferTransaction; + const tx2 = (await createTransferBuilderWithGas().build()) as TransferTransaction; const rawTx2 = await tx2.toBroadcastFormat(); should.equal(rawTx1, rawTx2); - should.equal(tx1.type, TransactionType.Send); - }); - - it('should build tx with signable payload', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.isSimulateTx, false); - - const signablePayload = tx.signablePayload; - should.exist(signablePayload); - should.equal(Buffer.isBuffer(signablePayload), true); - should.equal(signablePayload.length, 32); // Blake2b hash is 32 bytes }); it('should validate toJSON output', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const txJson = tx.toJson(); should.equal(txJson.sender, testData.sender.address); @@ -198,839 +123,402 @@ describe('Iota Transfer Builder', () => { }); }); - describe('Fail', () => { - it('should fail for invalid sender', function () { - const builder = factory.getTransferBuilder(); - should(() => builder.sender('randomString')).throwError('Invalid address randomString'); - }); + describe('Round-trip Parsing', () => { + it('should correctly parse and rebuild transaction using gas objects', async function () { + const tx = (await createBuilderWithGasObjectsOnly().build()) as TransferTransaction; + const rawTx = await tx.toBroadcastFormat(); + const txHex = Buffer.from(rawTx, 'base64').toString('hex'); - it('should fail for invalid recipient address', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - should(() => builder.recipients([{ address: 'invalidAddress', amount: '1000' }])).throwError( - 'Invalid address invalidAddress' - ); - }); + const rebuiltBuilder = factory.from(txHex); + const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - it('should fail for invalid recipient amount', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - should(() => builder.recipients([{ address: testData.addresses.validAddresses[0], amount: '-1000' }])).throwError( - 'Value cannot be less than zero' - ); + should.equal(rebuiltTx.sender, tx.sender); + should.deepEqual(rebuiltTx.recipients, tx.recipients); + should.equal(rebuiltTx.gasBudget, tx.gasBudget); + should.equal(rebuiltTx.gasPrice, tx.gasPrice); + should.equal(rebuiltTx.paymentObjects, undefined); + should.exist(rebuiltTx.gasPaymentObjects); }); - it('should fail for empty recipients during build', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients([]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + it('should correctly parse transaction with payment objects', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + const rawTx = await tx.toBroadcastFormat(); + const txHex = Buffer.from(rawTx, 'base64').toString('hex'); - await builder.build().should.be.rejected(); - }); + const rebuiltBuilder = factory.from(txHex); + const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - it('should fail for empty payment objects', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - should(() => builder.paymentObjects([])).throwError('No Objects provided for payment'); + should.exist(rebuiltTx.paymentObjects); + should.equal(rebuiltTx.paymentObjects?.length, testData.paymentObjects.length); }); - it('should fail when gas payment objects exceed maximum', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - - const tooManyGasObjects = testData.generateObjects(MAX_GAS_PAYMENT_OBJECTS + 1); - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: tooManyGasObjects, - }); + it('should handle round-trip with gas sponsor and payment objects', async function () { + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; + const rawTx = await tx.toBroadcastFormat(); + const txHex = Buffer.from(rawTx, 'base64').toString('hex'); - await builder.build().should.be.rejected(); + const rebuiltBuilder = factory.from(txHex); + const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; + + should.equal(rebuiltTx.gasSponsor, testData.gasSponsor.address); + should.exist(rebuiltTx.paymentObjects); }); + }); - it('should fail to build without sender', async function () { - const builder = factory.getTransferBuilder(); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + describe('Recipient Handling', () => { + const testCases = [ + { count: 1, description: 'single recipient' }, + { count: 10, description: 'multiple recipients' }, + { count: MAX_RECIPIENTS, description: 'maximum recipients' }, + ]; + + testCases.forEach(({ count, description }) => { + it(`should build transaction with ${description}`, async function () { + const recipients = createRecipients(count, '1000'); + const tx = (await createBuilderWithRecipients(recipients).build()) as TransferTransaction; + + should.equal(tx.type, TransactionType.Send); + tx.outputs.length.should.equal(count); + tx.outputs.forEach((output) => { + output.value.should.equal('1000'); + output.coin?.should.equal('tiota'); + }); + }); + }); + it('should fail with more than MAX_RECIPIENTS', async function () { + const recipients = createRecipients(MAX_RECIPIENTS + 1); + const builder = createBuilderWithRecipients(recipients); await builder.build().should.be.rejected(); }); - it('should fail to build without recipients', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + it('should handle duplicate recipient addresses', async function () { + const duplicateRecipients = [ + { address: testData.addresses.validAddresses[0], amount: '1000' }, + { address: testData.addresses.validAddresses[0], amount: '2000' }, + ]; + const tx = (await createBuilderWithRecipients(duplicateRecipients).build()) as TransferTransaction; - await builder.build().should.be.rejected(); + should.equal(tx.type, TransactionType.Send); + tx.outputs.length.should.equal(2); }); + }); - it('should fail to build without payment objects when using gas sponsor', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - builder.gasSponsor(testData.gasSponsor.address); // Gas sponsor requires payment objects + describe('Gas Object Handling', () => { + it('should build with single gas object', async function () { + const tx = (await createBuilderWithGasObjects([testData.gasPaymentObjects[0]]).build()) as TransferTransaction; - await builder.build().should.be.rejected(); + should.equal(tx.type, TransactionType.Send); + should.exist(tx.gasPaymentObjects); + tx.gasPaymentObjects?.length.should.equal(1); + await assertValidRawTransaction(tx); }); - it('should build transfer using gas objects when no payment objects and sender pays own gas', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - // No payment objects, no gas sponsor - should use gas objects for payment + it('should build with exactly MAX_GAS_PAYMENT_OBJECTS gas objects', async function () { + // Create exactly MAX_GAS_PAYMENT_OBJECTS (256) with valid digests + const exactlyMaxGasObjects: TransactionObjectInput[] = []; + for (let i = 0; i < MAX_GAS_PAYMENT_OBJECTS; i++) { + exactlyMaxGasObjects.push({ + ...testData.gasPaymentObjects[i % testData.gasPaymentObjects.length], + objectId: `0x${i.toString(16).padStart(64, '0')}`, + }); + } + + const tx = (await createBuilderWithGasObjects(exactlyMaxGasObjects).build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; should.equal(tx.type, TransactionType.Send); should.equal(tx.sender, testData.sender.address); - should.equal(tx.isSimulateTx, false); - should.equal(tx.gasBudget, testData.GAS_BUDGET); - should.equal(tx.gasPrice, testData.GAS_PRICE); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + await assertValidRawTransaction(tx); }); - it('should build transfer using many gas objects requiring merge', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - + it('should build with more than MAX_GAS_PAYMENT_OBJECTS requiring merge', async function () { // Create more than MAX_GAS_PAYMENT_OBJECTS (256) to test merge logic - // Use duplicate of valid gas payment objects to have valid digests const manyGasObjects: TransactionObjectInput[] = []; for (let i = 0; i < MAX_GAS_PAYMENT_OBJECTS + 10; i++) { manyGasObjects.push({ ...testData.gasPaymentObjects[i % testData.gasPaymentObjects.length], - objectId: `0x${i.toString(16).padStart(64, '0')}`, // Unique object IDs + objectId: `0x${i.toString(16).padStart(64, '0')}`, }); } - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: manyGasObjects, - }); + const tx = (await createBuilderWithGasObjects(manyGasObjects).build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; should.equal(tx.type, TransactionType.Send); - should.equal(tx.sender, testData.sender.address); should.equal(tx.isSimulateTx, false); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); - }); - - it('should fail when no payment objects and no gas payment objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - - // Should fail during gasData() call due to validation - should(() => - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: [], // Empty gas payment objects - }) - ).throwError(/Gas input objects list is empty/); + await assertValidRawTransaction(tx); }); + }); - it('should fail to get signable payload for simulate tx', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); + describe('Payment Object Handling', () => { + it('should build with single payment object', async function () { + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .paymentObjects([testData.paymentObjects[0]]) + .gasData(testData.gasData); const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.isSimulateTx, true); - - should(() => tx.signablePayload).throwError('Cannot sign a simulate tx'); + should.equal(tx.type, TransactionType.Send); + tx.paymentObjects?.length.should.equal(1); + await assertValidRawTransaction(tx); }); - it('should fail when payment and gas payment objects overlap', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - // Use same objects for payment and gas - builder.paymentObjects(testData.gasPaymentObjects); - builder.gasData(testData.gasData); + it('should keep payment and gas objects separate', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; - await builder.build().should.be.rejected(); + should.exist(tx.paymentObjects); + should.exist(tx.gasPaymentObjects); + tx.paymentObjects?.length.should.equal(testData.paymentObjects.length); + tx.gasPaymentObjects?.length.should.equal(testData.gasPaymentObjects.length); }); - it('should fail to parse invalid raw transaction', function () { - should(() => factory.from('invalidRawTransaction')).throwError(); - }); - }); + it('should handle payment objects with different versions', async function () { + const mixedVersionObjects: TransactionObjectInput[] = [ + { + objectId: '0x1111111111111111111111111111111111111111111111111111111111111111', + version: '1', + digest: 'DGVhYjk6YHwdPdZBgBN8czavy8LvbrshkbxF963EW7mB', + }, + { + objectId: '0x2222222222222222222222222222222222222222222222222222222222222222', + version: '999999', + digest: 'DoJwXuz9oU5Y5v5vBRiTgisVTQuZQLmHZWeqJzzD5QUE', + }, + ]; - describe('Round-trip with Gas Objects', () => { - it('should correctly parse and rebuild transaction using gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - // No payment objects - using gas objects for payment + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .paymentObjects(mixedVersionObjects) + .gasData(testData.gasData); const tx = (await builder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - const txHex = Buffer.from(rawTx, 'base64').toString('hex'); - - // Parse and rebuild - const rebuiltBuilder = factory.from(txHex); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - - // Verify properties match - should.equal(rebuiltTx.sender, tx.sender); - should.deepEqual(rebuiltTx.recipients, tx.recipients); - should.equal(rebuiltTx.gasBudget, tx.gasBudget); - should.equal(rebuiltTx.gasPrice, tx.gasPrice); - - // Verify it was correctly identified as gas object transaction - should.equal(rebuiltTx.paymentObjects, undefined); - should.exist(rebuiltTx.gasPaymentObjects); + should.equal(tx.type, TransactionType.Send); + tx.paymentObjects?.length.should.equal(2); }); + }); - it('should correctly parse and rebuild gas object transaction via JSON', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - // No payment objects - using gas objects for payment + describe('Validation Errors', () => { + describe('Address Validation', () => { + it('should fail for invalid sender', function () { + const builder = factory.getTransferBuilder(); + should(() => builder.sender('randomString')).throwError('Invalid address randomString'); + }); - const tx = (await builder.build()) as TransferTransaction; - const txJson = tx.toJson(); + it('should fail for invalid recipient address', function () { + const builder = createBasicTransferBuilder(); + should(() => builder.recipients([{ address: 'invalidAddress', amount: '1000' }])).throwError( + 'Invalid address invalidAddress' + ); + }); + }); - // Parse from JSON and rebuild - const rebuiltBuilder = factory.getTransferBuilder(); - (rebuiltBuilder.transaction as TransferTransaction).parseFromJSON(txJson); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; + describe('Amount Validation', () => { + it('should fail for negative amount', function () { + const builder = createBasicTransferBuilder(); + should(() => + builder.recipients([{ address: testData.addresses.validAddresses[0], amount: '-1000' }]) + ).throwError('Value cannot be less than zero'); + }); - // Verify properties match - should.equal(rebuiltTx.sender, tx.sender); - should.deepEqual(rebuiltTx.recipients, tx.recipients); - should.equal(rebuiltTx.gasBudget, tx.gasBudget); - should.equal(rebuiltTx.gasPrice, tx.gasPrice); + it('should fail for invalid format', function () { + const builder = createBasicTransferBuilder(); + should(() => + builder.recipients([{ address: testData.addresses.validAddresses[0], amount: 'invalid' }]) + ).throw(); + }); - // Verify it was correctly identified as gas object transaction - should.equal(rebuiltTx.paymentObjects, undefined); - should.exist(rebuiltTx.gasPaymentObjects); - }); + it('should accept zero amount', async function () { + const recipients = [{ address: testData.addresses.validAddresses[0], amount: '0' }]; + const tx = (await createBuilderWithRecipients(recipients).build()) as TransferTransaction; - it('should correctly parse transaction with payment objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + should.equal(tx.type, TransactionType.Send); + tx.outputs[0].value.should.equal('0'); + }); - const tx = (await builder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - const txHex = Buffer.from(rawTx, 'base64').toString('hex'); + it('should accept very large amount', async function () { + const largeAmount = '999999999999999999'; + const recipients = [{ address: testData.addresses.validAddresses[0], amount: largeAmount }]; + const tx = (await createBuilderWithRecipients(recipients).build()) as TransferTransaction; - // Parse and rebuild - const rebuiltBuilder = factory.from(txHex); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - - // Verify it has payment objects - should.exist(rebuiltTx.paymentObjects); - should.equal(rebuiltTx.paymentObjects?.length, testData.paymentObjects.length); + should.equal(tx.type, TransactionType.Send); + tx.outputs[0].value.should.equal(largeAmount); + }); }); - it('should handle round-trip with gas sponsor and payment objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - builder.gasSponsor(testData.gasSponsor.address); + describe('Required Field Validation', () => { + it('should fail when missing sender', async function () { + const builder = factory + .getTransferBuilder() + .recipients(testData.recipients) + .paymentObjects(testData.paymentObjects) + .gasData(testData.gasData); - const tx = (await builder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - const txHex = Buffer.from(rawTx, 'base64').toString('hex'); + await builder.build().should.be.rejected(); + }); - // Parse and rebuild - const rebuiltBuilder = factory.from(txHex); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; + it('should fail when missing recipients', async function () { + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .paymentObjects(testData.paymentObjects) + .gasData(testData.gasData); - // Verify gas sponsor is preserved - should.equal(rebuiltTx.gasSponsor, testData.gasSponsor.address); - should.exist(rebuiltTx.paymentObjects); - }); - }); + await builder.build().should.be.rejected(); + }); - describe('Boundary Tests', () => { - it('should build with exactly MAX_GAS_PAYMENT_OBJECTS gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); + it('should fail for empty recipients', async function () { + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients([]) + .paymentObjects(testData.paymentObjects) + .gasData(testData.gasData); - // Create exactly MAX_GAS_PAYMENT_OBJECTS (256) - const exactlyMaxGasObjects: TransactionObjectInput[] = []; - for (let i = 0; i < MAX_GAS_PAYMENT_OBJECTS; i++) { - exactlyMaxGasObjects.push({ - ...testData.gasPaymentObjects[i % testData.gasPaymentObjects.length], - objectId: `0x${i.toString(16).padStart(64, '0')}`, - }); - } + await builder.build().should.be.rejected(); + }); - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: exactlyMaxGasObjects, + it('should fail for empty payment objects', function () { + const builder = createBasicTransferBuilder(); + should(() => builder.paymentObjects([])).throwError('No Objects provided for payment'); }); - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - should.equal(tx.sender, testData.sender.address); + it('should fail without payment objects when using gas sponsor', async function () { + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .gasData(testData.gasData) + .gasSponsor(testData.gasSponsor.address); - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + await builder.build().should.be.rejected(); + }); }); - it('should build with single payment object (no merge needed)', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects([testData.paymentObjects[0]]); // Only one - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.paymentObjects?.length.should.equal(1); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + describe('Gas Data Validation', () => { + it('should fail for empty gas payment objects', function () { + const builder = createBasicTransferBuilder(); + should(() => + builder.gasData({ + gasBudget: testData.GAS_BUDGET, + gasPrice: testData.GAS_PRICE, + gasPaymentObjects: [], + }) + ).throwError(/Gas input objects list is empty/); + }); }); - it('should build with two payment objects (simple merge)', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); // Two objects - builder.gasData(testData.gasData); + describe('Object Duplication Validation', () => { + it('should fail when payment and gas payment objects overlap', async function () { + const builder = factory + .getTransferBuilder() + .sender(testData.sender.address) + .recipients(testData.recipients) + .paymentObjects(testData.gasPaymentObjects) + .gasData(testData.gasData); - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.paymentObjects?.length.should.equal(2); + await builder.build().should.be.rejected(); + }); + }); - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); + describe('Transaction Parsing Validation', () => { + it('should fail to parse invalid raw transaction', function () { + should(() => factory.from('invalidRawTransaction')).throwError(); + }); }); }); describe('Transaction Signing', () => { it('should build transaction with sender signature', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add signature + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); - // Rebuild to trigger serialization await tx.build(); - should.exist(tx.serializedSignature); should.equal(tx.serializedSignature!.length > 0, true); }); it('should build transaction with gas sponsor signature', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add gas sponsor signature + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); - // Rebuild to trigger serialization await tx.build(); - should.exist(tx.serializedGasSponsorSignature); should.equal(tx.serializedGasSponsorSignature!.length > 0, true); }); it('should build transaction with both sender and gas sponsor signatures', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add both signatures tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); - // Rebuild to trigger serialization await tx.build(); - should.exist(tx.serializedSignature); should.exist(tx.serializedGasSponsorSignature); tx.signature.length.should.equal(2); + tx.signature[0].should.equal(tx.serializedSignature); + tx.signature[1].should.equal(tx.serializedGasSponsorSignature); }); it('should add signature through builder and serialize correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - // Add signature through builder + const txBuilder = createTransferBuilderWithGas(); txBuilder.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); const tx = (await txBuilder.build()) as TransferTransaction; - should.exist(tx.serializedSignature); should.equal(typeof tx.serializedSignature, 'string'); - // Verify signature array is populated tx.signature.length.should.equal(1); }); - it('should add gas sponsor signature through builder and serialize correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - // Add gas sponsor signature through builder - txBuilder.addGasSponsorSignature( - testData.testGasSponsorSignature.publicKey, - testData.testGasSponsorSignature.signature - ); - - const tx = (await txBuilder.build()) as TransferTransaction; - - should.exist(tx.serializedGasSponsorSignature); - should.equal(typeof tx.serializedGasSponsorSignature, 'string'); - // Verify signature array is populated - tx.signature.length.should.equal(1); - }); - - it('should serialize signatures in correct order', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - // Add signatures through builder - txBuilder.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); - txBuilder.addGasSponsorSignature( - testData.testGasSponsorSignature.publicKey, - testData.testGasSponsorSignature.signature - ); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Verify signatures are in correct order: sender first, gas sponsor second - tx.signature.length.should.equal(2); - tx.signature[0].should.equal(tx.serializedSignature); - tx.signature[1].should.equal(tx.serializedGasSponsorSignature); - }); - it('should fail to add invalid sender signature via builder', function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - // Builder should validate and throw when adding invalid signature + const txBuilder = createTransferBuilderWithGas(); should(() => txBuilder.addSignature({ pub: 'tooshort' }, testData.testSignature.signature)).throwError( 'Invalid transaction signature' ); }); it('should fail to add invalid gas sponsor signature via builder', function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - // Builder should validate and throw when adding invalid signature + const txBuilder = createTransferBuilderWithSponsor(); should(() => txBuilder.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, Buffer.from('invalid')) ).throwError('Invalid transaction signature'); }); }); - describe('Gas Object Edge Cases', () => { - it('should build with single gas object', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData({ - gasBudget: testData.GAS_BUDGET, - gasPrice: testData.GAS_PRICE, - gasPaymentObjects: [testData.gasPaymentObjects[0]], - }); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - should.exist(tx.gasPaymentObjects); - tx.gasPaymentObjects?.length.should.equal(1); - - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); - }); - - it('should build with multiple gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - should.exist(tx.gasPaymentObjects); - tx.gasPaymentObjects?.length.should.equal(testData.gasPaymentObjects.length); - }); - - it('should successfully build with gas objects and single recipient', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients([testData.recipients[0]]); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs.length.should.equal(1); - tx.outputs[0].should.deepEqual({ - address: testData.recipients[0].address, - value: testData.recipients[0].amount, - coin: 'tiota', - }); - }); - - it('should use gas objects when payment objects are undefined', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - // Explicitly not setting payment objects - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.paymentObjects, undefined); - should.exist(tx.gasPaymentObjects); - should.equal(tx.type, TransactionType.Send); - }); - - it('should validate gas budget is set when using gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.exist(tx.gasBudget); - should.equal(tx.gasBudget, testData.GAS_BUDGET); - }); - - it('should validate gas price is set when using gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.exist(tx.gasPrice); - should.equal(tx.gasPrice, testData.GAS_PRICE); - }); - }); - - describe('Recipient Validation Tests', () => { - it('should build with single recipient', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: '5000', - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs.length.should.equal(1); - tx.outputs[0].value.should.equal('5000'); - }); - - it('should fail with more than MAX_RECIPIENTS', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - - const tooManyRecipients = new Array(MAX_RECIPIENTS + 1).fill({ - address: testData.addresses.validAddresses[0], - amount: '100', - }); - - builder.recipients(tooManyRecipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - await builder.build().should.be.rejected(); - }); + describe('Signable Payload', () => { + it('should generate signable payload for non-simulate transaction', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; - it('should build with MAX_RECIPIENTS exactly', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - - const maxRecipients = new Array(MAX_RECIPIENTS).fill({ - address: testData.addresses.validAddresses[0], - amount: '100', - }); - - builder.recipients(maxRecipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs.length.should.equal(MAX_RECIPIENTS); - }); - - it('should fail when recipient address is same as sender', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - // Using sender address as recipient - should still work as it's technically valid - builder.recipients([ - { - address: testData.sender.address, - amount: '1000', - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); + should.equal(tx.isSimulateTx, false); + const signablePayload = tx.signablePayload; + should.exist(signablePayload); + should.equal(Buffer.isBuffer(signablePayload), true); + should.equal(signablePayload.length, 32); // Blake2b hash is 32 bytes }); - it('should handle duplicate recipient addresses', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: '1000', - }, - { - address: testData.addresses.validAddresses[0], - amount: '2000', - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); + it('should fail to get signable payload for simulate transaction', async function () { + const tx = (await createBasicTransferBuilder().build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs.length.should.equal(2); + should.equal(tx.isSimulateTx, true); + should(() => tx.signablePayload).throwError('Cannot sign a simulate tx'); }); }); - describe('Amount Validation Tests', () => { - it('should fail with negative amount', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - should(() => - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: '-100', - }, - ]) - ).throwError('Value cannot be less than zero'); - }); - - it('should build with zero amount', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: '0', - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs[0].value.should.equal('0'); - }); - - it('should build with very large amount', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - const largeAmount = '999999999999999999'; - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: largeAmount, - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.outputs[0].value.should.equal(largeAmount); - }); - + describe('Input and Output Calculation', () => { it('should calculate total input amount correctly for multiple recipients', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - const amount1 = '1000'; - const amount2 = '2000'; - const amount3 = '3000'; - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: amount1, - }, - { - address: testData.addresses.validAddresses[1], - amount: amount2, - }, - { - address: testData.addresses.validAddresses[2], - amount: amount3, - }, - ]); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - const expectedTotal = (Number(amount1) + Number(amount2) + Number(amount3)).toString(); - tx.inputs[0].value.should.equal(expectedTotal); - }); - - it('should fail with invalid amount format', function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - should(() => - builder.recipients([ - { - address: testData.addresses.validAddresses[0], - amount: 'invalid', - }, - ]) - ).throwError(); - }); - }); - - describe('Payment and Gas Object Interaction Tests', () => { - it('should keep payment and gas objects separate', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - should.exist(tx.paymentObjects); - should.exist(tx.gasPaymentObjects); - // Verify they are different - tx.paymentObjects?.length.should.equal(testData.paymentObjects.length); - tx.gasPaymentObjects?.length.should.equal(testData.gasPaymentObjects.length); - }); - - it('should handle payment objects with different versions', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - const mixedVersionObjects: TransactionObjectInput[] = [ - { - objectId: '0x1111111111111111111111111111111111111111111111111111111111111111', - version: '1', - digest: 'DGVhYjk6YHwdPdZBgBN8czavy8LvbrshkbxF963EW7mB', - }, - { - objectId: '0x2222222222222222222222222222222222222222222222222222222222222222', - version: '999999', - digest: 'DoJwXuz9oU5Y5v5vBRiTgisVTQuZQLmHZWeqJzzD5QUE', - }, + const recipients = [ + { address: testData.addresses.validAddresses[0], amount: '1000' }, + { address: testData.addresses.validAddresses[1], amount: '2000' }, + { address: testData.addresses.validAddresses[2], amount: '3000' }, ]; - builder.paymentObjects(mixedVersionObjects); - builder.gasData(testData.gasData); + const tx = (await createBuilderWithRecipients(recipients).build()) as TransferTransaction; - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.type, TransactionType.Send); - tx.paymentObjects?.length.should.equal(2); - }); - - it('should serialize and parse transaction with both payment and gas objects', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - - const tx = (await builder.build()) as TransferTransaction; - const rawTx = await tx.toBroadcastFormat(); - should.equal(utils.isValidRawTransaction(rawTx), true); - - // Parse it back - const rebuiltBuilder = factory.from(Buffer.from(rawTx, 'base64').toString('hex')); - const rebuiltTx = (await rebuiltBuilder.build()) as TransferTransaction; - - should.exist(rebuiltTx.paymentObjects); - should.exist(rebuiltTx.gasPaymentObjects); - }); - - it('should fail when using same object ID in payment and gas', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - // Use same object IDs for payment and gas - builder.paymentObjects(testData.gasPaymentObjects); - builder.gasData(testData.gasData); - - await builder.build().should.be.rejected(); - }); - - it('should handle gas sponsor with payment objects correctly', async function () { - const builder = factory.getTransferBuilder(); - builder.sender(testData.sender.address); - builder.recipients(testData.recipients); - builder.paymentObjects(testData.paymentObjects); - builder.gasData(testData.gasData); - builder.gasSponsor(testData.gasSponsor.address); - - const tx = (await builder.build()) as TransferTransaction; - should.equal(tx.gasSponsor, testData.gasSponsor.address); - should.exist(tx.paymentObjects); - should.exist(tx.gasPaymentObjects); - should.equal(tx.sender, testData.sender.address); - should.notEqual(tx.sender, tx.gasSponsor); + const expectedTotal = '6000'; + tx.inputs[0].value.should.equal(expectedTotal); + tx.outputs.length.should.equal(3); }); }); }); diff --git a/modules/sdk-coin-iota/test/unit/transferTransaction.ts b/modules/sdk-coin-iota/test/unit/transferTransaction.ts index 14f09827f6..4fcaa832bf 100644 --- a/modules/sdk-coin-iota/test/unit/transferTransaction.ts +++ b/modules/sdk-coin-iota/test/unit/transferTransaction.ts @@ -1,84 +1,48 @@ import should from 'should'; import { coins } from '@bitgo/statics'; -import { TransactionBuilderFactory, TransferTransaction } from '../../src'; -import * as testData from '../resources/iota'; import { TransactionType } from '@bitgo/sdk-core'; +import { TransferTransaction } from '../../src'; +import { + createBasicTransferBuilder, + createTransferBuilderWithGas, + createTransferBuilderWithSponsor, + testData, +} from './helpers/testHelpers'; describe('Iota Transfer Transaction', () => { - const factory = new TransactionBuilderFactory(coins.get('tiota')); - describe('Transaction Properties', () => { - it('should have correct transaction type', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); + it('should have correct basic properties', async function () { + const tx = (await createBasicTransferBuilder().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; should.equal(tx.type, TransactionType.Send); - }); - - it('should have correct sender', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - - const tx = (await txBuilder.build()) as TransferTransaction; should.equal(tx.sender, testData.sender.address); - }); - - it('should have correct recipients', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - - const tx = (await txBuilder.build()) as TransferTransaction; should.deepEqual(tx.recipients, testData.recipients); - }); - - it('should have correct payment objects', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - - const tx = (await txBuilder.build()) as TransferTransaction; should.deepEqual(tx.paymentObjects, testData.paymentObjects); + should.equal(tx.isSimulateTx, true); }); - it('should be in simulate mode by default', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); + it('should switch to real transaction mode when gas data is provided', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.isSimulateTx, true); + should.equal(tx.isSimulateTx, false); + should.equal(tx.gasBudget, testData.GAS_BUDGET); + should.equal(tx.gasPrice, testData.GAS_PRICE); + should.deepEqual(tx.gasPaymentObjects, testData.gasPaymentObjects); + should.equal(tx.getFee(), testData.GAS_BUDGET.toString()); }); - it('should not be in simulate mode when gas data is provided', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should correctly set gas sponsor', async function () { + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.isSimulateTx, false); + should.equal(tx.gasSponsor, testData.gasSponsor.address); + should.equal(tx.sender, testData.sender.address); + should.notEqual(tx.sender, tx.gasSponsor); }); }); - describe('Transaction Inputs and Outputs', () => { - it('should correctly set inputs', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + describe('Inputs and Outputs', () => { + it('should correctly calculate inputs and outputs', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const totalAmount = testData.recipients.reduce((sum, r) => sum + Number(r.amount), 0); tx.inputs.length.should.equal(1); @@ -87,16 +51,6 @@ describe('Iota Transfer Transaction', () => { value: totalAmount.toString(), coin: 'tiota', }); - }); - - it('should correctly set outputs', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; tx.outputs.length.should.equal(testData.recipients.length); testData.recipients.forEach((recipient, index) => { @@ -109,17 +63,12 @@ describe('Iota Transfer Transaction', () => { }); }); - describe('Transaction Serialization', () => { - it('should serialize to JSON correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + describe('Serialization', () => { + it('should serialize to and deserialize from JSON', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const json = tx.toJson(); + // Verify JSON structure should.equal(json.sender, testData.sender.address); should.deepEqual(json.recipients, testData.recipients); should.deepEqual(json.paymentObjects, testData.paymentObjects); @@ -127,18 +76,8 @@ describe('Iota Transfer Transaction', () => { should.equal(json.gasPrice, testData.GAS_PRICE); should.deepEqual(json.gasPaymentObjects, testData.gasPaymentObjects); should.equal(json.type, TransactionType.Send); - }); - - it('should deserialize from JSON correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - const json = tx.toJson(); + // Deserialize and verify const newTx = new TransferTransaction(coins.get('tiota')); newTx.parseFromJSON(json); @@ -150,105 +89,34 @@ describe('Iota Transfer Transaction', () => { }); it('should serialize to broadcast format', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - const broadcastFormat = await tx.toBroadcastFormat(); - - should.exist(broadcastFormat); - should.equal(typeof broadcastFormat, 'string'); - should.equal(broadcastFormat.length > 0, true); - }); - - it('should serialize to broadcast format', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const broadcastFormat = await tx.toBroadcastFormat(); should.exist(broadcastFormat); should.equal(typeof broadcastFormat, 'string'); should.equal(broadcastFormat.length > 0, true); - - // Note: parseFromBroadcastTx has known parsing issues and is not fully tested here }); }); - describe('Gas Configuration', () => { - it('should set gas budget correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.gasBudget, testData.GAS_BUDGET); - }); - - it('should set gas price correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.gasPrice, testData.GAS_PRICE); - }); - - it('should set gas payment objects correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - should.deepEqual(tx.gasPaymentObjects, testData.gasPaymentObjects); - }); - - it('should set gas sponsor correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); + describe('Transaction ID and Signing', () => { + it('should generate transaction ID for built transaction', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + const txId = tx.id; - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.gasSponsor, testData.gasSponsor.address); + should.exist(txId); + should.equal(typeof txId, 'string'); + should.equal(txId.length > 0, true); }); - it('should return gas fee correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should throw error when getting ID before build', function () { + const tx = new TransferTransaction(coins.get('tiota')); + tx.sender = testData.sender.address; - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.getFee(), testData.GAS_BUDGET.toString()); + should(() => tx.id).throwError('Tx not built or a rebuild is required'); }); - }); - - describe('Transaction Signing', () => { - it('should get signable payload for non-simulate tx', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - const tx = (await txBuilder.build()) as TransferTransaction; + it('should provide signable payload for non-simulate transactions', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const payload = tx.signablePayload; should.exist(payload); @@ -256,49 +124,25 @@ describe('Iota Transfer Transaction', () => { should.equal(payload.length, 32); // Blake2b produces 32-byte hash }); - it('should throw error when getting signable payload for simulate tx', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); + it('should throw error when getting signable payload for simulate transaction', async function () { + const tx = (await createBasicTransferBuilder().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; should.equal(tx.isSimulateTx, true); - should(() => tx.signablePayload).throwError('Cannot sign a simulate tx'); }); - it('should allow canSign for non-simulate tx', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); + it('should correctly report canSign based on transaction mode', async function () { + const simulateTx = (await createBasicTransferBuilder().build()) as TransferTransaction; + const realTx = (await createTransferBuilderWithGas().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.canSign({} as any), true); - }); - - it('should not allow canSign for simulate tx', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - - const tx = (await txBuilder.build()) as TransferTransaction; - should.equal(tx.canSign({} as any), false); + should.equal(simulateTx.canSign({} as any), false); + should.equal(realTx.canSign({} as any), true); }); }); describe('Transaction Explanation', () => { - it('should explain transaction correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + it('should provide detailed transaction explanation', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; const explanation = tx.explainTransaction(); should.exist(explanation); @@ -310,143 +154,68 @@ describe('Iota Transfer Transaction', () => { }); }); - describe('Transaction ID', () => { - it('should generate transaction ID for built transaction', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - const txId = tx.id; - - should.exist(txId); - should.equal(typeof txId, 'string'); - should.equal(txId.length > 0, true); - }); - - it('should throw error when getting ID before build', function () { - const tx = new TransferTransaction(coins.get('tiota')); - tx.sender = testData.sender.address; - - should(() => tx.id).throwError('Tx not built or a rebuild is required'); - }); - }); - - describe('Rebuild Requirement', () => { - it('should set rebuild required when sender changes', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - tx.sender = testData.gasSponsor.address; - should(() => tx.id).throwError('Tx not built or a rebuild is required'); - }); - - it('should set rebuild required when gas budget changes', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - tx.gasBudget = 10000000; - - should(() => tx.id).throwError('Tx not built or a rebuild is required'); - }); - - it('should set rebuild required when recipients change', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - tx.recipients = [{ address: testData.addresses.validAddresses[0], amount: '5000' }]; - - should(() => tx.id).throwError('Tx not built or a rebuild is required'); + describe('Rebuild Requirements', () => { + const rebuildTriggers = [ + { + name: 'sender changes', + modifier: (tx: TransferTransaction) => (tx.sender = testData.gasSponsor.address), + }, + { + name: 'gas budget changes', + modifier: (tx: TransferTransaction) => (tx.gasBudget = 10000000), + }, + { + name: 'recipients change', + modifier: (tx: TransferTransaction) => + (tx.recipients = [{ address: testData.addresses.validAddresses[0], amount: '5000' }]), + }, + ]; + + rebuildTriggers.forEach(({ name, modifier }) => { + it(`should require rebuild when ${name}`, async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; + modifier(tx); + should(() => tx.id).throwError('Tx not built or a rebuild is required'); + }); }); }); describe('Signature Serialization', () => { - it('should have undefined serializedSignature before signing', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; + it('should have undefined serialized signatures before signing', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; should.equal(tx.serializedSignature, undefined); should.equal(tx.serializedGasSponsorSignature, undefined); }); - it('should serialize signature after adding and rebuilding', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add signature + it('should serialize sender signature after adding and rebuilding', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); - // Rebuild to trigger serialization await tx.build(); should.exist(tx.serializedSignature); should.equal(typeof tx.serializedSignature, 'string'); - // Verify it's valid base64 - should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedSignature as string), true); + should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedSignature!), true); }); it('should serialize gas sponsor signature correctly', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add gas sponsor signature + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); - // Rebuild to trigger serialization await tx.build(); should.exist(tx.serializedGasSponsorSignature); should.equal(typeof tx.serializedGasSponsorSignature, 'string'); - // Verify it's valid base64 - should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedGasSponsorSignature as string), true); + should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedGasSponsorSignature!), true); }); it('should serialize both sender and gas sponsor signatures', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add both signatures tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); - // Rebuild to trigger serialization await tx.build(); should.exist(tx.serializedSignature); @@ -455,61 +224,32 @@ describe('Iota Transfer Transaction', () => { }); it('should include serialized signatures in signatures array', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add signature + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); - // Rebuild to trigger serialization await tx.build(); - // Check that signatures array contains the serialized signature tx.signature.length.should.equal(1); tx.signature[0].should.equal(tx.serializedSignature); }); - it('should include both signatures in signatures array when gas sponsor is present', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - txBuilder.gasSponsor(testData.gasSponsor.address); + it('should include both signatures in correct order when gas sponsor is present', async function () { + const tx = (await createTransferBuilderWithSponsor().build()) as TransferTransaction; - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add both signatures tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); - // Rebuild to trigger serialization await tx.build(); - // Check that signatures array contains both serialized signatures tx.signature.length.should.equal(2); tx.signature[0].should.equal(tx.serializedSignature); tx.signature[1].should.equal(tx.serializedGasSponsorSignature); }); - it('should verify signature serialization format', async function () { - const txBuilder = factory.getTransferBuilder(); - txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.recipients); - txBuilder.paymentObjects(testData.paymentObjects); - txBuilder.gasData(testData.gasData); - - const tx = (await txBuilder.build()) as TransferTransaction; - - // Add signature + it('should verify signature serialization format (EDDSA scheme)', async function () { + const tx = (await createTransferBuilderWithGas().build()) as TransferTransaction; tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); - // Rebuild to trigger serialization await tx.build(); // Decode and verify format: 0x00 + signature (64 bytes) + pubkey (32 bytes) = 97 bytes @@ -518,7 +258,7 @@ describe('Iota Transfer Transaction', () => { // Should be 97 bytes total (1 prefix + 64 signature + 32 pubkey) decoded.length.should.equal(97); - // First byte should be 0x00 + // First byte should be 0x00 (EDDSA scheme) decoded[0].should.equal(0x00); // Next 64 bytes should be the signature