From 7183cfeb51df92665c4e281c6fdfb4206ff11fee Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 19 Sep 2024 11:30:02 -0600 Subject: [PATCH] feat: opt-in consolidate outputs --- src/vms/pvm/etna-builder/builder.ts | 35 +- .../pvm/etna-builder/experimental-spend.ts | 534 ------------- src/vms/pvm/etna-builder/original-spend.ts | 439 +++++++++++ src/vms/pvm/etna-builder/spend.ts | 716 ++++++++++-------- src/vms/pvm/etna-builder/spendHelper.test.ts | 7 +- src/vms/pvm/etna-builder/spendHelper.ts | 16 +- 6 files changed, 890 insertions(+), 857 deletions(-) delete mode 100644 src/vms/pvm/etna-builder/experimental-spend.ts create mode 100644 src/vms/pvm/etna-builder/original-spend.ts diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 358a81b3b..c9c4e13ef 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -65,7 +65,7 @@ import { getOwnerComplexity, getSignerComplexity, } from '../txs/fee'; -import { spend } from './experimental-spend'; +import { spend, useSpendableLockedUTXOs, useUnlockedUTXOs } from './spend'; const getAddressMaps = ({ inputs, @@ -158,13 +158,15 @@ export const newBaseTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses, + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toBurn, utxos, }, + [useUnlockedUTXOs], context, ); @@ -319,13 +321,14 @@ export const newImportTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: importedAvax, fromAddresses, + initialComplexity: complexity, ownerOverride: OutputOwners.fromNative(toAddresses, locktime, threshold), spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -396,13 +399,14 @@ export const newExportTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses, + initialComplexity: complexity, spendOptions: defaultedOptions, toBurn, utxos, }, + [useUnlockedUTXOs], context, ); @@ -474,12 +478,13 @@ export const newCreateSubnetTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -585,12 +590,13 @@ export const newCreateChainTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -678,12 +684,13 @@ export const newAddSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -760,12 +767,13 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -927,13 +935,15 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toStake, utxos, }, + [useSpendableLockedUTXOs, useUnlockedUTXOs], context, ); @@ -1075,13 +1085,15 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toStake, utxos, }, + [useSpendableLockedUTXOs, useUnlockedUTXOs], context, ); @@ -1188,12 +1200,13 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); diff --git a/src/vms/pvm/etna-builder/experimental-spend.ts b/src/vms/pvm/etna-builder/experimental-spend.ts deleted file mode 100644 index a17fb900c..000000000 --- a/src/vms/pvm/etna-builder/experimental-spend.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { Address } from '../../../serializable'; -import { - BigIntPr, - OutputOwners, - TransferInput, - TransferableInput, - TransferableOutput, - TransferOutput, - Id, -} from '../../../serializable'; -import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; -import type { SpendOptions } from '../../common'; -import type { Dimensions } from '../../common/fees/dimensions'; -import type { Context } from '../../context'; -import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; -import { SpendHelper } from './spendHelper'; - -type SpendResult = Readonly<{ - /** - * The consolidated and sorted change outputs. - */ - changeOutputs: readonly TransferableOutput[]; - /** - * The total calculated fee for the transaction. - */ - fee: bigint; - /** - * The sorted inputs. - */ - inputs: readonly TransferableInput[]; - /** - * The UTXOs that were used as inputs. - */ - inputUTXOs: readonly Utxo[]; - /** - * The consolidated and sorted staked outputs. - */ - stakeOutputs: readonly TransferableOutput[]; -}>; - -export type SpendProps = Readonly<{ - /** - * The initial complexity of the transaction. - */ - complexity: Dimensions; - /** - * The extra AVAX that spend can produce in - * the change outputs in addition to the consumed and not burned AVAX. - */ - excessAVAX?: bigint; - /** - * List of Addresses that are used for selecting which UTXOs are signable. - */ - fromAddresses: readonly Address[]; - /** - * Optionally specifies the output owners to use for the unlocked - * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined` or `null`, the default change owner is used. - * - * Used in ImportTx. - */ - ownerOverride?: OutputOwners | null; - spendOptions: Required; - /** - * Maps `assetID` to the amount of the asset to spend without - * producing an output. This is typically used for fees. - * However, it can also be used to consume some of an asset that - * will be produced in separate outputs, such as ExportedOutputs. - * - * Only unlocked UTXOs are able to be burned here. - */ - toBurn?: Map; - /** - * Maps `assetID` to the amount of the asset to spend and place info - * the staked outputs. First locked UTXOs are attempted to be used for - * these funds, and then unlocked UTXOs will be attempted to be used. - * There is no preferential ordering on the unlock times. - */ - toStake?: Map; - /** - * List of UTXOs that are available to be spent. - */ - utxos: readonly Utxo[]; -}>; - -type SpendReducerState = Readonly>; - -type SpendReducerFunction = ( - state: SpendReducerState, - spendHelper: SpendHelper, - context: Context, -) => SpendReducerState; - -const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { - const verifyError = spendHelper.verifyAssetsConsumed(); - - if (verifyError) { - throw verifyError; - } - - return state; -}; - -export const IncorrectStakeableLockOutError = new Error( - 'StakeableLockOut transferOut must be a TransferOutput.', -); - -export const useSpendableLockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter((utxo): utxo is Utxo> => { - // 1a. Ensure UTXO output is a StakeableLockOut. - if (!isStakeableLockOut(utxo.output)) { - return false; - } - - // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { - return false; - } - - // 1c. Ensure there are funds to stake. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { - return false; - } - - // 1d. Ensure transferOut is a TransferOutput. - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return true; - }); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Do all the logic for spending based on the UTXOs. - for (const { sigData, data: utxo } of verifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. - if (remainingAmountToStake === 0n) { - continue; - } - - // 3b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - // StakeableLockOut - new BigIntPr(utxoInfo.stakeableLocktime), - TransferInput.fromNative( - // TransferOutput - utxoInfo.amount, - sigData.sigIndicies, - ), - ), - ), - ); - - // 3c. Consume the locked asset and get the remaining amount. - const remainingAmount = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 3d. Add the stake output. - spendHelper.addStakedOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - - // 3e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - } - } - - // 4. Add all remaining stake amounts assuming they are unlocked. - for (const [assetId, amount] of state.toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - state.spendOptions.changeAddresses, - ), - ); - } - - return state; -}; - -export const useUnlockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = - state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter( - ( - utxo, - ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; - } - - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return ( - utxo.output.getLocktime() < state.spendOptions.minIssuanceTime - ); - } - - return false; - }, - ); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => - isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. - const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = - verifiedUsableUTXOs.reduce( - (result, { sigData, data: utxo }) => { - if (utxo.assetId.value() === context.avaxAssetID) { - return [result[0], [...result[1], { sigData, data: utxo }]]; - } - - return [[...result[0], { sigData, data: utxo }], result[1]]; - }, - [[], []] as [ - other: typeof verifiedUsableUTXOs, - avax: typeof verifiedUsableUTXOs, - ], - ); - - // 4. Handle all the non-AVAX asset UTXOs first. - for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToBurn: bigint = - state.toBurn.get(utxoInfo.assetId) ?? 0n; - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. - if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { - continue; - } - - // 4b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - // 4c. Consume the asset and get the remaining amount. - const remainingAmount = spendHelper.consumeAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 4d. If "amountToStake" is greater than 0, add the stake output. - // TODO: Implement or determine if needed. - - // 4e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(remainingAmount), - OutputOwners.fromNative( - state.spendOptions.changeAddresses, - 0n, - 1, - ), - ), - ), - ), - ); - } - } - - // 5. Handle AVAX asset UTXOs last to account for fees. - let excessAVAX = state.excessAVAX; - let clearOwnerOverride = false; - for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - const remainingAmount = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); - - excessAVAX += remainingAmount; - - // The ownerOverride is no longer needed. Clear it. - clearOwnerOverride = true; - } - - return { - ...state, - excessAVAX, - ownerOverride: clearOwnerOverride ? null : state.ownerOverride, - }; -}; - -export const handleFee: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - const requiredFee = spendHelper.calculateFee(); - - if (state.excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - state.excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, - ); - } - - // No need to add a change output. - if (state.excessAVAX === requiredFee) { - return state; - } - - // Use the change owner override if it exists, otherwise use the default change owner. - // This is used for import transactions. - const changeOwners = - state.ownerOverride || - OutputOwners.fromNative(state.spendOptions.changeAddresses); - - // TODO: Clean-up if this is no longer needed. - // Additionally, no need for public .addOutputComplexity(). - // - // Pre-consolidation code. - // - // spendHelper.addOutputComplexity( - // new TransferableOutput( - // Id.fromString(context.avaxAssetID), - // new TransferOutput(new BigIntPr(0n), changeOwners), - // ), - // ); - // - // Recalculate the fee with the change output. - // const requiredFeeWithChange = spendHelper.calculateFee(); - - // Calculate the fee with a temporary output complexity if a change output is needed. - const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( - context.avaxAssetID, - changeOwners, - ) - ? requiredFee - : spendHelper.calculateFeeWithTemporaryOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); - - // Add a change output if needed. - if (state.excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(state.excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } - - return state; -}; - -/** - * Processes the spending of assets, including burning and staking, from a list of UTXOs. - * - * @param {SpendProps} props - The properties required to execute the spend operation. - * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. - * @param {Context} context - The context in which the spend operation is executed. - * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, - * and the second element is either the result of the spend operation or null. - */ -export const spend = ( - { - complexity, - excessAVAX: _excessAVAX = 0n, - fromAddresses, - ownerOverride, - spendOptions, - toBurn = new Map(), - toStake = new Map(), - utxos, - }: SpendProps, - // spendReducers: readonly SpendReducerFunction[], - context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - try { - const changeOwners = - ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - const excessAVAX: bigint = _excessAVAX; - - const spendHelper = new SpendHelper({ - changeOutputs: [], - complexity, - gasPrice: context.gasPrice, - inputs: [], - stakeOutputs: [], - toBurn, - toStake, - weights: context.complexityWeights, - }); - - const initialState: SpendReducerState = { - complexity, - excessAVAX, - fromAddresses, - ownerOverride: changeOwners, - spendOptions, - toBurn, - toStake, - utxos, - }; - - const spendReducerFunctions: readonly SpendReducerFunction[] = [ - // ...spendReducers, - useSpendableLockedUTXOs, - useUnlockedUTXOs, - verifyAssetsConsumed, - handleFee, - // Consolidation and sorting happens in the SpendHelper. - ]; - - // Run all the spend calculation reducer logic. - spendReducerFunctions.reduce((state, next) => { - return next(state, spendHelper, context); - }, initialState); - - return [null, spendHelper.getInputsOutputs()]; - } catch (error) { - return [ - error instanceof Error - ? error - : new Error('An unexpected error occurred during spend calculation'), - null, - ]; - } -}; diff --git a/src/vms/pvm/etna-builder/original-spend.ts b/src/vms/pvm/etna-builder/original-spend.ts new file mode 100644 index 000000000..8d10d0881 --- /dev/null +++ b/src/vms/pvm/etna-builder/original-spend.ts @@ -0,0 +1,439 @@ +// TODO: Delete this file once we are done referencing it. + +import type { Address } from '../../../serializable'; +import { + BigIntPr, + Id, + Input, + OutputOwners, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; +import { getUtxoInfo } from '../../../utils'; +import { matchOwners } from '../../../utils/matchOwners'; +import type { SpendOptions } from '../../common'; +import type { Dimensions } from '../../common/fees/dimensions'; +import type { Context } from '../../context'; +import { SpendHelper } from './spendHelper'; + +/** + * @internal + * + * Separates the provided UTXOs into two lists: + * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. + * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. + * + * @param utxos {readonly Utxo[]} + * @param minIssuanceTime {bigint} + * + * @returns Object containing two lists of UTXOs. + */ +export const splitByLocktime = ( + utxos: readonly Utxo[], + minIssuanceTime: bigint, +): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { + const locked: Utxo[] = []; + const unlocked: Utxo[] = []; + + for (const utxo of utxos) { + let utxoOwnersLocktime: bigint; + + // TODO: Remove this try catch in the future in favor of + // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs + try { + utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; + } catch (error) { + // If we can't get the locktime, we can't spend the UTXO. + // TODO: Is this the right thing to do? + // This was necessary to get tests working with testUtxos(). + continue; + } + + if (minIssuanceTime < utxoOwnersLocktime) { + locked.push(utxo); + } else { + unlocked.push(utxo); + } + } + + return { locked, unlocked }; +}; + +/** + * @internal + * + * Separates the provided UTXOs into two lists: + * - `other` contains UTXOs that have an asset ID different from `assetId`. + * - `requested` contains UTXOs that have an asset ID equal to `assetId`. + * + * @param utxos {readonly Utxo[]} + * @param assetId {string} + * + * @returns Object containing two lists of UTXOs. + */ +export const splitByAssetId = ( + utxos: readonly Utxo[], + assetId: string, +): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { + const other: Utxo[] = []; + const requested: Utxo[] = []; + + for (const utxo of utxos) { + if (assetId === utxo.assetId.toString()) { + requested.push(utxo); + } else { + other.push(utxo); + } + } + + return { other, requested }; +}; + +type SpendResult = Readonly<{ + changeOutputs: readonly TransferableOutput[]; + inputs: readonly TransferableInput[]; + inputUTXOs: readonly Utxo[]; + stakeOutputs: readonly TransferableOutput[]; +}>; + +export type SpendProps = Readonly<{ + /** + * The initial complexity of the transaction. + */ + complexity: Dimensions; + /** + * The extra AVAX that spend can produce in + * the change outputs in addition to the consumed and not burned AVAX. + */ + excessAVAX?: bigint; + /** + * List of Addresses that are used for selecting which UTXOs are signable. + */ + fromAddresses: readonly Address[]; + /** + * Optionally specifies the output owners to use for the unlocked + * AVAX change output if no additional AVAX was needed to be burned. + * If this value is `undefined`, the default change owner is used. + */ + ownerOverride?: OutputOwners; + spendOptions: Required; + /** + * Maps `assetID` to the amount of the asset to spend without + * producing an output. This is typically used for fees. + * However, it can also be used to consume some of an asset that + * will be produced in separate outputs, such as ExportedOutputs. + * + * Only unlocked UTXOs are able to be burned here. + */ + toBurn?: Map; + /** + * Maps `assetID` to the amount of the asset to spend and place info + * the staked outputs. First locked UTXOs are attempted to be used for + * these funds, and then unlocked UTXOs will be attempted to be used. + * There is no preferential ordering on the unlock times. + */ + toStake?: Map; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + complexity, + excessAVAX: _excessAVAX = 0n, + fromAddresses, + ownerOverride, + spendOptions, + toBurn = new Map(), + toStake = new Map(), + utxos, + }: SpendProps, + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + let changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + let excessAVAX: bigint = _excessAVAX; + + const spendHelper = new SpendHelper({ + changeOutputs: [], + initialComplexity: complexity, + gasPrice: context.gasPrice, + inputs: [], + shouldConsolidateOutputs: false, + stakeOutputs: [], + toBurn, + toStake, + weights: context.complexityWeights, + }); + + const utxosByLocktime = splitByLocktime( + utxos, + spendOptions.minIssuanceTime, + ); + + for (const utxo of utxosByLocktime.locked) { + if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { + continue; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ), + ); + + const excess = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + spendHelper.addStakedOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - excess), + utxo.getOutputOwners(), + ), + ), + ), + ); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), + ), + ), + ); + } + + // Add all remaining stake amounts assuming unlocked UTXOs + for (const [assetId, amount] of toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + spendOptions.changeAddresses, + ), + ); + } + + // AVAX is handled last to account for fees. + const utxosByAVAXAssetId = splitByAssetId( + utxosByLocktime.unlocked, + context.avaxAssetID, + ); + + for (const utxo of utxosByAVAXAssetId.other) { + const assetId = utxo.assetId.toString(); + + if (!spendHelper.shouldConsumeAsset(assetId)) { + continue; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ); + + const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(excess), + OutputOwners.fromNative(spendOptions.changeAddresses), + ), + ), + ); + } + + for (const utxo of utxosByAVAXAssetId.requested) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ); + + const excess = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); + + excessAVAX += excess; + + // If we need to consume additional AVAX, we should be returning the + // change to the change address. + changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); + } + + // Verify + const verifyError = spendHelper.verifyAssetsConsumed(); + if (verifyError) { + return [verifyError, null]; + } + + const requiredFee = spendHelper.calculateFee(); + + if (excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } + + // NOTE: This logic differs a bit from avalanche go because our classes are immutable. + spendHelper.addOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); + + const requiredFeeWithChange = spendHelper.calculateFee(); + + if (excessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(excessAVAX - requiredFeeWithChange), + changeOwners, + ), + ), + ); + } + + // Sorting happens in the .getInputsOutputs() method. + return [null, spendHelper.getInputsOutputs()]; + } catch (error) { + return [ + new Error('An unexpected error occurred during spend calculation', { + cause: error instanceof Error ? error : undefined, + }), + null, + ]; + } +}; diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index c7fa534dc..4274302fe 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -1,108 +1,46 @@ import type { Address } from '../../../serializable'; import { BigIntPr, - Id, - Input, OutputOwners, TransferInput, - TransferOutput, TransferableInput, TransferableOutput, + TransferOutput, + Id, } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo } from '../../../utils'; -import { matchOwners } from '../../../utils/matchOwners'; +import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; import type { SpendOptions } from '../../common'; import type { Dimensions } from '../../common/fees/dimensions'; import type { Context } from '../../context'; +import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; import { SpendHelper } from './spendHelper'; -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. - * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. - * - * @param utxos {readonly Utxo[]} - * @param minIssuanceTime {bigint} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByLocktime = ( - utxos: readonly Utxo[], - minIssuanceTime: bigint, -): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { - const locked: Utxo[] = []; - const unlocked: Utxo[] = []; - - for (const utxo of utxos) { - let utxoOwnersLocktime: bigint; - - // TODO: Remove this try catch in the future in favor of - // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs - try { - utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; - } catch (error) { - // If we can't get the locktime, we can't spend the UTXO. - // TODO: Is this the right thing to do? - // This was necessary to get tests working with testUtxos(). - continue; - } - - if (minIssuanceTime < utxoOwnersLocktime) { - locked.push(utxo); - } else { - unlocked.push(utxo); - } - } - - return { locked, unlocked }; -}; - -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `other` contains UTXOs that have an asset ID different from `assetId`. - * - `requested` contains UTXOs that have an asset ID equal to `assetId`. - * - * @param utxos {readonly Utxo[]} - * @param assetId {string} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByAssetId = ( - utxos: readonly Utxo[], - assetId: string, -): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { - const other: Utxo[] = []; - const requested: Utxo[] = []; - - for (const utxo of utxos) { - if (assetId === utxo.assetId.toString()) { - requested.push(utxo); - } else { - other.push(utxo); - } - } - - return { other, requested }; -}; - type SpendResult = Readonly<{ + /** + * The consolidated and sorted change outputs. + */ changeOutputs: readonly TransferableOutput[]; + /** + * The total calculated fee for the transaction. + */ + fee: bigint; + /** + * The sorted inputs. + */ inputs: readonly TransferableInput[]; + /** + * The UTXOs that were used as inputs. + */ inputUTXOs: readonly Utxo[]; + /** + * The consolidated and sorted staked outputs. + */ stakeOutputs: readonly TransferableOutput[]; }>; export type SpendProps = Readonly<{ - /** - * The initial complexity of the transaction. - */ - complexity: Dimensions; /** * The extra AVAX that spend can produce in * the change outputs in addition to the consumed and not burned AVAX. @@ -112,12 +50,24 @@ export type SpendProps = Readonly<{ * List of Addresses that are used for selecting which UTXOs are signable. */ fromAddresses: readonly Address[]; + /** + * The initial complexity of the transaction. + */ + initialComplexity: Dimensions; /** * Optionally specifies the output owners to use for the unlocked * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined`, the default change owner is used. + * If this value is `undefined` or `null`, the default change owner is used. + * + * Used in ImportTx. */ - ownerOverride?: OutputOwners; + ownerOverride?: OutputOwners | null; + /** + * Whether to consolidate outputs. + * + * @default false + */ + shouldConsolidateOutputs?: boolean; spendOptions: Required; /** * Maps `assetID` to the amount of the asset to spend without @@ -141,295 +91,453 @@ export type SpendProps = Readonly<{ utxos: readonly Utxo[]; }>; -/** - * Processes the spending of assets, including burning and staking, from a list of UTXOs. - * - * @param {SpendProps} props - The properties required to execute the spend operation. - * @param {Context} context - The context in which the spend operation is executed. - * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, - * and the second element is either the result of the spend operation or null. - */ -export const spend = ( - { - complexity, - excessAVAX: _excessAVAX = 0n, - fromAddresses, - ownerOverride, - spendOptions, - toBurn = new Map(), - toStake = new Map(), - utxos, - }: SpendProps, +type SpendReducerState = Readonly< + Required> +>; + +type SpendReducerFunction = ( + state: SpendReducerState, + spendHelper: SpendHelper, context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - try { - let changeOwners = - ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - let excessAVAX: bigint = _excessAVAX; +) => SpendReducerState; - const spendHelper = new SpendHelper({ - changeOutputs: [], - complexity, - gasPrice: context.gasPrice, - inputs: [], - stakeOutputs: [], - toBurn, - toStake, - weights: context.complexityWeights, - }); +const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { + const verifyError = spendHelper.verifyAssetsConsumed(); - const utxosByLocktime = splitByLocktime( - utxos, - spendOptions.minIssuanceTime, - ); + if (verifyError) { + throw verifyError; + } - for (const utxo of utxosByLocktime.locked) { - if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { - continue; + return state; +}; + +export const IncorrectStakeableLockOutError = new Error( + 'StakeableLockOut transferOut must be a TransferOutput.', +); + +export const useSpendableLockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter((utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; } - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; + // 1c. Ensure there are funds to stake. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; } - const utxoInfo = getUtxoInfo(utxo); + // 1d. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), + return true; + }); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Do all the logic for spending based on the UTXOs. + for (const { sigData, data: utxo } of verifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. + if (remainingAmountToStake === 0n) { + continue; + } + + // 3b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + // StakeableLockOut + new BigIntPr(utxoInfo.stakeableLocktime), + TransferInput.fromNative( + // TransferOutput + utxoInfo.amount, + sigData.sigIndicies, ), ), - ); + ), + ); - const excess = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); + // 3c. Consume the locked asset and get the remaining amount. + const remainingAmount = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); - spendHelper.addStakedOutput( - // TODO: Verify this. - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - excess), - utxo.getOutputOwners(), - ), + // 3d. Add the stake output. + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - remainingAmount), + utxo.getOutputOwners(), ), ), - ); - - if (excess === 0n) { - continue; - } + ), + ); - // This input had extra value, so some of it must be returned as change. + // 3e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { spendHelper.addChangeOutput( - // TODO: Verify this. new TransferableOutput( utxo.assetId, new StakeableLockOut( new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), + new TransferOutput( + new BigIntPr(remainingAmount), + utxo.getOutputOwners(), + ), ), ), ); } + } - // Add all remaining stake amounts assuming unlocked UTXOs - for (const [assetId, amount] of toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - spendOptions.changeAddresses, - ), - ); + // 4. Add all remaining stake amounts assuming they are unlocked. + for (const [assetId, amount] of state.toStake) { + if (amount === 0n) { + continue; } - // AVAX is handled last to account for fees. - const utxosByAVAXAssetId = splitByAssetId( - utxosByLocktime.unlocked, - context.avaxAssetID, + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + state.spendOptions.changeAddresses, + ), ); + } - for (const utxo of utxosByAVAXAssetId.other) { - const assetId = utxo.assetId.toString(); + return state; +}; - if (!spendHelper.shouldConsumeAsset(assetId)) { - continue; - } +export const useUnlockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = + state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter( + ( + utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return ( + utxo.output.getLocktime() < state.spendOptions.minIssuanceTime + ); + } + + return false; + }, + ); - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => + isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. + const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = + verifiedUsableUTXOs.reduce( + (result, { sigData, data: utxo }) => { + if (utxo.assetId.value() === context.avaxAssetID) { + return [result[0], [...result[1], { sigData, data: utxo }]]; + } + + return [[...result[0], { sigData, data: utxo }], result[1]]; + }, + [[], []] as [ + other: typeof verifiedUsableUTXOs, + avax: typeof verifiedUsableUTXOs, + ], + ); - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } + // 4. Handle all the non-AVAX asset UTXOs first. + for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToBurn: bigint = + state.toBurn.get(utxoInfo.assetId) ?? 0n; + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; - const utxoInfo = getUtxoInfo(utxo); + // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. + if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { + continue; + } - spendHelper.addInput( - utxo, - // TODO: Verify this. - // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); + // 4b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); - const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); + // 4c. Consume the asset and get the remaining amount. + const remainingAmount = spendHelper.consumeAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); - if (excess === 0n) { - continue; - } + // 4d. If "amountToStake" is greater than 0, add the stake output. + // TODO: Implement or determine if needed. - // This input had extra value, so some of it must be returned as change. + // 4e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { spendHelper.addChangeOutput( - // TODO: Verify this. new TransferableOutput( utxo.assetId, - new TransferOutput( - new BigIntPr(excess), - OutputOwners.fromNative(spendOptions.changeAddresses), + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(remainingAmount), + OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ), + ), ), ), ); } + } - for (const utxo of utxosByAVAXAssetId.requested) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } + // 5. Handle AVAX asset UTXOs last to account for fees. + let excessAVAX = state.excessAVAX; + let clearOwnerOverride = false; + for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { + const requiredFee = spendHelper.calculateFee(); - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } + const utxoInfo = getUtxoInfo(utxo); - const utxoInfo = getUtxoInfo(utxo); + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); + const remainingAmount = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); - const excess = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); + excessAVAX += remainingAmount; - excessAVAX += excess; + // The ownerOverride is no longer needed. Clear it. + clearOwnerOverride = true; + } - // If we need to consume additional AVAX, we should be returning the - // change to the change address. - changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); - } + return { + ...state, + excessAVAX, + ownerOverride: clearOwnerOverride ? null : state.ownerOverride, + }; +}; - // Verify - const verifyError = spendHelper.verifyAssetsConsumed(); - if (verifyError) { - return [verifyError, null]; - } +export const handleFee: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + const requiredFee = spendHelper.calculateFee(); + + if (state.excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - state.excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } - const requiredFee = spendHelper.calculateFee(); + // No need to add a change output. + if (state.excessAVAX === requiredFee) { + return state; + } - if (excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, + // Use the change owner override if it exists, otherwise use the default change owner. + // This is used for import transactions. + const changeOwners = + state.ownerOverride || + OutputOwners.fromNative(state.spendOptions.changeAddresses); + + // TODO: Clean-up if this is no longer needed. + // Additionally, no need for public .addOutputComplexity(). + // + // Pre-consolidation code. + // + // spendHelper.addOutputComplexity( + // new TransferableOutput( + // Id.fromString(context.avaxAssetID), + // new TransferOutput(new BigIntPr(0n), changeOwners), + // ), + // ); + // + // Recalculate the fee with the change output. + // const requiredFeeWithChange = spendHelper.calculateFee(); + + // Calculate the fee with a temporary output complexity if a change output is needed. + const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( + context.avaxAssetID, + changeOwners, + ) + ? requiredFee + : spendHelper.calculateFeeWithTemporaryOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), ); - } - // NOTE: This logic differs a bit from avalanche go because our classes are immutable. - spendHelper.addOutputComplexity( + // Add a change output if needed. + if (state.excessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( new TransferableOutput( Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFeeWithChange), + changeOwners, + ), ), ); + } - const requiredFeeWithChange = spendHelper.calculateFee(); + return state; +}; - if (excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + excessAVAX: _excessAVAX = 0n, + fromAddresses, + initialComplexity, + ownerOverride, + shouldConsolidateOutputs = false, + spendOptions, + toBurn = new Map(), + toStake = new Map(), + utxos, + }: SpendProps, + spendReducers: readonly SpendReducerFunction[], + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + const changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + const excessAVAX: bigint = _excessAVAX; + + const spendHelper = new SpendHelper({ + changeOutputs: [], + gasPrice: context.gasPrice, + initialComplexity, + inputs: [], + shouldConsolidateOutputs, + stakeOutputs: [], + toBurn, + toStake, + weights: context.complexityWeights, + }); + + const initialState: SpendReducerState = { + excessAVAX, + initialComplexity, + fromAddresses, + ownerOverride: changeOwners, + spendOptions, + toBurn, + toStake, + utxos, + }; + + const spendReducerFunctions: readonly SpendReducerFunction[] = [ + ...spendReducers, + // useSpendableLockedUTXOs, + // useUnlockedUTXOs, + verifyAssetsConsumed, + handleFee, + // Consolidation and sorting happens in the SpendHelper. + ]; + + // Run all the spend calculation reducer logic. + spendReducerFunctions.reduce((state, next) => { + return next(state, spendHelper, context); + }, initialState); - // Sorting happens in the .getInputsOutputs() method. return [null, spendHelper.getInputsOutputs()]; } catch (error) { return [ - new Error('An unexpected error occurred during spend calculation', { - cause: error instanceof Error ? error : undefined, - }), + error instanceof Error + ? error + : new Error('An unexpected error occurred during spend calculation'), null, ]; } diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 820cb001c..abfbe68f8 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -16,9 +16,10 @@ const DEFAULT_WEIGHTS = createDimensions(1, 2, 3, 4); const DEFAULT_PROPS: SpendHelperProps = { changeOutputs: [], - complexity: createDimensions(1, 1, 1, 1), gasPrice: DEFAULT_GAS_PRICE, + initialComplexity: createDimensions(1, 1, 1, 1), inputs: [], + shouldConsolidateOutputs: false, stakeOutputs: [], toBurn: new Map(), toStake: new Map(), @@ -35,7 +36,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(results.changeOutputs).toEqual([]); expect(results.fee).toBe( - dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + dimensionsToGas(DEFAULT_PROPS.initialComplexity, DEFAULT_WEIGHTS) * DEFAULT_GAS_PRICE, ); expect(results.inputs).toEqual([]); @@ -49,7 +50,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [], fee: - dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + dimensionsToGas(DEFAULT_PROPS.initialComplexity, DEFAULT_WEIGHTS) * DEFAULT_GAS_PRICE, inputs: [], inputUTXOs: [], diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 6a844f323..81bc3b084 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -15,9 +15,10 @@ import { getInputComplexity, getOutputComplexity } from '../txs/fee'; export interface SpendHelperProps { changeOutputs: readonly TransferableOutput[]; - complexity: Dimensions; gasPrice: bigint; + initialComplexity: Dimensions; inputs: readonly TransferableInput[]; + shouldConsolidateOutputs: boolean; stakeOutputs: readonly TransferableOutput[]; toBurn: Map; toStake: Map; @@ -33,6 +34,7 @@ export interface SpendHelperProps { export class SpendHelper { private readonly gasPrice: bigint; private readonly initialComplexity: Dimensions; + private readonly shouldConsolidateOutputs: boolean; private readonly toBurn: Map; private readonly toStake: Map; private readonly weights: Dimensions; @@ -47,16 +49,18 @@ export class SpendHelper { constructor({ changeOutputs, - complexity, gasPrice, + initialComplexity, inputs, + shouldConsolidateOutputs, stakeOutputs, toBurn, toStake, weights, }: SpendHelperProps) { this.gasPrice = gasPrice; - this.initialComplexity = complexity; + this.initialComplexity = initialComplexity; + this.shouldConsolidateOutputs = shouldConsolidateOutputs; this.toBurn = toBurn; this.toStake = toStake; this.weights = weights; @@ -141,8 +145,10 @@ export class SpendHelper { } private consolidateOutputs(): void { - this.changeOutputs = consolidateOutputs(this.changeOutputs); - this.stakeOutputs = consolidateOutputs(this.stakeOutputs); + if (this.shouldConsolidateOutputs) { + this.changeOutputs = consolidateOutputs(this.changeOutputs); + this.stakeOutputs = consolidateOutputs(this.stakeOutputs); + } } /**