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 f5f997e commit 7a9cc77
Show file tree
Hide file tree
Showing 14 changed files with 1,546 additions and 175 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
139 changes: 139 additions & 0 deletions packages/input-selection/src/GreedySelection/GreedyInputSelector.ts
@@ -0,0 +1,139 @@
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, Cardano.Percent>>;
}

/**
* Given a set of input and outputs, compute the fee. Then extract the fee from the change output
* with the highest value.
*
* @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
* as change.
*/
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,15 +1,18 @@
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';

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

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

interface ChangeComputationArgs {
Expand Down
@@ -1,7 +1,7 @@
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 { assertIsBalanceSufficient, preProcessArgs, toValues } from '../util';
import { computeChangeAndAdjustForFee } from './change';
import { roundRobinSelection } from './roundRobin';

Expand Down
Expand Up @@ -7,7 +7,7 @@ import {
assetQuantitySelector,
getCoinQuantity,
toValues
} from './util';
} from '../util';

const improvesSelection = (
utxoAlreadySelected: Cardano.Utxo[],
Expand Down
167 changes: 0 additions & 167 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 7a9cc77

Please sign in to comment.