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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion modules/sdk-coin-ada/src/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 63 additions & 25 deletions modules/sdk-coin-ada/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this exception for if change.less_than(CardanoWasm.BigNum.from_str(MIN_ADA_TRANSFER))) as well?

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))
);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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))
);
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-coin-ada/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 1 addition & 3 deletions modules/sdk-coin-ada/test/unit/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
72 changes: 72 additions & 0 deletions modules/sdk-coin-ada/test/unit/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading