diff --git a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts index cfefca50779..373d3f735b3 100644 --- a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts +++ b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts @@ -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 { @@ -30,146 +29,6 @@ export interface GreedySelectorProps { getChangeAddresses: () => Promise>; } -/** - * 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, - outputs: Set, - changeOutputs: Array, - currentFee: bigint -): Promise<{ - fee: bigint; - change: Array; - feeAccountedFor: boolean; - redeemers?: Array; -}> => { - 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, - outputs: Set, - changeLovelace: bigint, - changeAssets: Cardano.TokenMap | undefined, - constraints: SelectionConstraints, - getChangeAddresses: () => Promise>, - fee: bigint -): Promise<{ fee: bigint; change: Array; 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; diff --git a/packages/input-selection/src/GreedySelection/util.ts b/packages/input-selection/src/GreedySelection/util.ts index 191c23ab2e9..e7bb03cc904 100644 --- a/packages/input-selection/src/GreedySelection/util.ts +++ b/packages/input-selection/src/GreedySelection/util.ts @@ -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; @@ -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, + outputs: Set, + changeOutputs: Array, + currentFee: bigint +): Promise<{ + fee: bigint; + change: Array; + feeAccountedFor: boolean; + redeemers?: Array; +}> => { + 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, + outputs: Set, + changeLovelace: bigint, + changeAssets: Cardano.TokenMap | undefined, + constraints: SelectionConstraints, + getChangeAddresses: () => Promise>, + fee: bigint +): Promise<{ fee: bigint; change: Array; 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; +}; diff --git a/packages/input-selection/src/LargeFirstSelection/LargeFirstInputSelector.ts b/packages/input-selection/src/LargeFirstSelection/LargeFirstInputSelector.ts new file mode 100644 index 00000000000..45492a06d4c --- /dev/null +++ b/packages/input-selection/src/LargeFirstSelection/LargeFirstInputSelector.ts @@ -0,0 +1,429 @@ +/* eslint-disable max-params */ +import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; +import { ChangeAddressResolver } from '../ChangeAddress'; +import { + ImplicitTokens, + MAX_U64, + UtxoSelection, + addTokenMaps, + getCoinQuantity, + hasNegativeAssetValue, + mintToImplicitTokens, + sortByAssetQuantity, + sortByCoins, + sortUtxoByTxIn, + stubMaxSizeAddress, + subtractTokenMaps, + toValues +} from '../util'; +import { + ImplicitValue, + InputSelectionParameters, + InputSelector, + SelectionConstraints, + SelectionResult +} from '../types'; +import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError'; + +import { computeChangeAndAdjustForFee } from '../change'; + +import uniq from 'lodash/uniq.js'; + +/** + * A `PickAdditionalUtxo` callback for the large-first strategy. + * + * The callback simply: + * 1. Sorts the remaining UTxOs by coin quantity (largest first). + * 2. Picks the first UTxO from the sorted list. + * 3. Returns the updated `UtxoSelection`. + */ +const pickAdditionalLargestUtxo = ({ utxoRemaining, utxoSelected }: UtxoSelection): UtxoSelection => { + if (utxoRemaining.length === 0) { + return { utxoRemaining, utxoSelected }; + } + + const sorted = utxoRemaining.sort(([, a], [, b]) => sortByCoins(a, b)); + const [picked, ...newRemaining] = sorted; + + return { + utxoRemaining: newRemaining, + utxoSelected: [...utxoSelected, picked] + }; +}; + +/** LargeFirst selection initialization properties. */ +export interface LargeFirstSelectorProps { + changeAddressResolver: ChangeAddressResolver; +} + +/** + * Input selector that implements a "large-first" strategy. + * + * This strategy selects the largest UTxOs per asset, one asset at a time, until the requirements + * of all assets in the outputs + fees and implicit values are satisfied. + */ +export class LargeFirstSelector implements InputSelector { + #props: LargeFirstSelectorProps; + + /** Creates a new instance of the LargeFirstSelector. */ + constructor(props: LargeFirstSelectorProps) { + this.#props = props; + } + + /** + * Selects inputs using a large-first strategy: + * Selects largest UTxOs per asset until target is met + * Then selects largest UTxOs by Ada + * Then iteratively adds more UTxOs if needed to cover fees + * + * @param params Input selection parameters (available UTxOs, outputs, constraints, etc.) + * @returns A complete selection including inputs, outputs, change, and fee. + * @throws {InputSelectionError} If the selection cannot satisfy the outputs and fees. + */ + async select(params: InputSelectionParameters): Promise { + const { utxo, preSelectedUtxo, outputs, constraints, implicitValue } = params; + + const preSelected = [...preSelectedUtxo]; + const available = [...utxo].filter( + ([txIn]) => !preSelected.some(([preTxIn]) => preTxIn.txId === txIn.txId && preTxIn.index === txIn.index) + ); + const allAvailable = [...preSelected, ...available]; + + const { totalLovelaceOutput, outputAssets } = this.#computeNetImplicitSelectionValues( + new Set(allAvailable), + outputs, + implicitValue + ); + + let workingUtxo = new Set(preSelected); + workingUtxo = this.#selectAssets(outputAssets, allAvailable, [...workingUtxo]); + workingUtxo = this.#selectLovelace( + totalLovelaceOutput, + allAvailable, + workingUtxo, + implicitValue?.coin?.input ?? 0n + ); + + const { finalSelection, fee, change } = await this.#expandUtxosUntilFeeCovered( + workingUtxo, + outputs, + allAvailable, + outputAssets, + constraints, + this.#props.changeAddressResolver, + implicitValue + ); + + const limit = await constraints.computeSelectionLimit({ change, fee, inputs: finalSelection, outputs }); + if (finalSelection.size > limit) throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded); + + return { + remainingUTxO: this.#computeRemainingUtxo([...utxo], finalSelection), + selection: { + change, + fee, + inputs: new Set([...finalSelection].sort(sortUtxoByTxIn)), + outputs + } + }; + } + + /** + * Aggregate Lovelace that enters and leaves the transaction, + * taking implicit withdrawals / deposits into account. + * + * @param inputs Array of `Value`s held by the selected UTxOs. + * @param outputs Array of explicit transaction outputs. + * @param implicit Optional implicit values. + * @returns An object with + * `totalIn` — Lovelace provided by UTxOs + withdrawals + * `totalOut` — Lovelace required for explicit outputs + deposits + */ + #aggregateLovelace( + inputs: Cardano.Value[], + outputs: Cardano.Value[], + implicit?: ImplicitValue + ): { totalIn: bigint; totalOut: bigint } { + const utxoAda = getCoinQuantity(inputs); + const outputAda = getCoinQuantity(outputs); + const withdrawAda = implicit?.coin?.input ?? 0n; + const depositAda = implicit?.coin?.deposit ?? 0n; + + return { + totalIn: utxoAda + withdrawAda, + totalOut: outputAda + depositAda + }; + } + + /** + * Compute two token maps: + * - required: the exact quantity of every asset that must be provided by inputs in order to satisfy explicit outputs plus any burns. + * - available: the quantity of every asset that is actually provided by inputs + positive mint. + * + * @param inputs Values contained in the UTxOs selected so far. + * @param outputs Values required by the user’s explicit transaction outputs. + * @param implicit Optional `mint` map (positive = forge, negative = burn). + * @returns `{ required, available }` + */ + #aggregateAssets( + inputs: Cardano.Value[], + outputs: Cardano.Value[], + implicit?: ImplicitValue + ): { + required: Cardano.TokenMap; + available: Cardano.TokenMap; + } { + const outputsMap = coalesceValueQuantities(outputs).assets ?? new Map(); + const utxoMap = coalesceValueQuantities(inputs).assets; + const mint = implicit?.mint ?? new Map(); + + const posMint = new Map(); + const negMint = new Map(); + + for (const [id, q] of mint) (q > 0n ? posMint : negMint).set(id, q > 0n ? q : -q); + + let required = addTokenMaps(outputsMap, negMint); + required = subtractTokenMaps(required, posMint) ?? new Map(); + + const available = addTokenMaps(utxoMap, posMint) ?? new Map(); + + return { available, required }; + } + + /** + * Computes the total Lovelace and asset output requirements, including the effects + * of implicit values such as key deposits, withdrawals, and minting/burning. + * + * Minting and burning are treated as negative or positive contributions to input balance, + * and are subtracted from the output requirements. + * + * @param inputs The full set of selected UTxOs (including pre-selected). + * @param outputs The transaction outputs. + * @param implicitValue Optional implicit values including deposits, withdrawals, and minting. + * @returns An object with: + * `totalLovelaceInput`: Sum of all input Lovelace, including withdrawals. + * `totalLovelaceOutput`: Sum of all output Lovelace, including deposits. + * `outputAssets`: Asset requirements after accounting for minting/burning. + * @throws {InputSelectionError} If balance is insufficient to satisfy the target. + */ + #computeNetImplicitSelectionValues( + inputs: Set, + outputs: Set, + implicitValue?: ImplicitValue + ): { + totalLovelaceInput: bigint; + totalLovelaceOutput: bigint; + outputAssets: Cardano.TokenMap; + } { + const inputVals = toValues([...inputs]); + const outputVals = toValues([...outputs]); + + const { totalIn, totalOut } = this.#aggregateLovelace(inputVals, outputVals, implicitValue); + const { required, available } = this.#aggregateAssets(inputVals, outputVals, implicitValue); + + const changeAda = totalIn - totalOut; + const changeAssets = subtractTokenMaps(available, required); + + if (inputs.size === 0 || changeAda < 0n || hasNegativeAssetValue(changeAssets)) + throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + + return { + outputAssets: required, + totalLovelaceInput: totalIn, + totalLovelaceOutput: totalOut + }; + } + + /** + * Selects the largest UTxOs per required asset until each target amount is fulfilled. + * + * @param requiredAssets The asset quantities required by the transaction outputs. + * @param allAvailable All available UTxOs (including preselected ones). + * @param preSelected The UTxOs already selected for the transaction. + * @returns A set of selected UTxOs covering the asset requirements. + * @throws {InputSelectionError} If any asset cannot be sufficiently fulfilled. + */ + #selectAssets( + requiredAssets: Map, + allAvailable: Cardano.Utxo[], + preSelected: Cardano.Utxo[] + ): Set { + const selected = new Set(preSelected); + + for (const [assetId, requiredQuantity] of requiredAssets) { + const candidates = allAvailable + .filter(([_, out]) => (out.value.assets?.get(assetId) ?? 0n) > 0n) + .sort(([, a], [, b]) => sortByAssetQuantity(assetId)(a, b)); + + let accumulated = 0n; + + for (const candidate of candidates) { + selected.add(candidate); + accumulated += candidate[1].value.assets?.get(assetId) ?? 0n; + if (accumulated >= requiredQuantity) break; + } + + if (accumulated < requiredQuantity) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + } + + return selected; + } + + /** + * Selects UTxOs (largest Ada first) until the total Lovelace covers the target amount. + * + * @param target The required amount of Lovelace. + * @param allAvailable All available UTxOs. + * @param selected The current set of already selected UTxOs. + * @param implicitCoinInput The implicit coin input amount. + * @returns A new set including the original selected UTxOs plus any added for Ada coverage. + * @throws {InputSelectionError} If the Lovelace requirement cannot be fulfilled. + */ + #selectLovelace( + target: bigint, + allAvailable: Cardano.Utxo[], + selected: Set, + implicitCoinInput: bigint + ): Set { + const result = new Set(selected); + const selectedTxIns = new Set([...selected].map(([txIn]) => txIn)); + + const adaCandidates = allAvailable + .filter(([txIn]) => !selectedTxIns.has(txIn)) + .sort(([, a], [, b]) => sortByCoins(a, b)); + + let adaAccumulated = getCoinQuantity(toValues([...result])) + implicitCoinInput; + + for (const candidate of adaCandidates) { + if (adaAccumulated >= target) break; + result.add(candidate); + adaAccumulated += candidate[1].value.coins; + } + + if (result.size === 0) { + if (adaCandidates.length === 0) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + result.add(adaCandidates[0]); + } + + if (adaAccumulated < target) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + + return result; + } + + /** + * Computes the UTxOs that were not selected, given the original list and selected set. + * + * @param original The original list of available UTxOs (excluding preselected). + * @param used The set of selected UTxOs. + * @returns A new set of UTxOs that were not consumed in the transaction. + */ + #computeRemainingUtxo(original: Cardano.Utxo[], used: Set): Set { + const usedTxIns = new Set([...used].map(([txIn]) => txIn)); + return new Set(original.filter(([txIn]) => !usedTxIns.has(txIn))); + } + + /** + * Select additional UTxOs until the fee and min-Ada requirements are + * satisfied, then build the final change outputs. + * + * @param initialInputs The UTxOs already chosen for assets and/or pre-selected by the wallet. + * @param outputs The explicit transaction outputs. + * @param allAvailable Every UTxO the wallet can spend (pre-selected ∪ utxo). + * @param requiredAssets Aggregate asset requirements computed from `outputs` + burn. Used for fee/Ada expansion. + * @param constraints Network / wallet selection constraints (min-Ada, fee estimator, bundle size, limit…). + * @param changeAddressResolver Callback that assigns addresses (or further splits) for the provisional change bundles returned by the fee engine. + * @param implicitValue Optional implicit components (deposits, withdrawals, mint or burn) + * @returns An object containing Set of all inputs that will appear in the tx body, minimum fee returned by the cost model and an Array of change outputs. + */ + async #expandUtxosUntilFeeCovered( + initialInputs: Set, + outputs: Set, + allAvailable: Cardano.Utxo[], + requiredAssets: Cardano.TokenMap, + constraints: SelectionConstraints, + changeAddressResolver: ChangeAddressResolver, + implicitValue?: ImplicitValue + ): Promise<{ finalSelection: Set; fee: bigint; change: Cardano.TxOut[] }> { + const utxoSelectedArr = [...initialInputs]; + const utxoRemainingArr = allAvailable + .filter((u) => !initialInputs.has(u)) + .sort(([, a], [, b]) => sortByCoins(a, b)); // Ada-descending + + const outputValues = toValues([...outputs]); + + const changeAddress = stubMaxSizeAddress; + + const implicitCoin: Required = { + deposit: implicitValue?.coin?.deposit || 0n, + input: implicitValue?.coin?.input || 0n, + reclaimDeposit: implicitValue?.coin?.reclaimDeposit || 0n, + withdrawals: implicitValue?.coin?.withdrawals || 0n + }; + const mintMap: Cardano.TokenMap = implicitValue?.mint || new Map(); + const uniqueTxAssetIDs = uniq([...requiredAssets.keys(), ...mintMap.keys()]); + + const { implicitTokensInput, implicitTokensSpend } = mintToImplicitTokens(mintMap); + const implicitTokens: ImplicitTokens = { + input: (assetId) => implicitTokensInput.get(assetId) || 0n, + spend: (assetId) => implicitTokensSpend.get(assetId) || 0n + }; + const { + change, + fee, + inputs: finalInputs + } = await computeChangeAndAdjustForFee({ + computeMinimumCoinQuantity: constraints.computeMinimumCoinQuantity, + estimateTxCosts: (utxos, changeValues) => + constraints.computeMinimumCost({ + change: changeValues.map( + (value) => + ({ + address: changeAddress, + value + } as Cardano.TxOut) + ), + fee: MAX_U64, + inputs: new Set(utxos), + outputs + }), + implicitValue: { + implicitCoin, + implicitTokens + }, + outputValues, + pickAdditionalUtxo: pickAdditionalLargestUtxo, + tokenBundleSizeExceedsLimit: constraints.tokenBundleSizeExceedsLimit, + uniqueTxAssetIDs, + utxoSelection: { + utxoRemaining: utxoRemainingArr, + utxoSelected: utxoSelectedArr + } + }); + + if (change.length === 0) { + return { + change: [], + fee, + finalSelection: new Set(finalInputs) + }; + } + + const changeTxOuts: Cardano.TxOut[] = change.map((val) => ({ + address: changeAddress, + value: val + })); + + const resolvedChange = await changeAddressResolver.resolve({ + change: changeTxOuts, + fee, + inputs: new Set(finalInputs), + outputs + }); + + return { + change: resolvedChange, + fee, + finalSelection: new Set(finalInputs) + }; + } +} diff --git a/packages/input-selection/src/LargeFirstSelection/index.ts b/packages/input-selection/src/LargeFirstSelection/index.ts new file mode 100644 index 00000000000..2fb18d2e788 --- /dev/null +++ b/packages/input-selection/src/LargeFirstSelection/index.ts @@ -0,0 +1 @@ +export * from './LargeFirstInputSelector'; diff --git a/packages/input-selection/src/RoundRobinRandomImprove/index.ts b/packages/input-selection/src/RoundRobinRandomImprove/index.ts index 466d182a80d..c36f58022e1 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/index.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/index.ts @@ -2,18 +2,36 @@ import { Cardano } from '@cardano-sdk/core'; import { ChangeAddressResolver } from '../ChangeAddress'; import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError'; import { InputSelectionParameters, InputSelector, SelectionResult } from '../types'; -import { assertIsBalanceSufficient, preProcessArgs, stubMaxSizeAddress, toValues } from '../util'; -import { computeChangeAndAdjustForFee } from './change'; +import { + MAX_U64, + UtxoSelection, + assertIsBalanceSufficient, + preProcessArgs, + sortUtxoByTxIn, + stubMaxSizeAddress, + toValues +} from '../util'; +import { PickAdditionalUtxo, computeChangeAndAdjustForFee } from '../change'; import { roundRobinSelection } from './roundRobin'; -import { sortUtxoByTxIn } from '../GreedySelection'; - -export const MAX_U64 = 18_446_744_073_709_551_615n; interface RoundRobinRandomImproveOptions { changeAddressResolver: ChangeAddressResolver; random?: typeof Math.random; } +/** Picks one UTxO from remaining set and puts it to the selected set. Precondition: utxoRemaining.length > 0 */ +export const createPickAdditionalRandomUtxo = + (random: typeof Math.random): PickAdditionalUtxo => + ({ utxoRemaining, utxoSelected }: UtxoSelection): UtxoSelection => { + const remainingUtxoOfOnlyCoin = utxoRemaining.filter(([_, { value }]) => !value.assets); + const pickFrom = remainingUtxoOfOnlyCoin.length > 0 ? remainingUtxoOfOnlyCoin : utxoRemaining; + const pickIdx = Math.floor(random() * pickFrom.length); + const newUtxoSelected = [...utxoSelected, pickFrom[pickIdx]]; + const originalIdx = utxoRemaining.indexOf(pickFrom[pickIdx]); + const newUtxoRemaining = [...utxoRemaining.slice(0, originalIdx), ...utxoRemaining.slice(originalIdx + 1)]; + return { utxoRemaining: newUtxoRemaining, utxoSelected: newUtxoSelected }; + }; + export const roundRobinRandomImprove = ({ changeAddressResolver, random = Math.random @@ -63,7 +81,7 @@ export const roundRobinRandomImprove = ({ }), implicitValue, outputValues: toValues(outputs), - random, + pickAdditionalUtxo: createPickAdditionalRandomUtxo(random), tokenBundleSizeExceedsLimit, uniqueTxAssetIDs, utxoSelection: roundRobinSelectionResult diff --git a/packages/input-selection/src/RoundRobinRandomImprove/change.ts b/packages/input-selection/src/change.ts similarity index 94% rename from packages/input-selection/src/RoundRobinRandomImprove/change.ts rename to packages/input-selection/src/change.ts index 42b78358b4b..bdffbaf97ab 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/change.ts +++ b/packages/input-selection/src/change.ts @@ -1,6 +1,6 @@ import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; -import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit, TxCosts } from '../types'; -import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError'; +import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit, TxCosts } from './types'; +import { InputSelectionError, InputSelectionFailure } from './InputSelectionError'; import { RequiredImplicitValue, UtxoSelection, @@ -8,13 +8,21 @@ import { getCoinQuantity, stubMaxSizeAddress, toValues -} from '../util'; +} from './util'; import minBy from 'lodash/minBy.js'; import orderBy from 'lodash/orderBy.js'; import pick from 'lodash/pick.js'; type EstimateTxCostsWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise; +/** + * Callback used by the change-selection whenever it needs to pull one additional UTxO from `utxoRemaining` into `utxoSelected`. + * + * @param selection Object that holds the current state of the selection. + * @returns A new `UtxoSelection` object in which exactly one entry has been moved from `utxoRemaining` to `utxoSelected`. + */ +export type PickAdditionalUtxo = (selection: UtxoSelection) => UtxoSelection; + interface ChangeComputationArgs { utxoSelection: UtxoSelection; outputValues: Cardano.Value[]; @@ -23,7 +31,7 @@ interface ChangeComputationArgs { estimateTxCosts: EstimateTxCostsWithOriginalOutputs; computeMinimumCoinQuantity: ComputeMinimumCoinQuantity; tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit; - random: typeof Math.random; + pickAdditionalUtxo: PickAdditionalUtxo; } interface ChangeComputationResult { @@ -194,20 +202,6 @@ const computeRequestedAssetChangeBundles = ( return bundles; }; -/** Picks one UTxO from remaining set and puts it to the selected set. Precondition: utxoRemaining.length > 0 */ -const pickExtraRandomUtxo = ( - { utxoRemaining, utxoSelected }: UtxoSelection, - random: typeof Math.random -): UtxoSelection => { - const remainingUtxoOfOnlyCoin = utxoRemaining.filter(([_, { value }]) => !value.assets); - const pickFrom = remainingUtxoOfOnlyCoin.length > 0 ? remainingUtxoOfOnlyCoin : utxoRemaining; - const pickIdx = Math.floor(random() * pickFrom.length); - const newUtxoSelected = [...utxoSelected, pickFrom[pickIdx]]; - const originalIdx = utxoRemaining.indexOf(pickFrom[pickIdx]); - const newUtxoRemaining = [...utxoRemaining.slice(0, originalIdx), ...utxoRemaining.slice(originalIdx + 1)]; - return { utxoRemaining: newUtxoRemaining, utxoSelected: newUtxoSelected }; -}; - const mergeWithSmallestBundle = (values: Cardano.Value[], index: number): Cardano.Value[] => { let result = [...values]; const toBeMerged = result.splice(index, 1)[0]; @@ -408,7 +402,7 @@ export const computeChangeAndAdjustForFee = async ({ outputValues, uniqueTxAssetIDs, implicitValue, - random, + pickAdditionalUtxo, utxoSelection }: ChangeComputationArgs): Promise => { const recomputeChangeAndAdjustForFeeWithExtraUtxo = (currentUtxoSelection: UtxoSelection) => { @@ -418,10 +412,10 @@ export const computeChangeAndAdjustForFee = async ({ estimateTxCosts, implicitValue, outputValues, - random, + pickAdditionalUtxo, tokenBundleSizeExceedsLimit, uniqueTxAssetIDs, - utxoSelection: pickExtraRandomUtxo(currentUtxoSelection, random) + utxoSelection: pickAdditionalUtxo(currentUtxoSelection) }); } // This is not a great error type for this, because the spec says diff --git a/packages/input-selection/src/index.ts b/packages/input-selection/src/index.ts index cf8b263d3a4..6d18b7aa34e 100644 --- a/packages/input-selection/src/index.ts +++ b/packages/input-selection/src/index.ts @@ -1,5 +1,7 @@ export * from './RoundRobinRandomImprove'; export * from './GreedySelection'; +export * from './LargeFirstSelection'; export * from './types'; export * from './InputSelectionError'; export * from './ChangeAddress'; +export { sortTxIn, sortUtxoByTxIn } from './util'; diff --git a/packages/input-selection/src/util.ts b/packages/input-selection/src/util.ts index 6a46653fc2a..4404e146aee 100644 --- a/packages/input-selection/src/util.ts +++ b/packages/input-selection/src/util.ts @@ -6,10 +6,33 @@ import { ComputeMinimumCoinQuantity, ImplicitValue, TokenBundleSizeExceedsLimit import { InputSelectionError, InputSelectionFailure } from './InputSelectionError'; import uniq from 'lodash/uniq.js'; +export const MAX_U64 = 18_446_744_073_709_551_615n; + export const stubMaxSizeAddress = Cardano.PaymentAddress( 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' ); +/** + * Sorts the given TxIn set first by txId and then by index. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The left-hand side of the comparison operation. + */ +export const sortTxIn = (lhs: Cardano.TxIn, rhs: Cardano.TxIn) => { + const txIdComparison = lhs.txId.localeCompare(rhs.txId); + if (txIdComparison !== 0) return txIdComparison; + + return lhs.index - rhs.index; +}; + +/** + * Sorts the given Utxo set first by TxIn. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The left-hand side of the comparison operation. + */ +export const sortUtxoByTxIn = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => sortTxIn(lhs[0], rhs[0]); + export interface ImplicitTokens { spend(assetId: Cardano.AssetId): bigint; input(assetId: Cardano.AssetId): bigint; @@ -307,3 +330,21 @@ export const isValidValue = ( return isValid; }; + +/** + * Creates a comparator function that sorts `TxOut`s in descending order based on + * the quantity of a specified asset. + * + * @param assetId - The asset ID to compare quantities of. + * @returns A comparator function that can be used with `.sort()`. + */ +export const sortByAssetQuantity = + (assetId: Cardano.AssetId) => + (lhs: Cardano.TxOut, rhs: Cardano.TxOut): number => { + const lhsQty = lhs.value.assets?.get(assetId) ?? 0n; + const rhsQty = rhs.value.assets?.get(assetId) ?? 0n; + + if (lhsQty > rhsQty) return -1; + if (lhsQty < rhsQty) return 1; + return 0; + }; diff --git a/packages/input-selection/test/InputSelectionPropertyTesting.test.ts b/packages/input-selection/test/InputSelectionPropertyTesting.test.ts index a6622dc9752..81d8995199b 100644 --- a/packages/input-selection/test/InputSelectionPropertyTesting.test.ts +++ b/packages/input-selection/test/InputSelectionPropertyTesting.test.ts @@ -1,6 +1,13 @@ import { AssetId, TxTestUtil } from '@cardano-sdk/util-dev'; import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; -import { ChangeAddressResolver, GreedyInputSelector, InputSelectionError, InputSelector, Selection } from '../src'; +import { + ChangeAddressResolver, + GreedyInputSelector, + InputSelectionError, + InputSelector, + LargeFirstSelector, + Selection +} from '../src'; import { InputSelectionFailure } from '../src/InputSelectionError'; import { SelectionConstraints, @@ -36,6 +43,11 @@ const createGreedySelector = () => getChangeAddresses: async () => new Map([[asPaymentAddress('A'), 1]]) }); +const createLargeFirstSelector = () => + new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => { describe(name, () => { describe('Properties', () => { @@ -343,3 +355,4 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => testInputSelection('RoundRobinRandomImprove', createRoundRobinRandomImprove); testInputSelection('GreedySelector', createGreedySelector); +testInputSelection('LargeFirstSelector', createLargeFirstSelector); diff --git a/packages/input-selection/test/LargeFirstSelection/LargeFirstSelection.test.ts b/packages/input-selection/test/LargeFirstSelection/LargeFirstSelection.test.ts new file mode 100644 index 00000000000..4ddbb2b4ab0 --- /dev/null +++ b/packages/input-selection/test/LargeFirstSelection/LargeFirstSelection.test.ts @@ -0,0 +1,342 @@ +import { Cardano } from '@cardano-sdk/core'; +import { LargeFirstSelector } from '../../src'; +import { MOCK_NO_CONSTRAINTS, mockConstraintsToConstraints } from '../util/selectionConstraints'; +import { + MockChangeAddressResolver, + asAssetId, + asTokenMap, + assertInputSelectionProperties, + getCoinValueForAddress, + mockChangeAddress +} from '../util'; +import { TxTestUtil } from '@cardano-sdk/util-dev'; + +describe('LargeFirstSelection', () => { + it('picks the largest ADA UTxOs first', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 5_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 4_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }) + ]); + + const outputs = new Set([TxTestUtil.createOutput({ coins: 6_000_000n })]); + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }); + + const results = await selector.select({ + constraints, + implicitValue: {}, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + const { selection, remainingUTxO } = results; + + expect(selection.inputs.size).toBe(2); + expect(remainingUTxO.size).toBe(3); + + const inputValues = new Set([...selection.inputs.entries()].map(([[_, output]]) => output.value.coins)); + expect(inputValues.has(5_000_000n)).toBe(true); + expect(inputValues.has(4_000_000n)).toBe(true); + + const expectedFee = BigInt(selection.inputs.size) * 100n; + expect(selection.fee).toBe(expectedFee); + + expect(getCoinValueForAddress(mockChangeAddress, selection.change)).toBe(2_999_800n); + + assertInputSelectionProperties({ + constraints: { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }, + implicitValue: {}, + outputs, + results, + utxo + }); + }); + + it('picks the largest ADA UTxOs first as change', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 5_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 4_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }) + ]); + + const outputs = new Set([TxTestUtil.createOutput({ coins: 9_000_000n })]); + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 1n + }); + + const results = await selector.select({ + constraints, + implicitValue: {}, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + const { selection, remainingUTxO } = results; + + expect(selection.inputs.size).toBe(3); + expect(remainingUTxO.size).toBe(2); + + const inputValues = new Set([...selection.inputs.entries()].map(([[_, output]]) => output.value.coins)); + expect(inputValues.has(5_000_000n)).toBe(true); + expect(inputValues.has(4_000_000n)).toBe(true); + + const expectedFee = BigInt(selection.inputs.size); + expect(selection.fee).toBe(expectedFee); + + expect(getCoinValueForAddress(mockChangeAddress, selection.change)).toBe(2_999_997n); + + assertInputSelectionProperties({ + constraints: { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }, + implicitValue: {}, + outputs, + results, + utxo + }); + }); + + it('picks the largest native asset UTxOs first', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const assetX = asAssetId('X'); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[assetX, 20n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[assetX, 80n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[assetX, 50n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[assetX, 100n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }) + ]); + + const outputs = new Set([ + TxTestUtil.createOutput({ + assets: asTokenMap([[assetX, 130n]]), + coins: 2_000_000n + }) + ]); + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }); + + const results = await selector.select({ + constraints, + implicitValue: {}, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + const { selection, remainingUTxO } = results; + + expect(selection.inputs.size).toBe(2); + const coinsPicked = new Set([...selection.inputs].map(([, o]) => o.value.assets?.get(assetX) ?? 0n)); + expect(coinsPicked.has(100n)).toBe(true); + expect(coinsPicked.has(80n)).toBe(true); + expect(remainingUTxO.size).toBe(3); + + const expectedFee = BigInt(selection.inputs.size) * 100n; + expect(selection.fee).toBe(expectedFee); + + expect(selection.change.some((txOut) => txOut.value.assets?.get(assetX) === 50n)).toBe(true); + + assertInputSelectionProperties({ + constraints: { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }, + implicitValue: {}, + outputs, + results, + utxo + }); + }); + + it('consumes just enough ADA UTxOs needed and leaves the remainder', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }) + ]); + const outputs = new Set([TxTestUtil.createOutput({ coins: 5_000_000n })]); + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }); + + const results = await selector.select({ + constraints, + implicitValue: {}, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + const { selection, remainingUTxO } = results; + + expect(selection.inputs.size).toBe(2); + expect(remainingUTxO.size).toBe(3); + + const expectedFee = BigInt(selection.inputs.size) * 100n; + expect(selection.fee).toBe(expectedFee); + + expect(getCoinValueForAddress(mockChangeAddress, selection.change)).toBe(999_800n); + + assertInputSelectionProperties({ + constraints: { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }, + implicitValue: {}, + outputs, + results, + utxo + }); + }); + + it('picks the single largest UTxO for each required asset', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[asAssetId('0'), 100n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[asAssetId('0'), 50n]]), + coins: 3_000_000n + }), + TxTestUtil.createUnspentTxOutput({ + assets: asTokenMap([[asAssetId('1'), 8n]]), + coins: 4_000_000n + }), + TxTestUtil.createUnspentTxOutput({ coins: 4_000_000n }) + ]); + + // Need 90 of asset-0 and 5 of asset-1, plus 2000000 Ada + const outputs = new Set([ + TxTestUtil.createOutput({ + assets: asTokenMap([ + [asAssetId('0'), 90n], + [asAssetId('1'), 5n] + ]), + coins: 2_000_000n + }) + ]); + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }); + + const results = await selector.select({ + constraints, + implicitValue: {}, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + expect(results.selection.inputs.has([...utxo][0])).toBe(true); // 100-token-0 + expect(results.selection.inputs.has([...utxo][2])).toBe(true); // 8-token-1 + expect(results.remainingUTxO.has([...utxo][1])).toBe(true); + + assertInputSelectionProperties({ + constraints: { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }, + implicitValue: {}, + outputs, + results, + utxo + }); + }); + + it('accounts for implicit deposits, withdrawals, and mint', async () => { + const selector = new LargeFirstSelector({ + changeAddressResolver: new MockChangeAddressResolver() + }); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }) + ]); + + const outputs = new Set(); + + const implicitValue = { + coin: { deposit: 1_000_000n, input: 5_000_000n }, + mint: asTokenMap([[asAssetId('XYZ'), 2n]]) + }; + + const constraints = mockConstraintsToConstraints({ + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }); + + const { selection, remainingUTxO } = await selector.select({ + constraints, + implicitValue, + outputs, + preSelectedUtxo: new Set(), + utxo + }); + + const expectedFee = BigInt(selection.inputs.size) * 100n; + expect(selection.fee).toBe(expectedFee); + expect(getCoinValueForAddress(mockChangeAddress, selection.change)).toBe(6_999_900n); + expect(selection.change[0].value.assets?.get(asAssetId('XYZ'))).toBe(2n); + + expect(remainingUTxO.size).toBe(1); + }); +}); diff --git a/packages/input-selection/test/change.test.ts b/packages/input-selection/test/change.test.ts index b4f0d266fdb..734eb1812cf 100644 --- a/packages/input-selection/test/change.test.ts +++ b/packages/input-selection/test/change.test.ts @@ -1,5 +1,5 @@ import { Cardano } from '@cardano-sdk/core'; -import { coalesceChangeBundlesForMinCoinRequirement } from '../src/RoundRobinRandomImprove/change'; +import { coalesceChangeBundlesForMinCoinRequirement } from '../src/change'; const TOKEN1_ASSET_ID = Cardano.AssetId('5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84249530000'); const TOKEN2_ASSET_ID = Cardano.AssetId('5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84249530001'); diff --git a/packages/input-selection/test/util/index.ts b/packages/input-selection/test/util/index.ts index 6bd24d1a6a7..7735e557c6e 100644 --- a/packages/input-selection/test/util/index.ts +++ b/packages/input-selection/test/util/index.ts @@ -9,12 +9,14 @@ export const asAssetId = (x: string): Cardano.AssetId => x as unknown as Cardano export const asPaymentAddress = (x: string): Cardano.PaymentAddress => x as unknown as Cardano.PaymentAddress; export const asTokenMap = (elements: Iterable<[Cardano.AssetId, bigint]>) => new Map(elements); +export const mockChangeAddress = + 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress; + export class MockChangeAddressResolver implements ChangeAddressResolver { async resolve(selection: Selection) { return selection.change.map((txOut) => ({ ...txOut, - address: - 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress + address: mockChangeAddress })); } }