Skip to content

Commit 4bd8de0

Browse files
feat(input-selection): added new greedy input selector
1 parent 954745c commit 4bd8de0

19 files changed

+2012
-431
lines changed

Diff for: packages/input-selection/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"dependencies": {
6969
"@cardano-sdk/core": "workspace:~",
7070
"@cardano-sdk/util": "workspace:~",
71+
"bignumber.js": "^9.1.1",
7172
"lodash": "^4.17.21",
7273
"ts-custom-error": "^3.2.0"
7374
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/* eslint-disable max-params */
2+
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
3+
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
4+
import { InputSelectionParameters, InputSelector, SelectionConstraints, SelectionResult } from '../types';
5+
import {
6+
addTokenMaps,
7+
getCoinQuantity,
8+
hasNegativeAssetValue,
9+
sortByCoins,
10+
stubMaxSizeAddress,
11+
subtractTokenMaps,
12+
toValues
13+
} from '../util';
14+
import { splitChange } from './util';
15+
16+
/**
17+
* Greedy selection initialization properties.
18+
*/
19+
export interface GreedySelectorProps {
20+
/**
21+
* Callback that returns a map of addresses with their intended proportions expressed as weights.
22+
*
23+
* The weight is an integer, and relative to other weights in the map. For example, a map with two addresses and
24+
* respective weights of 1 and 2 means that we expect the selector to assign twice more change to the second address
25+
* than the first. This means that for every 3 Ada, 1 Ada should go to the first address, and 2 Ada should go to
26+
* the second.
27+
*
28+
* If the same distribution is needed for each address use the same weight (e.g. 1).
29+
*
30+
* This selector will create N change outputs at this change addresses with the given proportions.
31+
*/
32+
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>;
33+
}
34+
35+
/**
36+
* Given a set of input and outputs, compute the fee. Then extract the fee from the change output
37+
* with the highest value.
38+
*
39+
* @param changeLovelace The available amount of lovelace to be used as change.
40+
* @param constraints The selection constraints.
41+
* @param inputs The inputs of the transaction.
42+
* @param outputs The outputs of the transaction.
43+
* @param changeOutputs The list of change outputs.
44+
* @param currentFee The current computed fee for this selection.
45+
*/
46+
const adjustOutputsForFee = async (
47+
changeLovelace: bigint,
48+
constraints: SelectionConstraints,
49+
inputs: Set<Cardano.Utxo>,
50+
outputs: Set<Cardano.TxOut>,
51+
changeOutputs: Array<Cardano.TxOut>,
52+
currentFee: bigint
53+
): Promise<{ fee: bigint; change: Array<Cardano.TxOut> }> => {
54+
const totalOutputs = new Set([...outputs, ...changeOutputs]);
55+
const fee = await constraints.computeMinimumCost({
56+
change: [],
57+
fee: currentFee,
58+
inputs,
59+
outputs: totalOutputs
60+
});
61+
62+
if (fee === changeLovelace) return { change: [], fee };
63+
64+
if (changeLovelace < fee) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
65+
66+
const updatedOutputs = [...changeOutputs];
67+
68+
updatedOutputs.sort(sortByCoins);
69+
70+
let feeAccountedFor = false;
71+
for (const output of updatedOutputs) {
72+
const adjustedCoins = output.value.coins - fee;
73+
74+
if (adjustedCoins >= constraints.computeMinimumCoinQuantity(output)) {
75+
output.value.coins = adjustedCoins;
76+
feeAccountedFor = true;
77+
break;
78+
}
79+
}
80+
81+
if (!feeAccountedFor) {
82+
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
83+
}
84+
85+
return { change: [...updatedOutputs], fee };
86+
};
87+
88+
/**
89+
* Recursively compute the fee and compute change outputs until it finds a set of change outputs that satisfies the fee.
90+
*
91+
* @param inputs The inputs of the transaction.
92+
* @param outputs The outputs of the transaction.
93+
* @param changeLovelace The total amount of lovelace in the change.
94+
* @param changeAssets The total assets to be distributed as change.
95+
* @param constraints The selection constraints.
96+
* @param getChangeAddresses A callback that returns a list of addresses and their proportions.
97+
* @param fee The current computed fee for this selection.
98+
*/
99+
const splitChangeAndComputeFee = async (
100+
inputs: Set<Cardano.Utxo>,
101+
outputs: Set<Cardano.TxOut>,
102+
changeLovelace: bigint,
103+
changeAssets: Cardano.TokenMap | undefined,
104+
constraints: SelectionConstraints,
105+
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>,
106+
fee: bigint
107+
): Promise<{ fee: bigint; change: Array<Cardano.TxOut> }> => {
108+
const changeOutputs = await splitChange(
109+
getChangeAddresses,
110+
changeLovelace,
111+
changeAssets,
112+
constraints.computeMinimumCoinQuantity,
113+
constraints.tokenBundleSizeExceedsLimit,
114+
fee
115+
);
116+
117+
const adjustedChangeOutputs = await adjustOutputsForFee(
118+
changeLovelace,
119+
constraints,
120+
inputs,
121+
outputs,
122+
changeOutputs,
123+
fee
124+
);
125+
126+
// If the newly computed fee is higher than tha available balance for change,
127+
// but there are unallocated native assets, return the assets as change with 0n coins.
128+
if (adjustedChangeOutputs.fee >= changeLovelace) {
129+
return {
130+
change: [
131+
{
132+
address: stubMaxSizeAddress,
133+
value: {
134+
assets: changeAssets,
135+
coins: 0n
136+
}
137+
}
138+
],
139+
fee: adjustedChangeOutputs.fee
140+
};
141+
}
142+
143+
if (fee < adjustedChangeOutputs.fee) {
144+
return splitChangeAndComputeFee(
145+
inputs,
146+
outputs,
147+
changeLovelace,
148+
changeAssets,
149+
constraints,
150+
getChangeAddresses,
151+
adjustedChangeOutputs.fee
152+
);
153+
}
154+
155+
return adjustedChangeOutputs;
156+
};
157+
158+
/**
159+
* Selects all UTXOs to fulfill the amount required for the given outputs and return the remaining balance
160+
* as change.
161+
*/
162+
export class GreedyInputSelector implements InputSelector {
163+
#props: GreedySelectorProps;
164+
165+
constructor(props: GreedySelectorProps) {
166+
this.#props = props;
167+
}
168+
169+
async select(params: InputSelectionParameters): Promise<SelectionResult> {
170+
const { utxo: inputs, outputs, constraints, implicitValue } = params;
171+
const utxoValues = toValues([...inputs]);
172+
const outputsValues = toValues([...outputs]);
173+
const totalLovelaceInUtxoSet = getCoinQuantity(utxoValues);
174+
const totalLovelaceInOutputSet = getCoinQuantity(outputsValues);
175+
const totalAssetsInUtxoSet = coalesceValueQuantities(utxoValues).assets;
176+
const totalAssetsInOutputSet = coalesceValueQuantities(outputsValues).assets;
177+
const implicitCoinInput = implicitValue?.coin?.input || 0n;
178+
const implicitCoinOutput = implicitValue?.coin?.deposit || 0n;
179+
const implicitAssetInput = implicitValue?.mint || new Map<Cardano.AssetId, bigint>();
180+
const totalLovelaceInput = totalLovelaceInUtxoSet + implicitCoinInput;
181+
const totalLovelaceOutput = totalLovelaceInOutputSet + implicitCoinOutput;
182+
const totalAssetsInput = addTokenMaps(totalAssetsInUtxoSet, implicitAssetInput);
183+
184+
const changeLovelace = totalLovelaceInput - totalLovelaceOutput;
185+
const changeAssets = subtractTokenMaps(totalAssetsInput, totalAssetsInOutputSet);
186+
187+
if (inputs.size === 0 || totalLovelaceOutput > totalLovelaceInput || hasNegativeAssetValue(changeAssets))
188+
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
189+
190+
const adjustedChangeOutputs = await splitChangeAndComputeFee(
191+
inputs,
192+
outputs,
193+
changeLovelace,
194+
changeAssets,
195+
constraints,
196+
this.#props.getChangeAddresses,
197+
0n
198+
);
199+
200+
const change = adjustedChangeOutputs.change.filter(
201+
(out) => out.value.coins > 0n || (out.value.assets?.size || 0) > 0
202+
);
203+
204+
if (changeLovelace - adjustedChangeOutputs.fee < 0n)
205+
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
206+
207+
if (
208+
inputs.size >
209+
(await constraints.computeSelectionLimit({ change, fee: adjustedChangeOutputs.fee, inputs, outputs }))
210+
) {
211+
throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded);
212+
}
213+
214+
return {
215+
remainingUTxO: new Set<Cardano.Utxo>(), // This input selection always consumes all inputs.
216+
selection: {
217+
change,
218+
fee: adjustedChangeOutputs.fee,
219+
inputs,
220+
outputs
221+
}
222+
};
223+
}
224+
}
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './GreedyInputSelector';
2+
export * from './util';

