diff --git a/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts b/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts index 3d0d34cd327..a6c46558e88 100644 --- a/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts +++ b/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts @@ -18,11 +18,11 @@ import { nativeScriptPolicyId, util } from '@cardano-sdk/core'; +import { GreedyTxEvaluator, defaultSelectionConstraints } from '@cardano-sdk/tx-construction'; import { InputSelector, StaticChangeAddressResolver, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { MultiSigTx } from './MultiSigTx'; import { Observable, firstValueFrom, interval, map, switchMap } from 'rxjs'; import { WalletNetworkInfoProvider } from '@cardano-sdk/wallet'; -import { defaultSelectionConstraints } from '@cardano-sdk/tx-construction'; const randomHexChar = () => Math.floor(Math.random() * 16).toString(16); const randomPublicKey = () => Crypto.Ed25519PublicKeyHex(Array.from({ length: 64 }).map(randomHexChar).join('')); @@ -464,7 +464,9 @@ export class MultiSigWallet { } }; }, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: new GreedyTxEvaluator(() => this.#networkInfoProvider.protocolParameters()) }); const implicitCoin = Cardano.util.computeImplicitCoin(protocolParameters, { diff --git a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts index 85979b2b44f..f71dc393553 100644 --- a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts +++ b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts @@ -48,16 +48,21 @@ const adjustOutputsForFee = async ( outputs: Set, changeOutputs: Array, currentFee: bigint -): Promise<{ fee: bigint; change: Array; feeAccountedFor: boolean }> => { +): Promise<{ + fee: bigint; + change: Array; + feeAccountedFor: boolean; + redeemers?: Array; +}> => { const totalOutputs = new Set([...outputs, ...changeOutputs]); - const fee = await constraints.computeMinimumCost({ + const { fee, redeemers } = await constraints.computeMinimumCost({ change: [], fee: currentFee, inputs, outputs: totalOutputs }); - if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true }; + if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true, redeemers }; if (changeLovelace < fee) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); @@ -76,7 +81,7 @@ const adjustOutputsForFee = async ( } } - return { change: [...updatedOutputs], fee, feeAccountedFor }; + return { change: [...updatedOutputs], fee, feeAccountedFor, redeemers }; }; /** diff --git a/packages/input-selection/src/RoundRobinRandomImprove/change.ts b/packages/input-selection/src/RoundRobinRandomImprove/change.ts index 5030e54a8d4..c77b043f2ce 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/change.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/change.ts @@ -13,7 +13,10 @@ import minBy from 'lodash/minBy'; import orderBy from 'lodash/orderBy'; import pick from 'lodash/pick'; -type EstimateTxFeeWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise; +type EstimateTxFeeWithOriginalOutputs = ( + utxo: Cardano.Utxo[], + change: Cardano.Value[] +) => Promise<{ fee: Cardano.Lovelace; redeemers?: Array }>; interface ChangeComputationArgs { utxoSelection: UtxoSelection; @@ -31,6 +34,7 @@ interface ChangeComputationResult { inputs: Cardano.Utxo[]; change: Cardano.Value[]; fee: Cardano.Lovelace; + redeemers?: Array; } const getLeftoverAssets = (utxoSelected: Cardano.Utxo[], uniqueTxAssetIDs: Cardano.AssetId[]) => { @@ -372,13 +376,13 @@ export const computeChangeAndAdjustForFee = async ({ // Calculate fee with change outputs that include fee. // It will cover the fee of final selection, // where fee is excluded from change bundles - const fee = await estimateTxFee( + const estimatedFee = await estimateTxFee( selectionWithChangeAndFee.utxoSelected, validateChangeBundles(selectionWithChangeAndFee.changeBundles, tokenBundleSizeExceedsLimit) ); // Ensure fee quantity is covered by current selection - const totalOutputCoin = getCoinQuantity(outputValues) + fee + implicitValue.implicitCoin.deposit; + const totalOutputCoin = getCoinQuantity(outputValues) + estimatedFee.fee + implicitValue.implicitCoin.deposit; const totalInputCoin = getCoinQuantity(toValues(selectionWithChangeAndFee.utxoSelected)) + implicitValue.implicitCoin.input; if (totalOutputCoin > totalInputCoin) { @@ -391,7 +395,7 @@ export const computeChangeAndAdjustForFee = async ({ const finalSelection = computeChangeBundles({ computeMinimumCoinQuantity, - fee, + fee: estimatedFee.fee, implicitValue, outputValues, uniqueTxAssetIDs, @@ -406,8 +410,9 @@ export const computeChangeAndAdjustForFee = async ({ return { change: validateChangeBundles(changeBundles, tokenBundleSizeExceedsLimit), - fee, + fee: estimatedFee.fee, inputs: utxoSelected, + redeemers: estimatedFee.redeemers, remainingUTxO: utxoRemaining }; }; diff --git a/packages/input-selection/src/RoundRobinRandomImprove/index.ts b/packages/input-selection/src/RoundRobinRandomImprove/index.ts index c8ece3a1f0c..ed41e94036e 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/index.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/index.ts @@ -93,6 +93,7 @@ export const roundRobinRandomImprove = ({ selection.inputs = new Set([...selection.inputs].sort(sortUtxoByTxIn)); return { + redeemers: result.redeemers, remainingUTxO: new Set(result.remainingUTxO), selection }; diff --git a/packages/input-selection/src/types.ts b/packages/input-selection/src/types.ts index c49245a861a..c467afc8260 100644 --- a/packages/input-selection/src/types.ts +++ b/packages/input-selection/src/types.ts @@ -35,12 +35,17 @@ export interface SelectionResult { * has removed values to pay for entries in the requested output set. */ remainingUTxO: Set; + + /** The list of redeemers and their execution cost. */ + redeemers?: Array; } /** * @returns minimum transaction fee in Lovelace. */ -export type EstimateTxFee = (selectionSkeleton: SelectionSkeleton) => Promise; +export type EstimateTxFee = ( + selectionSkeleton: SelectionSkeleton +) => Promise<{ fee: bigint; redeemers?: Array }>; /** * @returns true if token bundle size exceeds it's maximum size limit. diff --git a/packages/input-selection/test/util/selectionConstraints.ts b/packages/input-selection/test/util/selectionConstraints.ts index a281481efbb..acc2b18dcea 100644 --- a/packages/input-selection/test/util/selectionConstraints.ts +++ b/packages/input-selection/test/util/selectionConstraints.ts @@ -17,7 +17,7 @@ export const MOCK_NO_CONSTRAINTS: MockSelectionConstraints = { export const mockConstraintsToConstraints = (constraints: MockSelectionConstraints): SelectionConstraints => ({ computeMinimumCoinQuantity: () => constraints.minimumCoinQuantity, - computeMinimumCost: async ({ inputs }) => constraints.minimumCostCoefficient * BigInt(inputs.size), + computeMinimumCost: async ({ inputs }) => ({ fee: constraints.minimumCostCoefficient * BigInt(inputs.size) }), computeSelectionLimit: async () => constraints.selectionLimit, tokenBundleSizeExceedsLimit: (assets?: Cardano.TokenMap) => (assets?.size || 0) > constraints.maxTokenBundleSize }); diff --git a/packages/tx-construction/src/createTransactionInternals.ts b/packages/tx-construction/src/createTransactionInternals.ts index 0652c97ba21..dc3521512e8 100644 --- a/packages/tx-construction/src/createTransactionInternals.ts +++ b/packages/tx-construction/src/createTransactionInternals.ts @@ -2,6 +2,8 @@ import * as Crypto from '@cardano-sdk/crypto'; import { Cardano, Serialization, util } from '@cardano-sdk/core'; import { SelectionResult } from '@cardano-sdk/input-selection'; import { TxBodyPreInputSelection } from './types'; +import { computeScriptDataHash } from './computeScriptDataHash'; +import { getDefaultCostModelsForVersions } from './tx-builder/costModels'; export type CreateTxInternalsProps = { inputSelection: SelectionResult['selection']; @@ -53,9 +55,13 @@ export const createPreInputSelectionTxBody = ({ /** Updates the txBody after input selection takes place with the calculated change and selected inputs */ export const includeChangeAndInputs = ({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions, + witness }: Pick & { bodyPreInputSelection: TxBodyPreInputSelection; + witness?: Cardano.Witness; + scriptVersions?: Set; }): Cardano.TxBodyWithHash => { const body: Cardano.TxBody = { ...bodyPreInputSelection, @@ -63,6 +69,17 @@ export const includeChangeAndInputs = ({ inputs: [...inputSelection.inputs].map(([txIn]) => txIn), outputs: [...inputSelection.outputs, ...inputSelection.change] }; + + if (scriptVersions && witness) { + const costModels = getDefaultCostModelsForVersions([...scriptVersions]); + body.scriptIntegrityHash = computeScriptDataHash( + costModels, + [...scriptVersions], + witness.redeemers, + witness.datums + ); + } + const serializableBody = Serialization.TransactionBody.fromCore(body); return { diff --git a/packages/tx-construction/src/input-selection/selectionConstraints.ts b/packages/tx-construction/src/input-selection/selectionConstraints.ts index 02ebc266de3..af1649a73a4 100644 --- a/packages/tx-construction/src/input-selection/selectionConstraints.ts +++ b/packages/tx-construction/src/input-selection/selectionConstraints.ts @@ -10,16 +10,98 @@ import { TokenBundleSizeExceedsLimit } from '@cardano-sdk/input-selection'; import { MinFeeCoefficient, MinFeeConstant, minAdaRequired, minFee } from '../fees'; +import { TxEvaluationResult, TxEvaluator, TxIdWithIndex } from '../tx-builder'; export const MAX_U64 = 18_446_744_073_709_551_615n; export type BuildTx = (selection: SelectionSkeleton) => Promise; +export interface RedeemersByType { + spend?: Map; + mint?: Array; + certificate?: Array; + withdrawal?: Array; + propose?: Array; + vote?: Array; +} + export interface DefaultSelectionConstraintsProps { protocolParameters: ProtocolParametersForInputSelection; buildTx: BuildTx; + redeemersByType: RedeemersByType; + txEvaluator: TxEvaluator; } +const updateRedeemers = ( + evaluation: TxEvaluationResult, + redeemersByType: RedeemersByType, + txInputs: Array +): Array => { + const result: Array = []; + + // Mapping between purpose and redeemersByType + const redeemersMap: { [key in Cardano.RedeemerPurpose]?: Map | Cardano.Redeemer[] } = { + [Cardano.RedeemerPurpose.spend]: redeemersByType.spend, + [Cardano.RedeemerPurpose.mint]: redeemersByType.mint, + [Cardano.RedeemerPurpose.certificate]: redeemersByType.certificate, + [Cardano.RedeemerPurpose.withdrawal]: redeemersByType.withdrawal, + [Cardano.RedeemerPurpose.propose]: redeemersByType.propose, + [Cardano.RedeemerPurpose.vote]: redeemersByType.vote + }; + + for (const txEval of evaluation) { + const redeemers = redeemersMap[txEval.purpose]; + if (!redeemers) throw new Error(`No redeemers found for ${txEval.purpose} purpose`); + + let knownRedeemer; + if (txEval.purpose === Cardano.RedeemerPurpose.spend) { + const input = txInputs[txEval.index]; + + knownRedeemer = (redeemers as Map).get(`${input.txId}#${input.index}`); + + if (!knownRedeemer) throw new Error(`Known Redeemer not found for tx id ${input.txId} and index ${input.index}`); + } else { + const redeemerList = redeemers as Cardano.Redeemer[]; + + knownRedeemer = redeemerList.find((redeemer) => redeemer.index === txEval.index); + + if (!knownRedeemer) throw new Error(`Known Redeemer not found for index ${txEval.index}`); + } + + result.push({ ...knownRedeemer, executionUnits: txEval.budget }); + } + + return result; +}; + +const reorgRedeemers = ( + redeemerByType: RedeemersByType, + witness: Cardano.Witness, + txInputs: Array +): Cardano.Redeemer[] => { + let redeemers: Cardano.Redeemer[] = []; + + if (witness.redeemers) { + // Lets remove all spend redeemers if any. + redeemers = witness.redeemers.filter((redeemer) => redeemer.purpose !== Cardano.RedeemerPurpose.spend); + + // Add them back with the correct redeemer index. + if (redeemerByType.spend) { + for (const [key, value] of redeemerByType.spend) { + const index = txInputs.findIndex((input) => key === `${input.txId}#${input.index}`); + + if (index < 0) throw new Error(`Redeemer not found for tx id ${key}`); + + value.index = index; + + redeemers.push({ ...value }); + } + } + } + + return redeemers; +}; + export const computeMinimumCost = ( { @@ -27,12 +109,25 @@ export const computeMinimumCost = minFeeConstant, prices }: Pick, - buildTx: BuildTx + buildTx: BuildTx, + txEvaluator: TxEvaluator, + redeemersByType: RedeemersByType ): EstimateTxFee => async (selection) => { const tx = await buildTx(selection); + const utxos = [...selection.inputs]; + const txIns = utxos.map((utxo) => utxo[0]); + + if (tx.witness && tx.witness.redeemers && tx.witness.redeemers.length > 0) { + // before the evaluation can happen, we need to point every redeemer to its corresponding inputs. + tx.witness.redeemers = reorgRedeemers(redeemersByType, tx.witness, txIns); + tx.witness.redeemers = updateRedeemers(await txEvaluator.evaluate(tx, utxos), redeemersByType, txIns); + } - return minFee(tx, prices, MinFeeConstant(minFeeConstant), MinFeeCoefficient(minFeeCoefficient)); + return { + fee: minFee(tx, prices, MinFeeConstant(minFeeConstant), MinFeeCoefficient(minFeeCoefficient)), + redeemers: tx.witness.redeemers + }; }; export const computeMinimumCoinQuantity = @@ -75,7 +170,9 @@ export const computeSelectionLimit = export const defaultSelectionConstraints = ({ protocolParameters: { coinsPerUtxoByte, maxTxSize, maxValueSize, minFeeCoefficient, minFeeConstant, prices }, - buildTx + buildTx, + redeemersByType, + txEvaluator }: DefaultSelectionConstraintsProps): SelectionConstraints => { if (!coinsPerUtxoByte || !maxTxSize || !maxValueSize || !minFeeCoefficient || !minFeeConstant || !prices) { throw new InvalidProtocolParametersError( @@ -84,7 +181,12 @@ export const defaultSelectionConstraints = ({ } return { computeMinimumCoinQuantity: computeMinimumCoinQuantity(coinsPerUtxoByte), - computeMinimumCost: computeMinimumCost({ minFeeCoefficient, minFeeConstant, prices }, buildTx), + computeMinimumCost: computeMinimumCost( + { minFeeCoefficient, minFeeConstant, prices }, + buildTx, + txEvaluator, + redeemersByType + ), computeSelectionLimit: computeSelectionLimit(maxTxSize, buildTx), tokenBundleSizeExceedsLimit: tokenBundleSizeExceedsLimit(maxValueSize) }; diff --git a/packages/tx-construction/src/tx-builder/TxBuilder.ts b/packages/tx-construction/src/tx-builder/TxBuilder.ts index e27e405a829..05401448550 100644 --- a/packages/tx-construction/src/tx-builder/TxBuilder.ts +++ b/packages/tx-construction/src/tx-builder/TxBuilder.ts @@ -15,7 +15,6 @@ import { HandleResolution, Serialization, coalesceValueQuantities, - util as coreUtils, metadatum } from '@cardano-sdk/core'; import { @@ -44,13 +43,11 @@ import { import { GreedyTxEvaluator } from './GreedyTxEvaluator'; import { Logger } from 'ts-log'; import { OutputBuilderValidator, TxOutputBuilder } from './OutputBuilder'; +import { RedeemersByType, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit } from '../input-selection'; import { RewardAccountWithPoolId } from '../types'; import { coldObservableProvider } from '@cardano-sdk/util-rxjs'; -import { computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit } from '../input-selection'; -import { computeScriptDataHash } from '../computeScriptDataHash'; import { contextLogger, deepEquals } from '@cardano-sdk/util'; import { createOutputValidator } from '../output-validation'; -import { getDefaultCostModelsForVersions } from './costModels'; import { initializeTx } from './initializeTx'; import { lastValueFrom } from 'rxjs'; import minBy from 'lodash/minBy'; @@ -180,7 +177,14 @@ export class GenericTxBuilder implements TxBuilder { #knownReferenceScripts = new Set(); #knownDatums = new Map(); #knownInlineDatums = new Set(); - #knownSpendRedeemers = new Map(); + #knownRedeemers: RedeemersByType = { + certificate: new Array(), + mint: new Array(), + propose: new Array(), + spend: new Map(), + vote: new Array(), + withdrawal: new Array() + }; #unresolvedInputs = new Array(); #unresolvedReferenceInputs = new Array(); @@ -409,14 +413,22 @@ export class GenericTxBuilder implements TxBuilder { // We must resolve datums after inputs since we may discover datums during that process. await Promise.all(this.#unresolvedDatums.map((datumHash) => this.#resolveDatum(datumHash))); - const witness = await this.#computeWitnessWithExecutionBudget(partialSigningOptions, auxiliaryData); + const witness = await this.#buildWitness(); + const hasPlutusScripts = [...this.#knownScripts.values()].some((script) => Cardano.isPlutusScript(script)); const { collaterals, collateralReturn } = hasPlutusScripts ? await this.#computeCollateral() : { collateralReturn: undefined, collaterals: undefined }; - const { body, hash, inputSelection } = await initializeTx( + const scriptVersions = new Set(); + for (const script of this.#knownScripts.values()) { + if (Cardano.isPlutusScript(script)) { + scriptVersions.add(script.version); + } + } + + const { body, hash, inputSelection, redeemers } = await initializeTx( { auxiliaryData, certificates: this.partialTxBody.certificates, @@ -430,15 +442,18 @@ export class GenericTxBuilder implements TxBuilder { }, outputs: new Set(this.partialTxBody.outputs || []), proposalProcedures: this.partialTxBody.proposalProcedures, + redeemersByType: this.#knownRedeemers, referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])), scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined, + scriptVersions, signingOptions: partialSigningOptions, + txEvaluator: this.#txEvaluator, witness }, dependencies ); - const updateHash = this.#updateHashes(body, witness); + witness.redeemers = redeemers; return { ctx: { @@ -453,7 +468,7 @@ export class GenericTxBuilder implements TxBuilder { witness }, inputSelection, - tx: { body, hash: updateHash ?? hash } + tx: { body, hash } }; } catch (error) { this.#logger.debug('Transaction build error', error); @@ -524,110 +539,7 @@ export class GenericTxBuilder implements TxBuilder { throw new Error('No suitable collateral found'); } - #updateHashes(body: Cardano.TxBody, witness: Cardano.Witness): Cardano.TransactionId | undefined { - const hasPlutusScripts = witness.redeemers ? witness.redeemers.length > 0 : false; - - if (hasPlutusScripts) { - const scriptVersions = new Set(); - - for (const script of this.#knownScripts.values()) { - if (Cardano.isPlutusScript(script)) { - scriptVersions.add(script.version); - } - } - - const datumsToHash = []; - - for (const [key, value] of this.#knownDatums) { - if (!this.#knownInlineDatums.has(key)) datumsToHash.push(value); - } - - const costModels = getDefaultCostModelsForVersions([...scriptVersions]); - body.scriptIntegrityHash = computeScriptDataHash( - costModels, - [...scriptVersions], - witness.redeemers, - datumsToHash - ); - - const serializableBody = Serialization.TransactionBody.fromCore(body); - - return Cardano.TransactionId.fromHexBlob( - coreUtils.bytesToHex( - Crypto.blake2b(Crypto.blake2b.BYTES).update(coreUtils.hexToBytes(serializableBody.toCbor())).digest() - ) - ); - } - - return undefined; - } - - async #computeWitnessWithExecutionBudget( - partialSigningOptions?: SignTransactionOptions, - auxiliaryData?: Cardano.AuxiliaryData - ) { - let witness = await this.#buildWitness(); - - const scripts = [...this.#knownScripts.values()]; - if (scripts.some((script) => Cardano.isPlutusScript(script))) { - // We need to perform input selection with a preliminary witness that over budgets, with this - // initial draft of the transaction with the selection we can then compute the actual cost of the scripts, - // and rerun the input selection. - const dependencies = { ...this.#dependencies }; - - const { body, inputSelection } = await initializeTx( - { - auxiliaryData, - certificates: this.partialTxBody.certificates, - customizeCb: this.#customizeCb, - handleResolutions: this.#handleResolutions, - inputs: new Set(this.#mustSpendInputs.values()), - options: { - validityInterval: this.partialTxBody.validityInterval - }, - outputs: new Set(this.partialTxBody.outputs || []), - proposalProcedures: this.partialTxBody.proposalProcedures, - referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])), - scriptIntegrityHash: DUMMY_SCRIPT_DATA_HASH, - signingOptions: partialSigningOptions, - witness - }, - dependencies - ); - - witness = await this.#buildWitness(body); - const evaluation = await this.#txEvaluator.evaluate( - { - body, - witness - } as Cardano.Tx, - [...inputSelection.inputs, ...this.#referenceInputs.values()] - ); - - for (const txEval of evaluation) { - const redeemer = witness.redeemers?.find((x) => x.index === txEval.index && x.purpose === txEval.purpose); - - if (!redeemer) throw new Error(`Redeemer not found for index ${txEval.index} and purpose ${txEval.purpose}`); - - redeemer.executionUnits = txEval.budget; - - // Update our known redeemers - if (txEval.purpose === Cardano.RedeemerPurpose.spend) { - const input = body.inputs[txEval.index]; - const knownRedeemer = this.#knownSpendRedeemers.get(`${input.txId}#${input.index}`); - - if (!knownRedeemer) - throw new Error(`Known Redeemer not found for tx id ${input.txId} and index ${input.index}`); - - knownRedeemer.executionUnits = txEval.budget; - } - } - } - - return witness; - } - - async #buildWitness(body?: Cardano.TxBody): Promise { + async #buildWitness(): Promise { const witnesses = { signatures: new Map() } as Cardano.Witness; if (this.#knownDatums) { @@ -648,28 +560,27 @@ export class GenericTxBuilder implements TxBuilder { const { maxExecutionUnitsPerTransaction } = await this.#dependencies.txBuilderProviders.protocolParameters(); - if (this.#knownSpendRedeemers) { - witnesses.redeemers = GenericTxBuilder.#buildRedeemers( - this.#knownSpendRedeemers, - maxExecutionUnitsPerTransaction, - body - ); + if (this.#knownRedeemers) { + witnesses.redeemers = GenericTxBuilder.#buildRedeemers(this.#knownRedeemers, maxExecutionUnitsPerTransaction); } return witnesses; } - static #buildRedeemers( - redeemersData: Map, - maxExecutionUnits: Cardano.ExUnits, - body?: Cardano.TxBody - ) { + static #buildRedeemers(redeemersData: RedeemersByType, maxExecutionUnits: Cardano.ExUnits) { const redeemers = []; - for (const [key, value] of redeemersData.entries()) { - const index = body - ? body.inputs.findIndex((txIn) => `${txIn.txId}#${txIn.index}` === key) - : Number.MAX_SAFE_INTEGER; + const knownRedeemers = [ + ...(redeemersData.mint ?? []), + ...(redeemersData.vote ?? []), + ...(redeemersData.propose ?? []), + ...(redeemersData.certificate ?? []), + ...(redeemersData.withdrawal ?? []), + ...(redeemersData.spend ? [...redeemersData.spend.values()] : []) + ]; + + for (const value of knownRedeemers) { + const index = Number.MAX_SAFE_INTEGER; redeemers.push({ data: value.data, @@ -952,7 +863,7 @@ export class GenericTxBuilder implements TxBuilder { } if (scriptUnlockProps.redeemer) { - this.#knownSpendRedeemers.set(txId, { + this.#knownRedeemers.spend?.set(txId, { data: scriptUnlockProps.redeemer, executionUnits: { memory: 0, diff --git a/packages/tx-construction/src/tx-builder/initializeTx.ts b/packages/tx-construction/src/tx-builder/initializeTx.ts index 711150a81b1..9c4e83e342a 100644 --- a/packages/tx-construction/src/tx-builder/initializeTx.ts +++ b/packages/tx-construction/src/tx-builder/initializeTx.ts @@ -1,10 +1,11 @@ import { StaticChangeAddressResolver, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { Cardano, Serialization } from '@cardano-sdk/core'; +import { GreedyTxEvaluator } from './GreedyTxEvaluator'; import { InitializeTxProps, InitializeTxResult } from '../types'; +import { RedeemersByType, defaultSelectionConstraints } from '../input-selection'; import { TxBuilderDependencies } from './types'; import { createPreInputSelectionTxBody, includeChangeAndInputs } from '../createTransactionInternals'; -import { defaultSelectionConstraints } from '../input-selection'; import { ensureValidityInterval } from '../ensureValidityInterval'; import { util } from '@cardano-sdk/key-management'; @@ -28,6 +29,8 @@ export const initializeTx = async ( txBuilderProviders.addresses.get() ]); + const txEvaluator = props.txEvaluator ?? new GreedyTxEvaluator(() => txBuilderProviders.protocolParameters()); + inputSelector = inputSelector ?? roundRobinRandomImprove({ @@ -64,7 +67,9 @@ export const initializeTx = async ( } const unwitnessedTx = includeChangeAndInputs({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions: props.scriptVersions, + witness: props.witness as Cardano.Witness }); const dRepPublicKey = addressManager @@ -92,7 +97,9 @@ export const initializeTx = async ( return tx; }, - protocolParameters + protocolParameters, + redeemersByType: props.redeemersByType ?? ({} as RedeemersByType), + txEvaluator }); const implicitCoin = Cardano.util.computeImplicitCoin(protocolParameters, { @@ -101,7 +108,7 @@ export const initializeTx = async ( withdrawals: bodyPreInputSelection.withdrawals }); - const { selection: inputSelection } = await inputSelector.select({ + const { selection: inputSelection, redeemers } = await inputSelector.select({ constraints, implicitValue: { coin: implicitCoin, mint: bodyPreInputSelection.mint }, mustSpendUtxo: props.inputs || new Set(), @@ -109,9 +116,14 @@ export const initializeTx = async ( utxo: new Set(utxo) }); + const witness = { ...props.witness, redeemers } as Cardano.Witness; + const { body, hash } = includeChangeAndInputs({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions: props.scriptVersions, + witness }); - return { body, hash, inputSelection }; + + return { body, hash, inputSelection, redeemers }; }; diff --git a/packages/tx-construction/src/tx-builder/types.ts b/packages/tx-construction/src/tx-builder/types.ts index 8561eaaa9c7..a2a79c660fa 100644 --- a/packages/tx-construction/src/tx-builder/types.ts +++ b/packages/tx-construction/src/tx-builder/types.ts @@ -379,7 +379,7 @@ export interface TxBuilder { export interface TxBuilderDependencies { inputSelector?: InputSelector; datumResolver?: DatumResolver; - txEvaluator?: TxEvaluator; + txEvaluator: TxEvaluator; inputResolver: Cardano.InputResolver; bip32Account?: Bip32Account; witnesser: Witnesser; diff --git a/packages/tx-construction/src/types.ts b/packages/tx-construction/src/types.ts index cfb6c792504..8ee21e5b447 100644 --- a/packages/tx-construction/src/types.ts +++ b/packages/tx-construction/src/types.ts @@ -3,10 +3,14 @@ import { Cardano, HandleResolution } from '@cardano-sdk/core'; import { GroupedAddress, SignTransactionOptions } from '@cardano-sdk/key-management'; import { SelectionSkeleton } from '@cardano-sdk/input-selection'; -import { CustomizeCb } from './tx-builder'; +import { CustomizeCb, TxEvaluator } from './tx-builder'; import { MinimumCoinQuantityPerOutput } from './output-validation'; +import { RedeemersByType } from './input-selection'; -export type InitializeTxResult = Cardano.TxBodyWithHash & { inputSelection: SelectionSkeleton }; +export type InitializeTxResult = Cardano.TxBodyWithHash & { + inputSelection: SelectionSkeleton; + redeemers?: Array; +}; export type RewardAccountWithPoolId = Omit & { delegatee?: { nextNextEpoch?: { id: Cardano.PoolId } }; @@ -55,6 +59,9 @@ export interface InitializeTxProps { proposalProcedures?: Cardano.ProposalProcedure[]; /** callback function that allows updating the transaction before input selection */ customizeCb?: CustomizeCb; + txEvaluator?: TxEvaluator; + redeemersByType?: RedeemersByType; + scriptVersions?: Set; } export interface InitializeTxPropsValidationResult { diff --git a/packages/tx-construction/test/input-selection/selectionConstraints.test.ts b/packages/tx-construction/test/input-selection/selectionConstraints.test.ts index c8e5bec97ee..e08007cef4b 100644 --- a/packages/tx-construction/test/input-selection/selectionConstraints.test.ts +++ b/packages/tx-construction/test/input-selection/selectionConstraints.test.ts @@ -3,10 +3,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable unicorn/consistent-function-scoping */ import { AssetId } from '@cardano-sdk/util-dev'; -import { Cardano, InvalidProtocolParametersError } from '@cardano-sdk/core'; +import { Cardano, InvalidProtocolParametersError, Serialization } from '@cardano-sdk/core'; import { DefaultSelectionConstraintsProps, defaultSelectionConstraints } from '../../src'; +import { HexBlob } from '@cardano-sdk/util'; import { ProtocolParametersForInputSelection, SelectionSkeleton } from '@cardano-sdk/input-selection'; import { babbageTx, getBigBabbageTx } from '../testData'; +import { mockTxEvaluator } from '../tx-builder/mocks'; describe('defaultSelectionConstraints', () => { const protocolParameters = { @@ -29,15 +31,63 @@ describe('defaultSelectionConstraints', () => { }); it('computeMinimumCost', async () => { - const fee = 218_763n; + const fee = 218_137n; const buildTx = jest.fn(async () => babbageTx); - const selectionSkeleton = {} as SelectionSkeleton; + const selectionSkeleton = { inputs: [] } as unknown as SelectionSkeleton; const constraints = defaultSelectionConstraints({ buildTx, - protocolParameters + protocolParameters, + redeemersByType: { + certificate: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008102')).toCore(), + executionUnits: { + memory: 0, + steps: 0 + }, + index: 1, + purpose: Cardano.RedeemerPurpose.certificate + } + ], + mint: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008101')).toCore(), + executionUnits: { + memory: 0, + steps: 0 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.mint + } + ] + }, + txEvaluator: mockTxEvaluator }); + const result = await constraints.computeMinimumCost(selectionSkeleton); - expect(result).toEqual(fee); + expect(result).toEqual({ + fee, + redeemers: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008101')).toCore(), + executionUnits: { + memory: 100, + steps: 200 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.mint + }, + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008102')).toCore(), + executionUnits: { + memory: 100, + steps: 200 + }, + index: 1, + purpose: Cardano.RedeemerPurpose.certificate + } + ] + }); expect(buildTx).toBeCalledTimes(1); expect(buildTx).toBeCalledWith(selectionSkeleton); }); @@ -64,7 +114,9 @@ describe('defaultSelectionConstraints', () => { it("doesn't exceed max tx size", async () => { const constraints = defaultSelectionConstraints({ buildTx: async () => babbageTx, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(await constraints.computeSelectionLimit({ inputs: new Set([1, 2]) as any } as SelectionSkeleton)).toEqual( 2 @@ -74,7 +126,9 @@ describe('defaultSelectionConstraints', () => { it('exceeds max tx size', async () => { const constraints = defaultSelectionConstraints({ buildTx: getBigBabbageTx, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(await constraints.computeSelectionLimit({ inputs: new Set([1, 2]) as any } as SelectionSkeleton)).toEqual( 1 @@ -86,7 +140,9 @@ describe('defaultSelectionConstraints', () => { it('empty bundle', () => { const constraints = defaultSelectionConstraints({ buildTx: jest.fn(), - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(constraints.tokenBundleSizeExceedsLimit()).toBe(false); }); diff --git a/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts b/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts index a330f06a738..ef102c1569c 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts @@ -4,6 +4,7 @@ import { GenericTxBuilder, OutputValidation, TxBuilderProviders } from '../../sr import { SodiumBip32Ed25519 } from '@cardano-sdk/crypto'; import { dummyLogger } from 'ts-log'; import { logger, mockProviders as mocks } from '@cardano-sdk/util-dev'; +import { mockTxEvaluator } from './mocks'; describe.each([ ['TxBuilderGeneric', false], @@ -66,6 +67,7 @@ describe.each([ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(keyAgent) }; const txBuilder = new GenericTxBuilder(builderParams); diff --git a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts index 4c696b6207a..6b42ad3a369 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts @@ -29,6 +29,7 @@ import { TxOutputFailure } from '../../src'; import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; function assertObjectRefsAreDifferent(obj1: unknown, obj2: unknown): void { expect(obj1).not.toBe(obj2); @@ -115,6 +116,7 @@ describe.each([ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }; diff --git a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts index d69910aaa68..f4eef6e2260 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts @@ -12,6 +12,7 @@ import { } from '../../src'; import { GreedyInputSelector, GreedySelectorProps, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; import { mockProviders as mocks } from '@cardano-sdk/util-dev'; import uniqBy from 'lodash/uniqBy'; @@ -112,6 +113,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }), txBuilderProviders, @@ -120,6 +122,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }) }; diff --git a/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts b/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts index 0ce0141afb0..c76b3578e88 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts @@ -12,6 +12,7 @@ import { } from '../../src'; import { HexBlob } from '@cardano-sdk/util'; import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; import { mockProviders as mocks } from '@cardano-sdk/util-dev'; import { roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import uniqBy from 'lodash/uniqBy'; @@ -174,6 +175,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }), txBuilderProviders, @@ -182,6 +184,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }) }; diff --git a/packages/tx-construction/test/tx-builder/mocks.ts b/packages/tx-construction/test/tx-builder/mocks.ts index fedc447e836..ff9d43e20d0 100644 --- a/packages/tx-construction/test/tx-builder/mocks.ts +++ b/packages/tx-construction/test/tx-builder/mocks.ts @@ -1,5 +1,6 @@ import { Cardano } from '@cardano-sdk/core'; import { ChangeAddressResolver, Selection } from '@cardano-sdk/input-selection'; +import { GreedyTxEvaluator } from '../../src'; export class MockChangeAddressResolver implements ChangeAddressResolver { async resolve(selection: Selection) { @@ -10,3 +11,13 @@ export class MockChangeAddressResolver implements ChangeAddressResolver { })); } } + +const getParams = (): Promise => + Promise.resolve({ + maxExecutionUnitsPerTransaction: { + memory: 100, + steps: 200 + } + } as unknown as Cardano.ProtocolParameters); + +export const mockTxEvaluator = new GreedyTxEvaluator(getParams); diff --git a/packages/wallet/src/Wallets/BaseWallet.ts b/packages/wallet/src/Wallets/BaseWallet.ts index 90291b986d0..ef6a7febea9 100644 --- a/packages/wallet/src/Wallets/BaseWallet.ts +++ b/packages/wallet/src/Wallets/BaseWallet.ts @@ -93,6 +93,7 @@ import { Cip30DataSignature } from '@cardano-sdk/dapp-connector'; import { Ed25519PublicKey, Ed25519PublicKeyHex } from '@cardano-sdk/crypto'; import { GenericTxBuilder, + GreedyTxEvaluator, InitializeTxProps, InitializeTxResult, InvalidConfigurationError, @@ -774,6 +775,7 @@ export class BaseWallet implements ObservableWallet { tip: () => this.#firstValueFromSettled(this.tip$), utxoAvailable: () => this.#firstValueFromSettled(this.utxo.available$) }, + txEvaluator: new GreedyTxEvaluator(() => this.#firstValueFromSettled(this.protocolParameters$)), witnesser: this.witnesser }; }