Skip to content

feat(input-selection): add large first input selection strategy #1621

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

Merged
merged 1 commit into from
May 14, 2025
Merged
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
147 changes: 3 additions & 144 deletions packages/input-selection/src/GreedySelection/GreedyInputSelector.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/* eslint-disable max-params */
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
import { InputSelectionParameters, InputSelector, SelectionConstraints, SelectionResult } from '../types';
import { InputSelectionParameters, InputSelector, SelectionResult } from '../types';
import {
addTokenMaps,
getCoinQuantity,
hasNegativeAssetValue,
sortByCoins,
stubMaxSizeAddress,
sortUtxoByTxIn,
subtractTokenMaps,
toValues
} from '../util';
import { sortUtxoByTxIn, splitChange } from './util';
import { splitChangeAndComputeFee } from './util';

/** Greedy selection initialization properties. */
export interface GreedySelectorProps {
Expand All @@ -30,146 +29,6 @@ export interface GreedySelectorProps {
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>;
}

/**
* Given a set of input and outputs, compute the fee. Then extract the fee from the change output
* with the highest value.
*
* @param changeLovelace The available amount of lovelace to be used as change.
* @param constraints The selection constraints.
* @param inputs The inputs of the transaction.
* @param outputs The outputs of the transaction.
* @param changeOutputs The list of change outputs.
* @param currentFee The current computed fee for this selection.
*/
const adjustOutputsForFee = async (
changeLovelace: bigint,
constraints: SelectionConstraints,
inputs: Set<Cardano.Utxo>,
outputs: Set<Cardano.TxOut>,
changeOutputs: Array<Cardano.TxOut>,
currentFee: bigint
): Promise<{
fee: bigint;
change: Array<Cardano.TxOut>;
feeAccountedFor: boolean;
redeemers?: Array<Cardano.Redeemer>;
}> => {
const totalOutputs = new Set([...outputs, ...changeOutputs]);
const { fee, redeemers } = await constraints.computeMinimumCost({
change: [],
fee: currentFee,
inputs,
outputs: totalOutputs
});

if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true, redeemers };

if (changeLovelace < fee) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);

const updatedOutputs = [...changeOutputs];

updatedOutputs.sort(sortByCoins);

let feeAccountedFor = false;
for (const output of updatedOutputs) {
const adjustedCoins = output.value.coins - fee;

if (adjustedCoins >= constraints.computeMinimumCoinQuantity(output)) {
output.value.coins = adjustedCoins;
feeAccountedFor = true;
break;
}
}

return { change: [...updatedOutputs], fee, feeAccountedFor, redeemers };
};

/**
* Recursively compute the fee and compute change outputs until it finds a set of change outputs that satisfies the fee.
*
* @param inputs The inputs of the transaction.
* @param outputs The outputs of the transaction.
* @param changeLovelace The total amount of lovelace in the change.
* @param changeAssets The total assets to be distributed as change.
* @param constraints The selection constraints.
* @param getChangeAddresses A callback that returns a list of addresses and their proportions.
* @param fee The current computed fee for this selection.
*/
const splitChangeAndComputeFee = async (
inputs: Set<Cardano.Utxo>,
outputs: Set<Cardano.TxOut>,
changeLovelace: bigint,
changeAssets: Cardano.TokenMap | undefined,
constraints: SelectionConstraints,
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>,
fee: bigint
): Promise<{ fee: bigint; change: Array<Cardano.TxOut>; feeAccountedFor: boolean }> => {
const changeOutputs = await splitChange(
getChangeAddresses,
changeLovelace,
changeAssets,
constraints.computeMinimumCoinQuantity,
constraints.tokenBundleSizeExceedsLimit,
fee
);

let adjustedChangeOutputs = await adjustOutputsForFee(
changeLovelace,
constraints,
inputs,
outputs,
changeOutputs,
fee
);

// If the newly computed fee is higher than tha available balance for change,
// but there are unallocated native assets, return the assets as change with 0n coins.
if (adjustedChangeOutputs.fee >= changeLovelace) {
const result = {
change: [
{
address: stubMaxSizeAddress,
value: {
assets: changeAssets,
coins: 0n
}
}
],
fee: adjustedChangeOutputs.fee,
feeAccountedFor: true
};

if (result.change[0].value.coins < constraints.computeMinimumCoinQuantity(result.change[0]))
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);

return result;
}

if (fee < adjustedChangeOutputs.fee) {
adjustedChangeOutputs = await splitChangeAndComputeFee(
inputs,
outputs,
changeLovelace,
changeAssets,
constraints,
getChangeAddresses,
adjustedChangeOutputs.fee
);

if (adjustedChangeOutputs.change.length === 0)
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
}

for (const out of adjustedChangeOutputs.change) {
if (out.value.coins < constraints.computeMinimumCoinQuantity(out))
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
}

if (!adjustedChangeOutputs.feeAccountedFor) throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);

return adjustedChangeOutputs;
};