Diff for: packages/input-selection/src/GreedySelection/util.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/* eslint-disable func-style, max-params */
2+
import { BigNumber } from 'bignumber.js';
3+
import { Cardano } from '@cardano-sdk/core';
4+
import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit } from '../types';
5+
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
6+
import { addTokenMaps, isValidValue, sortByCoins, subtractTokenMaps } from '../util';
7+
8+
const PERCENTAGE_TOLERANCE = 0.05;
9+
10+
/**
11+
* Distribute the assets among the given outputs. The function will try to allocate all the assets in
12+
* the output with the biggest coin balance, if this fails, it will spill over the assets to the second output (and so on)
13+
* until it can distribute all assets among the outputs. If no such distribution can be found, the algorithm with fail.
14+
*
15+
* remark: At this point we are not ready to compute the fee, which would need to be subtracted from one of this change
16+
* outputs, so we are going to assume a high fee for the time being (2000000 lovelace). This will guarantee that the
17+
* outputs will remain valid even after the fee has been subtracted from the change output.
18+
*
19+
* @param outputs The outputs where to distribute the assets into.
20+
* @param computeMinimumCoinQuantity callback that computes the minimum coin quantity for the given UTXO.
21+
* @param tokenBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit.
22+
* @param fee The transaction fee to be discounted.
23+
* @returns a new change output array with the given assets allocated.
24+
*/
25+
const distributeAssets = (
26+
outputs: Array<Cardano.TxOut>,
27+
computeMinimumCoinQuantity: ComputeMinimumCoinQuantity,
28+
tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit,
29+
fee: bigint
30+
): Array<Cardano.TxOut> => {
31+
const adjustedOutputs = [...outputs];
32+
33+
for (let i = 0; i < adjustedOutputs.length; ++i) {
34+
const output = adjustedOutputs[i];
35+
if (!isValidValue(output.value, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, fee)) {
36+
if (i === adjustedOutputs.length - 1) {
37+
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
38+
}
39+
40+
if (!output.value.assets || output.value.assets.size === 0) {
41+
// If this output failed and doesn't contain any assets, it means there is not enough coins to cover
42+
// the min ADA coin per UTXO even after moving all the assets to the other outputs.
43+
throw new InputSelectionError(InputSelectionFailure.UtxoFullyDepleted);
44+
}
45+
46+
const splicedAsset = new Map([...output.value.assets!.entries()].splice(0, 1));
47+
const currentOutputNewAssets = subtractTokenMaps(output.value.assets, splicedAsset);
48+
const nextOutputNewAssets = addTokenMaps(adjustedOutputs[i + 1].value.assets, splicedAsset);
49+
50+
output.value.assets = currentOutputNewAssets;
51+
adjustedOutputs[i + 1].value.assets = nextOutputNewAssets;
52+
53+
return distributeAssets(adjustedOutputs, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, fee);
54+
}
55+
}
56+
57+
return adjustedOutputs;
58+
};
59+
60+
/**
61+
* Splits the change proportionally between the given addresses. This algorithm makes
62+
* the best effort to be as accurate as possible in distributing the amounts, however, due to rounding
63+
* there may be a small error in the final distribution, I.E 8 lovelace divided in three equal parts will
64+
* yield 3, 3, 2 lovelace with an error of 33% in the last change output as lovelaces can't be further subdivided (The
65+
* error should be marginal for large amounts of lovelace).
66+
*
67+
* While lovelaces will be split according to the given distribution, native assets will use a different heuristic. We
68+
* will try to add all native assets to the UTXO with the most coins in the change outputs, if they don't 'fit', we will spill over to
69+
* the next change output and so on. We will assume a high fee (2 ADA) while doing this native asset allocation (this will guarantee that
70+
* when the actual fee is computed the largest change output can afford to discount it without becoming invalid). This is a rather
71+
* naive approach, but should work as long as the wallet is not at its maximum capacity for holding native assets due to minCoinAda
72+
* restrictions on the UTXOs.
73+
*
74+
* @param getChangeAddresses A callback that returns a list of addresses and their proportions.
75+
* @param totalChangeLovelace The total amount of lovelace in the change.
76+
* @param totalChangeAssets The total assets to be distributed as change.
77+
* @param computeMinimumCoinQuantity callback that computes the minimum coin quantity for the given UTXO.
78+
* @param tokenBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit.
79+
* @param fee The transaction fee to be discounted.
80+
*/
81+
export const splitChange = async (
82+
getChangeAddresses: () => Promise<Map<Cardano.PaymentAddress, number>>,
83+
totalChangeLovelace: bigint,
84+
totalChangeAssets: Cardano.TokenMap | undefined,
85+
computeMinimumCoinQuantity: ComputeMinimumCoinQuantity,
86+
tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit,
87+
fee: bigint
88+
): Promise<Array<Cardano.TxOut>> => {
89+
const changeAddresses = await getChangeAddresses();
90+
const totalWeight = [...changeAddresses.values()].reduce((sum, current) => sum + current, 0);
91+
const changeAsPercent = new Map([...changeAddresses.entries()].map((value) => [value[0], value[1] / totalWeight]));
92+
const totalPercentage = [...changeAsPercent.values()].reduce((sum, current) => sum + current, 0);
93+
94+
// We are going to enforce that the given % 'mostly' add up to 100% (to account for division errors)
95+
if (Math.abs(1 - totalPercentage) > PERCENTAGE_TOLERANCE)
96+
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); // TODO: We need a new error for this types of failures
97+
98+
const changeOutputs: Array<Cardano.TxOut> = [...changeAsPercent.entries()].map((val) => ({
99+
address: val[0],
100+
value: { coins: 0n }
101+
}));
102+
103+
let runningTotal = 0n;
104+
const totalCoinAllocation = new BigNumber(totalChangeLovelace.toString());
105+
for (const txOut of changeOutputs) {
106+
const factor = new BigNumber(changeAsPercent.get(txOut.address)!);
107+
const coinAllocation = BigInt(totalCoinAllocation.multipliedBy(factor).toFixed(0, 0)); // Round up and no decimals
108+
109+
runningTotal += coinAllocation;
110+
111+
// If we over shoot the available coin change, subtract the extra from the last output.
112+
txOut.value.coins =
113+
runningTotal > totalChangeLovelace ? coinAllocation - (runningTotal - totalChangeLovelace) : coinAllocation;
114+
}
115+
116+
if (runningTotal < totalChangeLovelace) {
117+
// This may be because the given proportions don't add up to 100% due to
118+
// rounding errors.
119+
const missingAllocation = totalChangeLovelace - runningTotal;
120+
changeOutputs[changeOutputs.length - 1].value.coins += missingAllocation;
121+
}
122+
123+
const sortedOutputs = changeOutputs.sort(sortByCoins).filter((out) => out.value.coins > 0n);
124+
125+
if (sortedOutputs && sortedOutputs.length > 0) sortedOutputs[0].value.assets = totalChangeAssets; // Add all assets to the 'biggest' output.
126+
127+
if (!totalChangeAssets || totalChangeAssets.size === 0) return sortedOutputs;
128+
129+
return distributeAssets(sortedOutputs, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, fee);
130+
};

