Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dart/catalyst_cardano_serialization): initial implementation of dynamic coin selection algorithm #1684

Merged
merged 29 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4c9fa6c
feat(dart/catalyst_cardano_serialization): initial implementation of …
ilap Jan 24, 2025
6c27f68
feat(dart/catalyst_cardano_serialization): add tests for coin selecti…
ilap Jan 27, 2025
f507047
feat(dart/catalyst_cardano_serialization): improve naming and comment…
ilap Jan 27, 2025
439ccc0
Merge branch 'input-output-hk:main' into feature/dynamic-coin-selection
ilap Jan 27, 2025
ec4f31e
fix(dart/catalyst_cardano_serialization): correct loop condition to p…
ilap Jan 27, 2025
db75a8a
Merge branch 'feature/dynamic-coin-selection' of github.com:ilap/cata…
ilap Jan 27, 2025
c0ae09b
Merge branch 'main' into feature/dynamic-coin-selection
dtscalac Jan 28, 2025
ddc62e0
fix(dart/catalyst_cardano_serialization): resolve broken property-bas…
ilap Jan 28, 2025
34a0958
Merge branch 'feature/dynamic-coin-selection' of github.com:ilap/cata…
ilap Jan 28, 2025
b9c3298
fix(dart/catalyst_cardano_serialization): inline types, clean comment…
ilap Jan 28, 2025
8a6f6b3
Merge branch 'main' into feature/dynamic-coin-selection
minikin Jan 29, 2025
1b4f9e8
fix(dar/catalyst_cardano_serialization): fix incorrect output fee cal…
ilap Jan 30, 2025
f933bff
Merge branch 'input-output-hk:main' into feature/dynamic-coin-selection
ilap Jan 30, 2025
884fc3b
feat(dart/catalyst_cardano_serialization): add property test for tran…
ilap Jan 30, 2025
56b6084
Merge branch 'input-output-hk:main' into feature/dynamic-coin-selection
ilap Jan 30, 2025
710f213
fix(dart/catalyst_cardano_serialization): simplify and fix output fee…
ilap Jan 30, 2025
7d9da25
fix(dart/catalyst_cardano_serialization): correct public key hash ext…
ilap Jan 30, 2025
a6a4f0b
feat(dart/catalyst_cardano_serialization): expose hash lengths as pub…
ilap Jan 30, 2025
26a7364
Merge branch 'input-output-hk:main' into feature/dynamic-coin-selection
ilap Jan 31, 2025
2409501
chore(dart/catalyst_cardano_serialization)): address PR review feedba…
ilap Jan 31, 2025
bc31d21
chore(dart/catalyst_cardano_serialization): revert accidental removal…
ilap Jan 31, 2025
435a8ef
fix(dart/catalyst_cardano_serialization): ensure change calculation r…
ilap Jan 31, 2025
f9ddf3a
feat(dart/catalyst_cardano_serialization): enhance selection utility …
ilap Feb 1, 2025
e9056a3
chore(dart/catalyst_cardano_serialization)): address PR review feedbacks
ilap Feb 3, 2025
6a76047
Merge branch 'main' into feature/dynamic-coin-selection
dtscalac Feb 3, 2025
13c8e23
Merge branch 'main' into feature/dynamic-coin-selection
dtscalac Feb 4, 2025
e04dff0
Merge branch 'input-output-hk:main' into feature/dynamic-coin-selection
ilap Feb 4, 2025
b917d4e
Merge branch 'main' into feature/dynamic-coin-selection
dtscalac Feb 4, 2025
709bc62
Merge branch 'main' into feature/dynamic-coin-selection
minikin Feb 4, 2025
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
1 change: 1 addition & 0 deletions .config/dictionaries/project.dic
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Keyhash
keyserver
keyspace
keyspaces
kiri
KUBECONFIG
kubernetescrd
kubetail
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ class ShelleyAddress extends Equatable implements CborEncodable {
static const Bech32Encoder _testNetRewardEncoder =
Bech32Encoder(hrp: defaultRewardHrp + testnetHrpSuffix);

/// Length of a Cardano base address (1 byte + 28 bytes + 28 bytes).
static const int baseAddrLength = 57;

/// Length of a Cardano enterprise address (1 byte + 28 bytes).
static const int entAddrLength = 29;

/// Raw bytes of address.
/// Format [ 8 bit header | payload ]
final Uint8List bytes;
Expand All @@ -40,7 +46,14 @@ class ShelleyAddress extends Equatable implements CborEncodable {
/// The constructor for [ShelleyAddress] from raw [bytes] and [hrp].
ShelleyAddress(List<int> bytes)
: bytes = Uint8List.fromList(bytes),
hrp = _extractHrp(bytes);
hrp = _extractHrp(bytes) {
if (bytes.length != entAddrLength && bytes.length != baseAddrLength) {
throw ArgumentError(
'Bytes length (${bytes.length}) must be either $entAddrLength'
' or $baseAddrLength.',
);
}
}

/// The constructor which parses the address from bech32 format.
factory ShelleyAddress.fromBech32(String address) {
Expand Down Expand Up @@ -160,7 +173,8 @@ class ShelleyAddress extends Equatable implements CborEncodable {
/// Extracts the payload from [bytes].
///
/// Format [ 8 bit header | payload ]
static Ed25519PublicKeyHash _extractPublicKeyHash(List<int> bytes) {
return Ed25519PublicKeyHash.fromBytes(bytes: bytes.sublist(1));
}
static Ed25519PublicKeyHash _extractPublicKeyHash(List<int> bytes) =>
Ed25519PublicKeyHash.fromBytes(
bytes: bytes.sublist(1, Ed25519PublicKeyHash.hashLength + 1),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart';
import 'package:catalyst_cardano_serialization/src/builders/types.dart';

/// A builder that constructs the minimal required transaction inputs from a set
/// of inputs using a coin selection algorithm.
///
/// The `InputBuilder` processes a set of transaction inputs and applies
/// a coin selection algorithm to determine the minimal required inputs needed
/// to satisfy the transaction outputs and fees. It produces a new selection
/// result containing:
/// - The selected inputs.
/// - Change outputs, if applicable.
/// - The calculated transaction fee.
///
final class InputBuilder implements CoinSelector {
/// Strategy used to prioritize the available UTxOs.
final CoinSelectionStrategy selectionStrategy;

/// Creates an [InputBuilder] with the given [selectionStrategy].
const InputBuilder({required this.selectionStrategy});

/// Selects inputs to satisfy transaction outputs and fees.
///
/// Throws:
/// - An exception if the coin selection algorithm fails to satisfy the
/// transaction's requirements.
@override
SelectionResult selectInputs({
required TransactionBuilder builder,
int minInputs = CoinSelector.minInputs,
int maxInputs = CoinSelector.maxInputs,
}) {
final availableInputs = builder.inputs.toSet();
final selectedInputs = <TransactionUnspentOutput>{};

final inputTotal = CoinSelector.sumAmounts(
builder.inputs,
(input) => input.output.amount,
);
final targetTotal = CoinSelector.sumAmounts(
builder.outputs,
(output) => output.amount,
);

// Exit early if the available inputs cannot cover the required outputs.
if (inputTotal.lessThan(targetTotal)) {
throw InsufficientUtxoBalanceException(
actualAmount: inputTotal,
requiredAmount: targetTotal,
);
}

// Group UTXOs by asset ID for coin selection.
final assetGroups = buildAssetGroups(targetTotal, availableInputs);

// Apply the coin selection strategy to prioritize UTXOs within each group.
selectionStrategy.apply(assetGroups);

final groupCount = assetGroups.length;
var selectedTotal = const Balance.zero();

// Iterate through each asset group (native tokens + ADA as the last group).
for (final entry in assetGroups) {
final assetId = entry.key;
final assetUtxos = entry.value;

// Determine if change should be calculated for the current asset group.
final shouldCalculateChange = assetId == CoinSelector.adaAssetId ||
assetGroups[groupCount - 2].key == assetId;
ilap marked this conversation as resolved.
Show resolved Hide resolved

while (assetUtxos.isNotEmpty) {
// Check if there are no more available inputs or if the maximum number
// of inputs has been exceeded.
if (availableInputs.isEmpty || selectedInputs.length > maxInputs) {
throw InsufficientUtxoBalanceException(
actualAmount: selectedTotal,
requiredAmount: targetTotal,
);
}

// Select the first available UTXO from the list of asset UTXOs.
final utxo = assetUtxos.removeAt(0);

// Check if the UTXO is still available for selection.
if (!availableInputs.remove(utxo)) continue;

// Add the UTXO to the selected inputs and update the selected total.
selectedInputs.add(utxo);
selectedTotal += utxo.output.amount;

// Check if the requirements have met.
if (_getAssetAmount(assetId, selectedTotal) <
_getAssetAmount(assetId, targetTotal)) {
if (assetUtxos.isEmpty) {
throw InsufficientUtxoBalanceException(
actualAmount: selectedTotal,
requiredAmount: targetTotal,
);
} else {
continue;
}
}

if (shouldCalculateChange && selectedInputs.length >= minInputs) {
final changeAndFee = _getChangeAndFee(
assetId,
builder,
selectedInputs,
selectedTotal,
targetTotal,
);

// Return the selection result if change and fees are successfully
// calculated.
if (changeAndFee != null) {
final (changeOutputs, totalFee) = changeAndFee;
return (selectedInputs, changeOutputs, totalFee);
}
}
}
}

// Throw an exception if the selection process fails to meet the
// requirements.
throw InsufficientUtxoBalanceException(
actualAmount: selectedTotal,
requiredAmount: targetTotal,
);
}

/// Groups UTxOs by token, mapping each token to its corresponding UTxOs.
@override
AssetsGroup buildAssetGroups(
Balance requiredBalance,
Set<TransactionUnspentOutput> inputs,
) {
final assetMap = <AssetId, List<TransactionUnspentOutput>>{};
final requiredAssets = requiredBalance.multiAsset?.bundle ?? {};

for (final input in inputs) {
final inputBalance = input.output.amount;
final inputPolicies =
inputBalance.multiAsset?.bundle.keys ?? <PolicyId>[];

for (final policy in [CoinSelector.adaPolicy, ...inputPolicies]) {
if (requiredAssets.containsKey(policy)) {
final inputAssets = inputBalance.multiAsset!.bundle[policy]!.entries;

for (final asset in inputAssets) {
final assetId = (policy, asset.key);
if (requiredAssets[policy]!.containsKey(asset.key)) {
assetMap.putIfAbsent(assetId, () => []).add(input);
} else {
assetMap
.putIfAbsent(CoinSelector.adaAssetId, () => [])
.add(input);
}
}
} else {
assetMap.putIfAbsent(CoinSelector.adaAssetId, () => []).add(input);
}
}
}

return assetMap.entries.toList()
..sort((a, b) => b.key.$1.hash.compareTo(a.key.$1.hash));
}

/// Retrieves the change outputs and the total transaction fee, if applicable.
///
/// This method calculates whether there is sufficient balance to cover the
/// required amount and associated fees, then retrieves the pre-constructed
/// change outputs and the total transaction fee.
///
/// - Parameters:
/// - [assetId]: The identifier for which the change is being calculated.
/// - [builder]: The transaction builder used to create the transaction.
/// - [selectedInputs]: The set of selected UTxOs to fund the transaction.
/// - [selectedTotal]: The total balance accumulated from selected inputs.
/// - [targetTotal]: The balance required to satisfy the transaction's
/// outputs and minimum ADA requirements.
///
/// - Returns:
/// A tuple containing:
/// - [List<TransactionOutput>]: The list of change outputs.
/// - [Coin]: The total transaction fee, including the fee for the change
/// outputs.
/// Returns `null` if the accumulated balance is insufficient to cover the
/// required amount and fees.
///
/// - Notes:
/// - This method ensures the transaction remains valid by confirming that
/// the accumulated balance exceeds or equals the required amount plus
/// fees.
(List<TransactionOutput>, Coin)? _getChangeAndFee(
AssetId assetId,
TransactionBuilder builder,
Set<TransactionUnspentOutput> selectedInputs,
Balance selectedTotal,
Balance targetTotal,
) {
final minFee =
builder.copyWith(inputs: selectedInputs).minFee(useWitnesses: true);
final minimumRequired = targetTotal + Balance(coin: minFee);

if (selectedTotal.lessThan(minimumRequired)) return null;

// Calculate the change to be returned, as the selected total is guaranteed
// to be larger.
final changeTotal = selectedTotal - minimumRequired;

final (changeOutputs, changeFee) = _buildChangeOutputs(
builder,
changeTotal,
minFee,
);

final requiredFee = minFee + changeFee;
if (selectedTotal.lessThan(targetTotal + Balance(coin: requiredFee))) {
return null;
}

return (changeOutputs, requiredFee);
}

/// Constructs the change outputs and calculates their associated fees.
///
/// This method generates the necessary change outputs based on the remaining
/// balance after covering the required amount and transaction fees. It also
/// calculates the additional fee incurred by the change outputs.
///
/// - Parameters:
/// - [builder]: The transaction builder used to create the transaction.
/// - [remainingBalance]: The balance remaining after deducting the required
/// amount and initial fees.
/// - [minFee]: The minimum fee for the transaction before considering
/// change outputs.
///
/// - Returns:
/// A tuple containing:
/// - [List<TransactionOutput>]: The list of change outputs.
/// - [Coin]: The additional fee incurred by the change outputs.
///
/// - Notes:
/// - If the remaining balance is zero, no change outputs are created, and
/// the fee remains unchanged.
/// - This method handles multi-asset balances and ensures they are included
/// in the change outputs, if applicable.
(List<TransactionOutput>, Coin) _buildChangeOutputs(
TransactionBuilder builder,
Balance remainingBalance,
Coin minFee,
) {
if (remainingBalance.isZero) return ([], const Coin(0));
if (builder.changeAddress == null) {
throw ArgumentError('Change address required for non-zero balance.');
}

// Remove empty multiasset from the balance
final normalizedBalance = remainingBalance.hasMultiAssets()
? remainingBalance
: Balance(coin: remainingBalance.coin);

final output = TransactionOutput(
address: builder.changeAddress!,
amount: normalizedBalance,
);
final changeFee = TransactionOutputBuilder.feeForOutput(
builder.config,
output,
numOutputs: builder.outputs.length,
);
final minAda = TransactionOutputBuilder.minimumAdaForOutput(
output,
builder.config.coinsPerUtxoByte,
);

final changeOutputs = <TransactionOutput>[];
if (normalizedBalance.coin >= minAda + changeFee) {
changeOutputs.add(
output.copyWith(amount: normalizedBalance - Balance(coin: changeFee)),
);
return (changeOutputs, changeFee);
} else {
return ([], minAda + changeFee);
}
}

/// Retrieves the amount of a specific asset in a balance or zero amount.
Coin _getAssetAmount(AssetId assetId, Balance balance) {
final (policy, assetName) = assetId;
return assetId == CoinSelector.adaAssetId
? balance.coin
: balance.multiAsset?.bundle[policy]?[assetName] ?? const Coin(0);
}
}
Loading