/** Selects all UTXOs to fulfill the amount required for the given outputs and return the remaining balance as change. */
export class GreedyInputSelector implements InputSelector {
#props: GreedySelectorProps;
Expand Down
145 changes: 132 additions & 13 deletions packages/input-selection/src/GreedySelection/util.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable func-style, max-params */
import { BigNumber } from 'bignumber.js';
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit } from '../types';
import { ComputeMinimumCoinQuantity, SelectionConstraints, TokenBundleSizeExceedsLimit } from '../types';
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
import { addTokenMaps, isValidValue, sortByCoins } from '../util';
import { addTokenMaps, isValidValue, sortByCoins, stubMaxSizeAddress } from '../util';

const PERCENTAGE_TOLERANCE = 0.05;

Expand Down Expand Up @@ -227,22 +227,141 @@ export const splitChange = async (
};

/**
* Sorts the given TxIn set first by txId and then by index.
* Given a set of input and outputs, compute the fee. Then extract the fee from the change output
* with the highest value.
*
* @param lhs The left-hand side of the comparison operation.
* @param rhs The left-hand side of the comparison operation.
* @param changeLovelace The available amount of lovelace to be used as change.
* @param constraints The selection constraints.
* @param inputs The inputs of the transaction.
* @param outputs The outputs of the transaction.
* @param changeOutputs The list of change outputs.
* @param currentFee The current computed fee for this selection.
*/
export const sortTxIn = (lhs: Cardano.TxIn, rhs: Cardano.TxIn) => {
const txIdComparison = lhs.txId.localeCompare(rhs.txId);
if (txIdComparison !== 0) return txIdComparison;
export const adjustOutputsForFee = async (
changeLovelace: bigint,
constraints: SelectionConstraints,
inputs: Set<Cardano.Utxo>,
outputs: Set<Cardano.TxOut>,
changeOutputs: Array<Cardano.TxOut>,
currentFee: bigint
): Promise<{
fee: bigint;
change: Array<Cardano.TxOut>;
feeAccountedFor: boolean;
redeemers?: Array<Cardano.Redeemer>;
}> => {
const totalOutputs = new Set([...outputs, ...changeOutputs]);
const { fee, redeemers } = await constraints.computeMinimumCost({
change: [],
fee: currentFee,
inputs,
outputs: totalOutputs
});

if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true, redeemers };

if (changeLovelace < fee) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);

const updatedOutputs = [...changeOutputs];

updatedOutputs.sort(sortByCoins);

let feeAccountedFor = false;
for (const output of updatedOutputs) {
const adjustedCoins = output.value.coins - fee;

if (adjustedCoins >= constraints.computeMinimumCoinQuantity(output)) {
output.value.coins = adjustedCoins;
feeAccountedFor = true;
break;
}
}

return lhs.index - rhs.index;
return { change: [...updatedOutputs], fee, feeAccountedFor, redeemers };
};

/**
* Sorts the given Utxo set first by TxIn.
* Recursively compute the fee and compute change outputs until it finds a set of change outputs that satisfies the fee.
*
* @param lhs The left-hand side of the comparison operation.
* @param rhs The left-hand side of the comparison operation.
* @param inputs The inputs of the transaction.
* @param outputs The outputs of the transaction.
* @param changeLovelace The total amount of lovelace in the change.
* @param changeAssets The total assets to be distributed as change.
* @param constraints The selection constraints.
* @param getChangeAddresses A callback that returns a list of addresses and their proportions.
* @param fee The current computed fee for this selection.
*/
export const sortUtxoByTxIn = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => sortTxIn(lhs[0], rhs[0]);
export const splitChangeAndComputeFee = async (
inputs: Set<Cardano.Utxo>,
outputs: Set<Cardano.TxOut>,
changeLovelace: bigint,
changeAssets: Cardano.TokenMap | undefined,
constraints: SelectionConstraints,
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>,
fee: bigint
): Promise<{ fee: bigint; change: Array<Cardano.TxOut>; feeAccountedFor: boolean }> => {
const changeOutputs = await splitChange(
getChangeAddresses,
changeLovelace,
changeAssets,
constraints.computeMinimumCoinQuantity,
constraints.tokenBundleSizeExceedsLimit,
fee
);

let adjustedChangeOutputs = await adjustOutputsForFee(
changeLovelace,
constraints,
inputs,
outputs,
changeOutputs,
fee
);

// If the newly computed fee is higher than the available balance for change,
// but there are unallocated native assets, return the assets as change with 0n coins.
if (adjustedChangeOutputs.fee >= changeLovelace) {
const result = {
change: [
{
address: stubMaxSizeAddress,
value: {
assets: changeAssets,
coins: 0n
}
}
],
fee: adjustedChangeOutputs.fee,
feeAccountedFor: true
};

if (result.change[0].value.coins < constraints.computeMinimumCoinQuantity(result.change[0]))
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);

return result;
}

if (fee < adjustedChangeOutputs.fee) {
adjustedChangeOutputs = await splitChangeAndComputeFee(
inputs,
outputs,
changeLovelace,
changeAssets,
constraints,
getChangeAddresses,
adjustedChangeOutputs.fee
);

if (adjustedChangeOutputs.change.length === 0)
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
}

for (const out of adjustedChangeOutputs.change) {
if (out.value.coins < constraints.computeMinimumCoinQuantity(out))
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
}

if (!adjustedChangeOutputs.feeAccountedFor) throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);

return adjustedChangeOutputs;
};
Loading
Loading