Skip to content

Commit

Permalink
feat(input-selection): added new greedy input selector
Browse files Browse the repository at this point in the history
  • Loading branch information
AngelCastilloB committed May 31, 2023
1 parent 7255fb2 commit 11512e9
Show file tree
Hide file tree
Showing 24 changed files with 1,649 additions and 203 deletions.
1 change: 1 addition & 0 deletions packages/input-selection/package.json
Expand Up @@ -68,6 +68,7 @@
"dependencies": {
"@cardano-sdk/core": "workspace:~",
"@cardano-sdk/util": "workspace:~",
"bignumber.js": "^9.1.1",
"lodash": "^4.17.21",
"ts-custom-error": "^3.2.0"
},
Expand Down
149 changes: 149 additions & 0 deletions packages/input-selection/src/GreedySelection/GreedyInputSelector.ts
@@ -0,0 +1,149 @@
import { AssetId } from '@cardano-sdk/core/dist/cjs/Cardano';
import { Cardano } from '@cardano-sdk/core';
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
import { InputSelectionParameters, InputSelector, SelectionConstraints, SelectionResult } from '../types';
import {
addTokenMaps,
getAssetQuantities,
getCoinQuantity,
hasNegativeAssetValue,
sortByCoins,
splitChange,
subtractTokenMaps,
toValues
} from '../util';

/**
* Greedy selection initialization properties.
*/
export interface GreedySelectorProps {
/**
* Callback that returns a map of addresses with their intended proportions. This selector
* will create N change outputs at this change addresses with the given proportions.
*/
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>;
}

/**
* Given a set of input and outputs, compute the fee. Then
* extract the fee from the output with the highest value. Do this recursively until we
* create a valid transaction.
*
* @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 (
constraints: SelectionConstraints,
inputs: Set<Cardano.Utxo>,
outputs: Set<Cardano.TxOut>,
changeOutputs: Array<Cardano.TxOut>,
currentFee: bigint
): Promise<{ fee: bigint; change: Array<Cardano.TxOut> }> => {
let result = { change: [...changeOutputs], fee: currentFee };

// Compute the fee. If the current fee is less than the newly computed fee, deduct the difference from the change output
// with the highest coin value and try again. If the coin value falls below the minimum coin quantity, deduct from the
// next highest coin value output instead. If we run out of outputs to deduct the fee from, throw.
const totalOutputs = new Set([...outputs, ...changeOutputs]);
const fee = await constraints.computeMinimumCost({
change: [],
fee: currentFee,
inputs,
outputs: totalOutputs
});

if (currentFee < fee) {
const feeDifference = fee - currentFee;
const updatedOutputs = [...changeOutputs];

updatedOutputs.sort(sortByCoins);

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

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

if (!feeAccountedFor) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);

result = await adjustOutputsForFee(constraints, inputs, outputs, updatedOutputs, fee);
}

return result;
};

/**
* Selects all UTXOs to fulfill the amount required for the given outputs and return the remaining balance (if any)
* as change in a single output.
*
* In its current form, this input selection strategy is not suitable for normal transactions.
*
* Here are a few caveats:
*
* - This input selector will slightly alter the given outputs to account for the transaction fee if the wallet tries
* to spend the whole balance.
* - This is a very naive selection algorithm and the resulting selection could be invalid if the user has too many UTXOs or if
* the user has too many native assets.
*/
export class GreedyInputSelector implements InputSelector {
#props: GreedySelectorProps;

constructor(props: GreedySelectorProps) {
this.#props = props;
}

async select(params: InputSelectionParameters): Promise<SelectionResult> {
const { utxo: inputs, outputs, constraints, implicitValue } = params;
const utxoValues = toValues([...inputs]);
const outputsValues = toValues([...outputs]);
const totalLovelaceInUtxoSet = getCoinQuantity(utxoValues);
const totalLovelaceInOutputSet = getCoinQuantity(outputsValues);
const totalAssetsInUtxoSet = getAssetQuantities(utxoValues);
const totalAssetsInOutputSet = getAssetQuantities(outputsValues);
const implicitCoinInput = implicitValue?.coin?.input || 0n;
const implicitCoinOutput = implicitValue?.coin?.deposit || 0n;
const implicitAssetInput = implicitValue?.mint || new Map<AssetId, bigint>();
const totalLovelaceInput = totalLovelaceInUtxoSet + implicitCoinInput;
const totalLovelaceOutput = totalLovelaceInOutputSet + implicitCoinOutput;
const totalAssetsInput = addTokenMaps(totalAssetsInUtxoSet, implicitAssetInput);

// Calculate the difference between the given outputs and the UTXO set. This will let us know
// how much we need to return as change.
const changeLovelace = totalLovelaceInput - totalLovelaceOutput;
const changeAssets = subtractTokenMaps(totalAssetsInput, totalAssetsInOutputSet);

// If the wallet tries to spend more than we can cover with the current UTXO set + implicit value throw.
if (changeLovelace <= 0n || hasNegativeAssetValue(changeAssets))
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);

// Split the remaining amount to be returned as change proportionally between the given addresses.
const changeOutputs = await splitChange(
this.#props.getChangeAddresses,
changeLovelace,
changeAssets,
constraints.computeMinimumCoinQuantity,
constraints.tokenBundleSizeExceedsLimit
);

// Adjust the change outputs to account for the transaction fee.
const adjustedChangeOutputs = await adjustOutputsForFee(constraints, inputs, outputs, changeOutputs, 0n);

return {
remainingUTxO: new Set<Cardano.Utxo>(), // This input selection always consumes all inputs.
selection: {
change: adjustedChangeOutputs.change,
fee: adjustedChangeOutputs.fee,
inputs,
outputs
}
};
}
}
1 change: 1 addition & 0 deletions packages/input-selection/src/GreedySelection/index.ts
@@ -0,0 +1 @@
export * from './GreedyInputSelector';
13 changes: 8 additions & 5 deletions packages/input-selection/src/RoundRobinRandomImprove/change.ts
@@ -1,17 +1,20 @@
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit } from '../types';
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
import { RequiredImplicitValue, UtxoSelection, assetQuantitySelector, getCoinQuantity, toValues } from './util';
import {
RequiredImplicitValue,
UtxoSelection,
assetQuantitySelector,
getCoinQuantity,
stubMaxSizeAddress,
toValues
} from '../util';
import minBy from 'lodash/minBy';
import orderBy from 'lodash/orderBy';
import pick from 'lodash/pick';

type EstimateTxFeeWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise<Cardano.Lovelace>;

const stubMaxSizeAddress = Cardano.PaymentAddress(
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9'
);

interface ChangeComputationArgs {
utxoSelection: UtxoSelection;
outputValues: Cardano.Value[];
Expand Down
30 changes: 24 additions & 6 deletions packages/input-selection/src/RoundRobinRandomImprove/index.ts
@@ -1,28 +1,37 @@
import { Cardano, cmlUtil } from '@cardano-sdk/core';
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
import { InputSelectionParameters, InputSelector, SelectionResult } from '../types';
import { assertIsBalanceSufficient, preProcessArgs, toValues } from './util';
import { cmlUtil } from '@cardano-sdk/core';
import { assertIsBalanceSufficient, preProcessArgs, toValues } from '../util';
import { computeChangeAndAdjustForFee } from './change';
import { roundRobinSelection } from './roundRobin';

interface RoundRobinRandomImproveOptions {
getChangeAddress: () => Promise<Cardano.PaymentAddress>;
random?: typeof Math.random;
}

export const roundRobinRandomImprove = ({
getChangeAddress,
random = Math.random
}: RoundRobinRandomImproveOptions = {}): InputSelector => ({
}: RoundRobinRandomImproveOptions): InputSelector => ({
select: async ({
utxo: utxoSet,
outputs: outputSet,
constraints: { computeMinimumCost, computeSelectionLimit, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit },
implicitValue: partialImplicitValue = {}
}: InputSelectionParameters): Promise<SelectionResult> => {
const { utxo, outputs, uniqueTxAssetIDs, implicitValue } = preProcessArgs(utxoSet, outputSet, partialImplicitValue);
const changeAddress = await getChangeAddress();
const { utxo, outputs, uniqueTxAssetIDs, implicitValue } = preProcessArgs(
utxoSet,
outputSet,
changeAddress,
partialImplicitValue
);

assertIsBalanceSufficient(uniqueTxAssetIDs, utxo, outputs, implicitValue);

const roundRobinSelectionResult = roundRobinSelection({
changeAddress,
implicitValue,
outputs,
random,
Expand All @@ -34,7 +43,13 @@ export const roundRobinRandomImprove = ({
computeMinimumCoinQuantity,
estimateTxFee: (utxos, changeValues) =>
computeMinimumCost({
change: new Set(changeValues),
change: changeValues.map(
(value) =>
({
address: changeAddress,
value
} as Cardano.TxOut)
),
fee: cmlUtil.MAX_U64,
inputs: new Set(utxos),
outputs: outputSet
Expand All @@ -48,7 +63,10 @@ export const roundRobinRandomImprove = ({
});

const inputs = new Set(result.inputs);
const change = new Set(result.change);
const change = result.change.map((value) => ({
address: changeAddress,
value
}));

if (result.inputs.length > (await computeSelectionLimit({ change, fee: result.fee, inputs, outputs: outputSet }))) {
throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded);
Expand Down
Expand Up @@ -7,7 +7,7 @@ import {
assetQuantitySelector,
getCoinQuantity,
toValues
} from './util';
} from '../util';

const improvesSelection = (
utxoAlreadySelected: Cardano.Utxo[],
Expand Down
129 changes: 0 additions & 129 deletions packages/input-selection/src/RoundRobinRandomImprove/util.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/input-selection/src/index.ts
@@ -1,3 +1,4 @@
export * from './RoundRobinRandomImprove';
export * from './GreedySelection';
export * from './types';
export * from './InputSelectionError';

0 comments on commit 11512e9

Please sign in to comment.