From 2485d9393be271184a9716fe7cbebff731f2b885 Mon Sep 17 00:00:00 2001 From: Dhanush GM Date: Thu, 12 Mar 2026 16:04:56 +0530 Subject: [PATCH] fix(sdk-coin-ada): rejecting ada outputs below 1 ADA Ticket: CSHLD-459 --- modules/sdk-coin-ada/src/ada.ts | 3 +- .../src/lib/transactionBuilder.ts | 88 +++++++++++++------ modules/sdk-coin-ada/src/lib/utils.ts | 2 + modules/sdk-coin-ada/test/unit/ada.ts | 4 +- .../test/unit/transactionBuilder.ts | 72 +++++++++++++++ 5 files changed, 140 insertions(+), 29 deletions(-) diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 31ad91e11a..1daf694c2f 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -586,7 +586,8 @@ export class Ada extends BaseCoin { } catch (e) { if ( e.message === 'Did not find address with funds to recover.' || - e.message.startsWith('Insufficient funds to recover') + e.message.startsWith('Insufficient funds to recover') || + e.message.startsWith('Consolidation amount too small') ) { lastScanIndex = i; continue; diff --git a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts index a2e500b8d2..aa03a5aeb8 100644 --- a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts @@ -12,7 +12,7 @@ import { } from '@bitgo/sdk-core'; import { Asset, Transaction, TransactionInput, TransactionOutput, Withdrawal, SponsorshipInfo } from './transaction'; import { KeyPair } from './keyPair'; -import util, { MIN_ADA_FOR_ONE_ASSET } from './utils'; +import util, { MIN_ADA_FOR_ONE_ASSET, MIN_ADA_TRANSFER } from './utils'; import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs'; import { BigNum } from '@emurgo/cardano-serialization-lib-nodejs'; @@ -297,8 +297,13 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { change = change.checked_sub(minAmountNeededForAssetOutput); } else { - // Native coin send + // Native coin send — reject if below Cardano's minimum output (BabbageOutputTooSmallUTxO) const amount = CardanoWasm.BigNum.from_str(receiverAmount); + if (amount.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + throw new BuildTransactionError( + `Transfer amount too small: minimum is 1 ADA (${MIN_ADA_TRANSFER} lovelace), got ${receiverAmount} lovelace` + ); + } outputs.add( CardanoWasm.TransactionOutput.new(util.getWalletAddress(receiverAddress), CardanoWasm.Value.new(amount)) ); @@ -392,7 +397,10 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { change = change.checked_sub(minAmountNeededForAssetOutput); }); } - if (!change.is_zero()) { + // Only create a change output if it meets the Cardano minimum output amount. + // If the change is positive but below 1 ADA, it is absorbed into the transaction fee + // (i.e. the miner receives the dust) rather than creating an unspendable UTXO. + if (!change.is_zero() && !change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); outputs.add(changeOutput); } @@ -524,6 +532,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { txOutputAmountBuilder = txOutputAmountBuilder.with_coin_and_asset(amount, multiAsset); outputs.add(txOutputAmountBuilder.build()); } else { + if (amount.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + throw new BuildTransactionError( + `Transfer amount too small: minimum is 1 ADA (${MIN_ADA_TRANSFER} lovelace), got ${output.amount} lovelace` + ); + } outputs.add( CardanoWasm.TransactionOutput.new(util.getWalletAddress(output.address), CardanoWasm.Value.new(amount)) ); @@ -553,7 +566,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { // If totalAmountToSend is 0, its consolidation if (totalAmountToSend.to_str() == '0') { // support for multi-asset consolidation - if (this._multiAssets !== undefined) { + if (this._multiAssets.length > 0) { const totalNumberOfAssets = CardanoWasm.BigNum.from_str(this._multiAssets.length.toString()); const minAmountNeededForOneAssetOutput = CardanoWasm.BigNum.from_str(MIN_ADA_FOR_ONE_ASSET); const minAmountNeededForTotalAssetOutputs = @@ -582,23 +595,36 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { outputs.add(txOutput); }); - // finally send the remaining ADA in its own output + // Only add the remaining ADA output if it meets the protocol minimum. + // If below 1 ADA, the remainder is absorbed into the fee rather than + // creating an unspendable output (BabbageOutputTooSmallUTxO). const remainingOutputAmount = change.checked_sub(minAmountNeededForTotalAssetOutputs); - const changeOutput = CardanoWasm.TransactionOutput.new( - changeAddress, - CardanoWasm.Value.new(remainingOutputAmount) - ); - outputs.add(changeOutput); + if (!remainingOutputAmount.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + const changeOutput = CardanoWasm.TransactionOutput.new( + changeAddress, + CardanoWasm.Value.new(remainingOutputAmount) + ); + outputs.add(changeOutput); + } } } else { - // If there are no tokens to consolidate, you only have 1 output which is ADA alone + // ADA-only consolidation — the entire balance (minus fee) becomes the single output. + // Reject if it would be below the Cardano minimum output to prevent BabbageOutputTooSmallUTxO. + if (change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + throw new BuildTransactionError( + `Consolidation amount too small: after fees, only ${change.to_str()} lovelace remains which is below the 1 ADA minimum output required by the Cardano protocol` + ); + } const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); outputs.add(changeOutput); } } else { - // If this isn't a consolidate request, whatever change that needs to be sent back to the rootaddress is added as a separate output here - const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); - outputs.add(changeOutput); + // If this isn't a consolidate request, whatever change that needs to be sent back to the rootaddress is added as a separate output here. + // Skip the change output if it would be below the Cardano minimum (1 ADA); dust is absorbed into the fee. + if (!change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); + outputs.add(changeOutput); + } } } @@ -720,7 +746,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { // If totalAmountToSend is 0, its consolidation if (totalAmountToSend.to_str() == '0') { // support for multi-asset consolidation - if (this._multiAssets !== undefined) { + if (this._multiAssets.length > 0) { const totalNumberOfAssets = CardanoWasm.BigNum.from_str(this._multiAssets.length.toString()); const minAmountNeededForOneAssetOutput = CardanoWasm.BigNum.from_str('1500000'); const minAmountNeededForTotalAssetOutputs = minAmountNeededForOneAssetOutput.checked_mul(totalNumberOfAssets); @@ -748,27 +774,39 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { outputs.add(txOutput); }); - // finally send the remaining ADA in its own output + // Only add the remaining ADA output if it meets the Cardano protocol minimum. + // If below 1 ADA, the remainder is absorbed into the fee (BabbageOutputTooSmallUTxO prevention). const remainingOutputAmount = change.checked_sub(minAmountNeededForTotalAssetOutputs); - const changeOutput = CardanoWasm.TransactionOutput.new( - changeAddress, - CardanoWasm.Value.new(remainingOutputAmount) - ); - outputs.add(changeOutput); + if (!remainingOutputAmount.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + const changeOutput = CardanoWasm.TransactionOutput.new( + changeAddress, + CardanoWasm.Value.new(remainingOutputAmount) + ); + outputs.add(changeOutput); + } } else { throw new BuildTransactionError( 'Insufficient funds: need a minimum of 1.5 ADA per output to construct token consolidation' ); } } else { - // If there are no tokens to consolidate, you only have 1 output which is ADA alone + // ADA-only consolidation — the entire balance (minus fee) becomes the single output. + // Reject if it would be below the Cardano minimum output to prevent BabbageOutputTooSmallUTxO. + if (change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + throw new BuildTransactionError( + `Consolidation amount too small: after fees, only ${change.to_str()} lovelace remains which is below the 1 ADA minimum output required by the Cardano protocol` + ); + } const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); outputs.add(changeOutput); } } else { - // If this isn't a consolidate request, whatever change that needs to be sent back to the rootaddress is added as a separate output here - const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); - outputs.add(changeOutput); + // If this isn't a consolidate request, whatever change needs to be sent back is added as a separate output. + // Skip if below the Cardano minimum (1 ADA); dust is absorbed into the fee. + if (!change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) { + const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); + outputs.add(changeOutput); + } } } diff --git a/modules/sdk-coin-ada/src/lib/utils.ts b/modules/sdk-coin-ada/src/lib/utils.ts index 275bbc8e64..65bb7eff7a 100644 --- a/modules/sdk-coin-ada/src/lib/utils.ts +++ b/modules/sdk-coin-ada/src/lib/utils.ts @@ -22,6 +22,8 @@ import bs58 from 'bs58'; import cbor from 'cbor'; export const MIN_ADA_FOR_ONE_ASSET = '1500000'; +// Cardano protocol minimum for any ADA-only output (BabbageOutputTooSmallUTxO is thrown if violated) +export const MIN_ADA_TRANSFER = '1000000'; export const VOTE_ALWAYS_ABSTAIN = 'always-abstain'; export const VOTE_ALWAYS_NO_CONFIDENCE = 'always-no-confidence'; diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index fd324cbab0..58b81ec157 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -1002,9 +1002,7 @@ describe('ADA', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destAddr, }) - .should.rejectedWith( - 'Insufficient funds to recover, minimum required is 1 ADA plus fees, got 834455 fees: 165545' - ); + .should.rejectedWith(/Consolidation amount too small: after fees, only 834455 lovelace remains/); sandBox.assert.calledTwice(basecoin.getDataFromNode); }); }); diff --git a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts index e104a441a6..554c1feb78 100644 --- a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts @@ -8,6 +8,78 @@ import { Transaction } from '../../src/lib/transaction'; describe('ADA Transaction Builder', async () => { const factory = new TransactionBuilderFactory(coins.get('tada')); + + describe('Minimum output amount validation (BabbageOutputTooSmallUTxO prevention)', () => { + // Shared test inputs — using the same transaction_id and addresses as the Shelley tests above + const txId = '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21'; + const recipientAddress = testData.rawTx.outputAddress1.address; + const changeAddress = testData.rawTx.outputAddress2.address; + const totalInput = 21032023; + + it('should reject a transfer output of 999999 lovelace (below 1 ADA minimum)', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ transaction_id: txId, transaction_index: 1 }); + txBuilder.output({ address: recipientAddress, amount: '999999' }); + txBuilder.changeAddress(changeAddress, totalInput.toString()); + txBuilder.ttl(800000000); + await txBuilder + .build() + .should.be.rejectedWith( + /Transfer amount too small: minimum is 1 ADA \(1000000 lovelace\), got 999999 lovelace/ + ); + }); + + it('should reject a transfer output of 0 lovelace', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ transaction_id: txId, transaction_index: 1 }); + txBuilder.output({ address: recipientAddress, amount: '0' }); + txBuilder.changeAddress(changeAddress, totalInput.toString()); + txBuilder.ttl(800000000); + await txBuilder + .build() + .should.be.rejectedWith(/Transfer amount too small: minimum is 1 ADA \(1000000 lovelace\), got 0 lovelace/); + }); + + it('should allow a transfer output of exactly 1 ADA (1000000 lovelace)', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ transaction_id: txId, transaction_index: 1 }); + txBuilder.output({ address: recipientAddress, amount: '1000000' }); + txBuilder.changeAddress(changeAddress, totalInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + tx.type.should.equal(TransactionType.Send); + const txData = tx.toJson(); + txData.outputs[0].amount.should.equal('1000000'); + }); + + it('should omit change output when change is below 1 ADA (dust absorbed into fee)', async () => { + // totalInput = 1000000 (recipient) + ~167000 (fee) + ~600 (dust) — change would be ~600 lovelace + // With our fix, no change output should appear; the dust becomes extra fee + const dustInput = 1167600; + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ transaction_id: txId, transaction_index: 1 }); + txBuilder.output({ address: recipientAddress, amount: '1000000' }); + txBuilder.changeAddress(changeAddress, dustInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + const txData = tx.toJson(); + // Only 1 output (the recipient), the change dust was absorbed into the fee + txData.outputs.length.should.equal(1); + txData.outputs[0].address.should.equal(recipientAddress); + }); + + it('should reject ADA-only consolidation when amount after fees is below 1 ADA', async () => { + // totalInput so small that after fee estimation the remaining balance < 1 ADA + const tinyInput = 500000; + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ transaction_id: txId, transaction_index: 1 }); + // No output = consolidation mode + txBuilder.changeAddress(changeAddress, tinyInput.toString()); + txBuilder.ttl(800000000); + await txBuilder.build().should.be.rejectedWith(/Consolidation amount too small/); + }); + }); + it('start and build an unsigned transfer tx for byron address', async () => { const txBuilder = factory.getTransferBuilder(); txBuilder.input({