Skip to content

Commit

Permalink
fixup! feat: tx-builder now supports spending from plutus scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
AngelCastilloB committed May 27, 2024
1 parent 7af0127 commit b23b6a4
Show file tree
Hide file tree
Showing 19 changed files with 310 additions and 164 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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(''));
Expand Down Expand Up @@ -464,7 +464,9 @@ export class MultiSigWallet {
}
};
},
protocolParameters
protocolParameters,
redeemersByType: {},
txEvaluator: new GreedyTxEvaluator(() => this.#networkInfoProvider.protocolParameters())
});

const implicitCoin = Cardano.util.computeImplicitCoin(protocolParameters, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,21 @@ const adjustOutputsForFee = async (
outputs: Set<Cardano.TxOut>,
changeOutputs: Array<Cardano.TxOut>,
currentFee: bigint
): Promise<{ fee: bigint; change: Array<Cardano.TxOut>; feeAccountedFor: boolean }> => {
): Promise<{
fee: bigint;
change: Array<Cardano.TxOut>;
feeAccountedFor: boolean;
redeemers?: Array<Cardano.Redeemer>;
}> => {
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);

Expand All @@ -76,7 +81,7 @@ const adjustOutputsForFee = async (
}
}

return { change: [...updatedOutputs], fee, feeAccountedFor };
return { change: [...updatedOutputs], fee, feeAccountedFor, redeemers };
};

/**
Expand Down
15 changes: 10 additions & 5 deletions packages/input-selection/src/RoundRobinRandomImprove/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cardano.Lovelace>;
type EstimateTxFeeWithOriginalOutputs = (
utxo: Cardano.Utxo[],
change: Cardano.Value[]
) => Promise<{ fee: Cardano.Lovelace; redeemers?: Array<Cardano.Redeemer> }>;

interface ChangeComputationArgs {
utxoSelection: UtxoSelection;
Expand All @@ -31,6 +34,7 @@ interface ChangeComputationResult {
inputs: Cardano.Utxo[];
change: Cardano.Value[];
fee: Cardano.Lovelace;
redeemers?: Array<Cardano.Redeemer>;
}

const getLeftoverAssets = (utxoSelected: Cardano.Utxo[], uniqueTxAssetIDs: Cardano.AssetId[]) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -391,7 +395,7 @@ export const computeChangeAndAdjustForFee = async ({

const finalSelection = computeChangeBundles({
computeMinimumCoinQuantity,
fee,
fee: estimatedFee.fee,
implicitValue,
outputValues,
uniqueTxAssetIDs,
Expand All @@ -406,8 +410,9 @@ export const computeChangeAndAdjustForFee = async ({

return {
change: validateChangeBundles(changeBundles, tokenBundleSizeExceedsLimit),
fee,
fee: estimatedFee.fee,
inputs: utxoSelected,
redeemers: estimatedFee.redeemers,
remainingUTxO: utxoRemaining
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
7 changes: 6 additions & 1 deletion packages/input-selection/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ export interface SelectionResult {
* has removed values to pay for entries in the requested output set.
*/
remainingUTxO: Set<Cardano.Utxo>;

/** The list of redeemers and their execution cost. */
redeemers?: Array<Cardano.Redeemer>;
}

/**
* @returns minimum transaction fee in Lovelace.
*/
export type EstimateTxFee = (selectionSkeleton: SelectionSkeleton) => Promise<Cardano.Lovelace>;
export type EstimateTxFee = (
selectionSkeleton: SelectionSkeleton
) => Promise<{ fee: bigint; redeemers?: Array<Cardano.Redeemer> }>;

/**
* @returns true if token bundle size exceeds it's maximum size limit.
Expand Down
2 changes: 1 addition & 1 deletion packages/input-selection/test/util/selectionConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
19 changes: 18 additions & 1 deletion packages/tx-construction/src/createTransactionInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -53,16 +55,31 @@ 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<CreateTxInternalsProps, 'inputSelection'> & {
bodyPreInputSelection: TxBodyPreInputSelection;
witness?: Cardano.Witness;
scriptVersions?: Set<Cardano.PlutusLanguageVersion>;
}): Cardano.TxBodyWithHash => {
const body: Cardano.TxBody = {
...bodyPreInputSelection,
fee: inputSelection.fee,
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 {
Expand Down
110 changes: 106 additions & 4 deletions packages/tx-construction/src/input-selection/selectionConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,124 @@ 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<Cardano.Tx>;

export interface RedeemersByType {
spend?: Map<TxIdWithIndex, Cardano.Redeemer>;
mint?: Array<Cardano.Redeemer>;
certificate?: Array<Cardano.Redeemer>;
withdrawal?: Array<Cardano.Redeemer>;
propose?: Array<Cardano.Redeemer>;
vote?: Array<Cardano.Redeemer>;
}

export interface DefaultSelectionConstraintsProps {
protocolParameters: ProtocolParametersForInputSelection;
buildTx: BuildTx;
redeemersByType: RedeemersByType;
txEvaluator: TxEvaluator;
}

const updateRedeemers = (
evaluation: TxEvaluationResult,
redeemersByType: RedeemersByType,
txInputs: Array<Cardano.TxIn>
): Array<Cardano.Redeemer> => {
const result: Array<Cardano.Redeemer> = [];

// Mapping between purpose and redeemersByType
const redeemersMap: { [key in Cardano.RedeemerPurpose]?: Map<string, Cardano.Redeemer> | 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<string, Cardano.Redeemer>).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.TxIn>
): 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 =
(
{
minFeeCoefficient,
minFeeConstant,
prices
}: Pick<ProtocolParametersRequiredByInputSelection, 'minFeeCoefficient' | 'minFeeConstant' | 'prices'>,
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 =
Expand Down Expand Up @@ -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(
Expand All @@ -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)
};
Expand Down
Loading

0 comments on commit b23b6a4

Please sign in to comment.