Diff for: packages/input-selection/src/RoundRobinRandomImprove/change.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
22
import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit } from '../types';
33
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
4-
import { RequiredImplicitValue, UtxoSelection, assetQuantitySelector, getCoinQuantity, toValues } from './util';
4+
import {
5+
RequiredImplicitValue,
6+
UtxoSelection,
7+
assetQuantitySelector,
8+
getCoinQuantity,
9+
stubMaxSizeAddress,
10+
toValues
11+
} from '../util';
512
import minBy from 'lodash/minBy';
613
import orderBy from 'lodash/orderBy';
714
import pick from 'lodash/pick';
815

9-
export const stubMaxSizeAddress = Cardano.PaymentAddress(
10-
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9'
11-
);
12-
1316
type EstimateTxFeeWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise<Cardano.Lovelace>;
1417

1518
interface ChangeComputationArgs {

Diff for: packages/input-selection/src/RoundRobinRandomImprove/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Cardano, cmlUtil } from '@cardano-sdk/core';
22
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
33
import { InputSelectionParameters, InputSelector, SelectionResult } from '../types';
4-
import { assertIsBalanceSufficient, preProcessArgs, toValues } from './util';
4+
import { assertIsBalanceSufficient, preProcessArgs, toValues } from '../util';
55
import { computeChangeAndAdjustForFee } from './change';
66
import { roundRobinSelection } from './roundRobin';
77

0 commit comments

Comments
 (0)