diff --git a/packages/input-selection/package.json b/packages/input-selection/package.json index 4075c6ecbf1..c2860db7bce 100644 --- a/packages/input-selection/package.json +++ b/packages/input-selection/package.json @@ -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" }, diff --git a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts index 5f9ff17fda7..39c16357694 100644 --- a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts +++ b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts @@ -1,17 +1,19 @@ +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 { + getAssetAddition, getAssetDifference, getAssetQuantities, getCoinQuantity, hasNegativeAssetValue, - isValidValue, sortByCoins, + splitChange, toValues } from '../util'; -type AdjustedOutputs = { outputs: Set; fee: bigint; change: Set }; +type AdjustedOutputs = { fee: bigint; change: Array }; interface GreedySelectorProps { getChangeAddresses: () => Promise>; @@ -25,38 +27,32 @@ interface GreedySelectorProps { * @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. - * @param change Change value if any. */ const adjustOutputsForFee = async ( constraints: SelectionConstraints, inputs: Set, outputs: Set, - currentFee: bigint, - change?: Cardano.Value + changeOutputs: Array, + currentFee: bigint ): Promise => { - let result = { change: new Set(), fee: currentFee, outputs }; - // const changeBundles = new Set(); - // const changeBundlesByAddress = new Map>(); - - // if (change) changeBundles.add(change); - - // Compute the fee. If the current fee is less than the newly computed fee, deduct the difference from the 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. - // Calculate fee with change outputs that include fee. - // It will cover the fee of final selection, - // where fee is excluded from change bundles + 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 + outputs: totalOutputs }); if (currentFee < fee) { const feeDifference = fee - currentFee; - const updatedOutputs = [...outputs]; + const updatedOutputs = [...changeOutputs]; updatedOutputs.sort(sortByCoins); @@ -73,7 +69,7 @@ const adjustOutputsForFee = async ( if (!feeAccountedFor) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); - result = await adjustOutputsForFee(constraints, inputs, new Set(updatedOutputs), fee, change); + result = await adjustOutputsForFee(constraints, inputs, outputs, updatedOutputs, fee); } return result; @@ -93,63 +89,53 @@ const adjustOutputsForFee = async ( * the user has too many native assets. */ export class GreedyInputSelector implements InputSelector { - getChangeAddresses: GreedySelectorProps; + #props: GreedySelectorProps; constructor(props: GreedySelectorProps) { - this.getChangeAddresses = props; + this.#props = props; } async select(params: InputSelectionParameters): Promise { - const { utxo: inputs, outputs, constraints } = params; + 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(utxoValues); - // const changeBundlesByAddress = new Map>(); - - if (totalLovelaceInUtxoSet === totalLovelaceInOutputSet && totalAssetsInUtxoSet === totalAssetsInOutputSet) { - // The wallet is trying to spend the whole balance, we just need to account for the fee and return the modified selection. - const adjustedOutputs = await adjustOutputsForFee(constraints, inputs, outputs, 0n); - - return { - remainingUTxO: new Set(), - selection: { - change: [], - fee: adjustedOutputs.fee, - inputs, - outputs: adjustedOutputs.outputs - } - }; - } - - // The wallet did not spend all the balance, so we must add the remaining as change. - const changeLovelace = totalLovelaceInOutputSet - totalLovelaceInUtxoSet; - const changeAssets = getAssetDifference(totalAssetsInOutputSet, totalAssetsInUtxoSet); - - // If the wallet tries to spend more than we can cover with the current UTXO set throw. + const implicitCoinInput = implicitValue?.coin?.input || 0n; + const implicitCoinOutput = implicitValue?.coin?.deposit || 0n; + const implicitAssetInput = implicitValue?.mint || new Map(); + const totalLovelaceInput = totalLovelaceInUtxoSet + implicitCoinInput; + const totalLovelaceOutput = totalLovelaceInOutputSet + implicitCoinOutput; + const totalAssetsInput = getAssetAddition(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 = totalLovelaceOutput - totalLovelaceInput; + const changeAssets = getAssetDifference(totalAssetsInOutputSet, totalAssetsInput); + + // 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); - const change = { - assets: changeAssets.size === 0 ? undefined : changeAssets, - coins: changeLovelace - }; - - // If not enough balance remaining to create a valid change output throw. - if (!isValidValue(change, constraints.computeMinimumCoinQuantity, constraints.tokenBundleSizeExceedsLimit)) - throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); - - const adjustedOutputs = await adjustOutputsForFee(constraints, inputs, outputs, 0n, change); + // 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 + ); + const adjustedChangeOutputs = await adjustOutputsForFee(constraints, inputs, outputs, changeOutputs, 0n); return { remainingUTxO: new Set(), selection: { - change: [], - fee: adjustedOutputs.fee, + change: adjustedChangeOutputs.change, + fee: adjustedChangeOutputs.fee, inputs, - outputs: adjustedOutputs.outputs + outputs } }; } diff --git a/packages/input-selection/src/util.ts b/packages/input-selection/src/util.ts index 7fd2f4207ab..20f7c23d458 100644 --- a/packages/input-selection/src/util.ts +++ b/packages/input-selection/src/util.ts @@ -1,5 +1,6 @@ /* eslint-disable func-style */ import { BigIntMath } from '@cardano-sdk/util'; +import { BigNumber } from 'bignumber.js'; import { Cardano } from '@cardano-sdk/core'; import { ComputeMinimumCoinQuantity, ImplicitValue, TokenBundleSizeExceedsLimit } from './types'; import { InputSelectionError, InputSelectionFailure } from './InputSelectionError'; @@ -192,7 +193,22 @@ export const sortByCoins = (lhs: Cardano.TxOut, rhs: Cardano.TxOut) => { * @param rhs the right-hand side of the subtraction operation. * @returns The difference between both TokenMaps. */ -export const getAssetDifference = (lhs: Cardano.TokenMap, rhs: Cardano.TokenMap): Cardano.TokenMap => { +export const getAssetDifference = ( + lhs: Cardano.TokenMap | undefined, + rhs: Cardano.TokenMap | undefined +): Cardano.TokenMap | undefined => { + if (!rhs) return lhs; + + if (!lhs) { + const negativeValues = new Map(); + + for (const [key, value] of rhs.entries()) { + negativeValues.set(key, -value); + } + + return negativeValues; + } + const result = new Map(); const intersection = new Array(); @@ -230,13 +246,62 @@ export const getAssetDifference = (lhs: Cardano.TokenMap, rhs: Cardano.TokenMap) return result; }; +/** + * Given two TokenMaps, compute a TokenMap with the addition between the left-hand side and the right-hand side. + * + * @param lhs the left-hand side of the addition operation. + * @param rhs the right-hand side of the addition operation. + * @returns The addition between both TokenMaps. + */ +export const getAssetAddition = ( + lhs: Cardano.TokenMap | undefined, + rhs: Cardano.TokenMap | undefined +): Cardano.TokenMap | undefined => { + if (!lhs) return rhs; + if (!rhs) return lhs; + + const result = new Map(); + const intersection = new Array(); + + for (const [key, value] of lhs.entries()) { + if (rhs.has(key)) { + intersection.push(key); + continue; + } + + result.set(key, value); + } + + for (const [key, value] of rhs.entries()) { + if (lhs.has(key)) { + intersection.push(key); + continue; + } + + result.set(key, value); + } + + // Elements present in both maps will be added together (lhs + rhs) + const uniqIntersection = uniq(intersection); + + for (const id of uniqIntersection) { + const lshVal = lhs.get(id); + const rshVal = rhs.get(id); + result.set(id, lshVal! + rshVal!); + } + + return result; +}; + /** * Gets whether the given token map contains any assets which quantity is less than 0. * * @param assets The assets to be checked for negative values. * @returns true if any of the assets has a negative value; otherwise, false. */ -export const hasNegativeAssetValue = (assets: Cardano.TokenMap): boolean => { +export const hasNegativeAssetValue = (assets: Cardano.TokenMap | undefined): boolean => { + if (!assets) return false; + const values = [...assets.values()]; return values.some((quantity) => quantity < 0n); }; @@ -247,16 +312,139 @@ export const hasNegativeAssetValue = (assets: Cardano.TokenMap): boolean => { * @param value The value to be tested. * @param computeMinimumCoinQuantity callback that computes the minimum coin quantity for the given UTXO. * @param tokenBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit. + * @param feeToDiscount The fee that could be discounted later on from this output (defaults to 0). * @returns true if the value is valid; otherwise, false. */ export const isValidValue = ( value: Cardano.Value, computeMinimumCoinQuantity: ComputeMinimumCoinQuantity, - tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit + tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit, + feeToDiscount = 0n ): boolean => { - let isValid = value.coins >= computeMinimumCoinQuantity({ address: stubMaxSizeAddress, value }); + let isValid = value.coins - feeToDiscount >= computeMinimumCoinQuantity({ address: stubMaxSizeAddress, value }); if (value.assets) isValid = isValid && !tokenBundleSizeExceedsLimit(value.assets); return isValid; }; + +/** + * Distribute the assets among the given outputs. The function will try to allocate all the assets in + * the output with the biggest coin balance, if this fails, it will spill over the assets to the second output (and so on) + * until it can distribute all assets among the outputs. If no such distribution can be found, the algorithm with fail. + * + * remark: At this point we are not ready to compute the fee, which would need to be subtracted from one of this change + * outputs, so we are going to assume a high fee for the time being (2000000 lovelace). This will guarantee that the + * outputs will remain valid even after the fee has been subtracted from the change output. + * + * @param outputs The outputs where to distribute the assets into. + * @param computeMinimumCoinQuantity callback that computes the minimum coin quantity for the given UTXO. + * @param tokenBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit. + * @returns a new change output array with the given assets allocated. + */ +const distributeAssets = ( + outputs: Array, + computeMinimumCoinQuantity: ComputeMinimumCoinQuantity, + tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit +): Array => { + const adjustedOutputs = [...outputs]; + + for (let i = 0; i < adjustedOutputs.length; ++i) { + const output = adjustedOutputs[i]; + if (!isValidValue(output.value, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, 2_000_000n)) { + if (i === adjustedOutputs.length - 1) { + throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + } + + if (!output.value.assets) { + // If this output failed and doesn't contain any assets, it means there is not enough coins to cover + // the min ADA coin pero UTXO. We will throw for now, but it may be possible to fix this by moving some excess + // lovelace from one of the other outputs (ignoring the given distribution). + throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); + } + + const splicedAsset = new Map([...output.value.assets!.entries()].splice(0, 1)); + const currentOutputNewAssets = getAssetDifference(output.value.assets, splicedAsset); + const nextOutputNewAssets = getAssetAddition(adjustedOutputs[i + 1].value.assets, splicedAsset); + + output.value.assets = currentOutputNewAssets; + adjustedOutputs[i + 1].value.assets = nextOutputNewAssets; + + return distributeAssets(adjustedOutputs, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit); + } + } + + return adjustedOutputs; +}; + +/** + * Splits the change proportionally between the given addresses. This algorithm makes + * the best effort to be as accurate as possible in distributing the amounts, however, due to rounding + * there may be a small error in the final distribution, I.E 8 lovelace divided in three equal parts will + * yield 3, 3, 2 lovelace with an error of 33% in the last change output as lovelaces can't be further subdivided (The + * error should be marginal for large amounts of lovelace). + * + * While lovelaces will be split according to the given distribution, native assets will use a different heuristic. We + * will try to add all native assets to the UTXO wit the most coins in the change outputs, if they don't 'fit', we will spill over to + * 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 + * when the actual fee is computed the largest change output can afford to discount it without becoming invalid). This is a rather + * naive approach, but should work as long as the wallet is not at its maximum capacity for holding native assets due to minCoinAda + * restrictions on the UTXOs. + * + * @param getChangeAddresses A callback that returns a list of addresses and their proportions. + * @param totalChangeLovelace The total amount of lovelace in the change. + * @param totalChangeAssets The total assets to be distributed as change. + * @param computeMinimumCoinQuantity callback that computes the minimum coin quantity for the given UTXO. + * @param tokenBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit. + */ +export const splitChange = async ( + getChangeAddresses: () => Promise>, + totalChangeLovelace: bigint, + totalChangeAssets: Cardano.TokenMap | undefined, + computeMinimumCoinQuantity: ComputeMinimumCoinQuantity, + tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit +): Promise> => { + const changeAddresses = await getChangeAddresses(); + const changeOutputs: Array = [...changeAddresses.entries()].map((val) => ({ + address: val[0], + value: { coins: 0n } + })); + + let runningTotal = 0n; + const totalCoinAllocation = new BigNumber(totalChangeLovelace.toString()); + for (const txOut of changeOutputs) { + const factor = new BigNumber(changeAddresses.get(txOut.address)!); + const coinAllocation = BigInt(totalCoinAllocation.multipliedBy(factor).toFixed(0, 0)); // Round up and no decimals + + runningTotal += coinAllocation; + + // If we over shoot the available coin change, subtract the extra from the last output. This could be either + // rounding errors, or imprecise definition of the proportions. + txOut.value.coins = + runningTotal > totalChangeLovelace ? coinAllocation - (runningTotal - totalChangeLovelace) : coinAllocation; + } + + const totalAllocated = changeOutputs.reduce( + (prev, current) => { + current.value.coins += prev.value.coins; + return current; + }, + { + address: '', + value: { coins: 0n } + } + ).value.coins; + + if (totalAllocated < totalChangeLovelace) { + // This may be because the given proportions don't add up to 100%. We will add the missing balance to the last output. + const missingAllocation = totalChangeLovelace - totalAllocated; + changeOutputs[changeOutputs.length - 1].value.coins += missingAllocation; + } + + const sortedOutputs = changeOutputs.sort(sortByCoins); + sortedOutputs[0].value.assets = totalChangeAssets; // Add all assets to the 'biggest' output. + + if (!totalChangeAssets) return sortedOutputs; + + return distributeAssets(sortedOutputs, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit); +}; diff --git a/yarn-project.nix b/yarn-project.nix index d096adf8cc9..c4c17ef52d4 100644 --- a/yarn-project.nix +++ b/yarn-project.nix @@ -847,6 +847,7 @@ cacheEntries = { "big-integer@npm:1.6.51" = { filename = "big-integer-npm-1.6.51-1a244d8e1f-3d444173d1.zip"; sha512 = "3d444173d1b2e20747e2c175568bedeebd8315b0637ea95d75fd27830d3b8e8ba36c6af40374f36bdaea7b5de376dcada1b07587cb2a79a928fccdb6e6e3c518"; }; "big.js@npm:5.2.2" = { filename = "big.js-npm-5.2.2-e147c30820-b89b6e8419.zip"; sha512 = "b89b6e8419b097a8fb4ed2399a1931a68c612bce3cfd5ca8c214b2d017531191070f990598de2fc6f3f993d91c0f08aa82697717f6b3b8732c9731866d233c9e"; }; "bignumber.js@npm:9.1.0" = { filename = "bignumber.js-npm-9.1.0-4f54bd1083-52ec2bb5a3.zip"; sha512 = "52ec2bb5a3874d7dc1a1018f28f8f7aff4683515ffd09d6c2d93191343c76567ae0ee32cc45149d53afb2b904bc62ed471a307b35764beea7e9db78e56bef6c6"; }; +"bignumber.js@npm:9.1.1" = { filename = "bignumber.js-npm-9.1.1-5929e8d8dc-ad243b7e2f.zip"; sha512 = "ad243b7e2f9120b112d670bb3d674128f0bd2ca1745b0a6c9df0433bd2c0252c43e6315d944c2ac07b4c639e7496b425e46842773cf89c6a2dcd4f31e5c4b11e"; }; "bin-links@npm:3.0.3" = { filename = "bin-links-npm-3.0.3-6f4ee98953-ea2dc6f91a.zip"; sha512 = "ea2dc6f91a6ef8b3840ceb48530bbeb8d6d1c6f7985fe1409b16d7e7db39432f0cb5ce15cc2788bb86d989abad6e2c7fba3500996a210a682eec18fb26a66e72"; }; "bin-links@npm:4.0.1" = { filename = "bin-links-npm-4.0.1-08882d205f-a806561750.zip"; sha512 = "a806561750039bcd7d4234efe5c0b8b7ba0ea8495086740b0da6395abe311e2cdb75f8324787354193f652d2ac5ab038c4ca926ed7bcc6ce9bc2001607741104"; }; "binary-extensions@npm:2.2.0" = { filename = "binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip"; sha512 = "ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8"; }; diff --git a/yarn.lock b/yarn.lock index c5cb9f9c435..f63c878f304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2707,6 +2707,7 @@ __metadata: "@cardano-sdk/util": "workspace:~" "@cardano-sdk/util-dev": "workspace:~" "@types/lodash": ^4.14.182 + bignumber.js: ^9.1.1 eslint: ^7.32.0 fast-check: ^2.17.0 jest: ^28.1.3 @@ -8313,6 +8314,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.1.1": + version: 9.1.1 + resolution: "bignumber.js@npm:9.1.1" + checksum: ad243b7e2f9120b112d670bb3d674128f0bd2ca1745b0a6c9df0433bd2c0252c43e6315d944c2ac07b4c639e7496b425e46842773cf89c6a2dcd4f31e5c4b11e + languageName: node + linkType: hard + "bin-links@npm:^3.0.0": version: 3.0.3 resolution: "bin-links@npm:3.0.3"