Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 9 additions & 32 deletions packages/e2e/test/wallet_epoch_0/PersonalWallet/mint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { BaseWallet, FinalizeTxProps } from '@cardano-sdk/wallet';
import { BaseWallet } from '@cardano-sdk/wallet';
import { Cardano, nativeScriptPolicyId } from '@cardano-sdk/core';
import { InitializeTxProps } from '@cardano-sdk/tx-construction';
import { KeyRole, util } from '@cardano-sdk/key-management';
import {
bip32Ed25519Factory,
Expand Down Expand Up @@ -66,38 +65,16 @@ describe('PersonalWallet/mint', () => {

const policyId = nativeScriptPolicyId(policyScript);
const assetId = Cardano.AssetId(`${policyId}`); // skip asset name
const tokens = new Map([[assetId, 1n]]);

const walletAddress = (await firstValueFrom(wallet.addresses$))[0].address;
// Mint through the fluent transaction builder. The minted asset is balanced into the wallet's
// change; the policy requires Alice's signature, supplied as an extra signer.
const { tx: signedTx } = await wallet
.createTxBuilder()
.addMint({ assets: new Map([[Cardano.AssetName(''), 1n]]), policy: policyScript })
.extraSigners([alicePolicySigner])
.build()
.sign();

const txProps: InitializeTxProps = {
mint: tokens,
outputs: new Set([
{
address: walletAddress,
value: {
assets: tokens,
coins
}
}
]),
signingOptions: {
extraSigners: [alicePolicySigner]
},
witness: { scripts: [policyScript] }
};

const unsignedTx = await wallet.initializeTx(txProps);

const finalizeProps: FinalizeTxProps = {
signingOptions: {
extraSigners: [alicePolicySigner]
},
tx: unsignedTx,
witness: { scripts: [policyScript] }
};

const signedTx = await wallet.finalizeTx(finalizeProps);
const [, txFoundInHistory] = await submitAndConfirm(wallet, signedTx, 1);

expect(txFoundInHistory.id).toEqual(signedTx.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('PersonalWallet/phase2validation', () => {
}
}
]),
redeemersByType: { [Cardano.RedeemerPurpose.mint]: [scriptRedeemer] },
redeemersByType: { mint: new Map([[policyId, scriptRedeemer]]) },
scriptIntegrityHash: scriptDataHash,
witness: { redeemers: [scriptRedeemer], scripts: [alwaysFailScript] }
};
Expand Down
198 changes: 137 additions & 61 deletions packages/tx-construction/src/input-selection/selectionConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,26 @@ export const MAX_U64 = 18_446_744_073_709_551_615n;

export type BuildTx = (selection: SelectionSkeleton) => Promise<Cardano.Tx>;

/**
* Redeemers grouped by purpose. Each purpose is keyed by the on-chain identifier its redeemer
* points at, so the canonical (ledger) redeemer index can be derived from the transaction body
* during {@link computeMinimumCost}:
*
* - `spend` by `txId#index`
* - `mint` by minting `PolicyId`
* - `withdrawal` by script `RewardAccount`
* - `vote` by `Voter`
*
* `certificate` and `propose` redeemers are sequence-indexed in transaction-body order, which is
* what the ledger expects for those purposes, so they remain ordered lists.
*/
export interface RedeemersByType {
spend?: Map<TxIdWithIndex, Cardano.Redeemer>;
mint?: Array<Cardano.Redeemer>;
mint?: Map<Cardano.PolicyId, Cardano.Redeemer>;
withdrawal?: Map<Cardano.RewardAccount, Cardano.Redeemer>;
vote?: Array<{ voter: Cardano.Voter; redeemer: Cardano.Redeemer }>;
certificate?: Array<Cardano.Redeemer>;
withdrawal?: Array<Cardano.Redeemer>;
propose?: Array<Cardano.Redeemer>;
vote?: Array<Cardano.Redeemer>;
}

export interface DefaultSelectionConstraintsProps {
Expand All @@ -33,75 +46,135 @@ export interface DefaultSelectionConstraintsProps {
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
};
/** Lexicographic comparison of hex-encoded byte strings. */
const compareHex = (a: string, b: string): number => (a === b ? 0 : a < b ? -1 : 1);

for (const txEval of evaluation) {
const redeemers = redeemersMap[txEval.purpose];
if (!redeemers) throw new Error(`No redeemers found for ${txEval.purpose} purpose`);
const rewardAccountCredential = (rewardAccount: Cardano.RewardAccount): Cardano.Credential =>
Cardano.Address.fromBech32(rewardAccount).asReward()!.getPaymentCredential();

let knownRedeemer;
if (txEval.purpose === Cardano.RedeemerPurpose.spend) {
const input = txInputs[txEval.index];
/** Ledger ordering group for a voter constructor: ConstitutionalCommittee < DRep < StakePool. */
const voterGroup = (voter: Cardano.Voter): number => {
switch (voter.__typename) {
case Cardano.VoterType.ccHotKeyHash:
case Cardano.VoterType.ccHotScriptHash:
return 0;
case Cardano.VoterType.dRepKeyHash:
case Cardano.VoterType.dRepScriptHash:
return 1;
default:
return 2;
}
};

knownRedeemer = (redeemers as Map<string, Cardano.Redeemer>).get(`${input.txId}#${input.index}`);
// Within a constructor the ledger Credential Ord places ScriptHash before KeyHash.
const credentialScriptFirstRank = (type: Cardano.CredentialType): number =>
type === Cardano.CredentialType.ScriptHash ? 0 : 1;

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[];
const compareVoters = (a: Cardano.Voter, b: Cardano.Voter): number =>
voterGroup(a) - voterGroup(b) ||
credentialScriptFirstRank(a.credential.type) - credentialScriptFirstRank(b.credential.type) ||
compareHex(a.credential.hash, b.credential.hash);

knownRedeemer = redeemerList.find((redeemer) => redeemer.index === txEval.index);
const votersEqual = (a: Cardano.Voter, b: Cardano.Voter): boolean =>
a.__typename === b.__typename && a.credential.hash === b.credential.hash;

if (!knownRedeemer) throw new Error(`Known Redeemer not found for index ${txEval.index}`);
}
const reindexed = (
redeemer: Cardano.Redeemer,
purpose: Cardano.RedeemerPurpose,
index: number,
executionUnits: Cardano.ExUnits
): Cardano.Redeemer => ({ data: redeemer.data, executionUnits, index, purpose });

result.push({ ...knownRedeemer, executionUnits: txEval.budget });
}
const spendRedeemers = (
redeemersByType: RedeemersByType,
sortedInputs: Array<Cardano.TxIn>,
executionUnits: Cardano.ExUnits
): Cardano.Redeemer[] =>
[...(redeemersByType.spend ?? [])].map(([key, redeemer]) => {
const index = sortedInputs.findIndex((input) => key === `${input.txId}#${input.index}`);
if (index < 0) throw new Error(`Spend redeemer input not found in transaction: ${key}`);
return reindexed(redeemer, Cardano.RedeemerPurpose.spend, index, executionUnits);
});

return result;
const mintRedeemers = (
redeemersByType: RedeemersByType,
body: Cardano.TxBody,
executionUnits: Cardano.ExUnits
): Cardano.Redeemer[] => {
const policyIds = [...new Set([...(body.mint?.keys() ?? [])].map(Cardano.AssetId.getPolicyId))].sort(compareHex);
return [...(redeemersByType.mint ?? [])].map(([policyId, redeemer]) => {
const index = policyIds.indexOf(policyId);
if (index < 0) throw new Error(`Mint redeemer policy not present in transaction mint: ${policyId}`);
return reindexed(redeemer, Cardano.RedeemerPurpose.mint, index, executionUnits);
});
};

const reorgRedeemers = (
redeemerByType: RedeemersByType,
witness: Cardano.Witness,
txInputs: Array<Cardano.TxIn>
const withdrawalRedeemers = (
redeemersByType: RedeemersByType,
body: Cardano.TxBody,
executionUnits: Cardano.ExUnits
): 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}`);
// Node quirk: withdrawal script credentials are processed before key-hash ones, so key-hash
// withdrawals carry no redeemer and don't shift the index. Index = rank among script reward
// accounts, sorted by credential hash.
const scriptRewardAccounts = (body.withdrawals ?? [])
.map((withdrawal) => withdrawal.stakeAddress)
.filter((rewardAccount) => rewardAccountCredential(rewardAccount).type === Cardano.CredentialType.ScriptHash)
.sort((a, b) => compareHex(rewardAccountCredential(a).hash, rewardAccountCredential(b).hash));
return [...(redeemersByType.withdrawal ?? [])].map(([rewardAccount, redeemer]) => {
const index = scriptRewardAccounts.indexOf(rewardAccount);
if (index < 0) throw new Error(`Withdrawal redeemer is not a script withdrawal in transaction: ${rewardAccount}`);
return reindexed(redeemer, Cardano.RedeemerPurpose.withdrawal, index, executionUnits);
});
};

value.index = index;
const voteRedeemers = (
redeemersByType: RedeemersByType,
body: Cardano.TxBody,
executionUnits: Cardano.ExUnits
): Cardano.Redeemer[] => {
const sortedVoters = (body.votingProcedures ?? []).map(({ voter }) => voter).sort(compareVoters);
return (redeemersByType.vote ?? []).map(({ voter, redeemer }) => {
const index = sortedVoters.findIndex((candidate) => votersEqual(candidate, voter));
if (index < 0) throw new Error('Vote redeemer voter not present in transaction voting procedures');
return reindexed(redeemer, Cardano.RedeemerPurpose.vote, index, executionUnits);
});
};

redeemers.push({ ...value });
}
}
}
/**
* Assigns each redeemer its canonical ledger index, derived from the position of the item it
* unlocks within the transaction body's canonically-ordered collections (mirroring the node's
* `getAlonzoScriptsNeeded`). Certificate/proposal redeemers keep the caller-provided index, which
* is the item's position in transaction-body order. `executionUnits` are seeded from the (already
* max-budgeted) builder redeemers so evaluation has headroom; concrete units are merged back in
* via {@link mergeExecutionUnits}.
*/
const assignCanonicalIndices = (
redeemersByType: RedeemersByType,
body: Cardano.TxBody,
sortedInputs: Array<Cardano.TxIn>,
executionUnits: Cardano.ExUnits
): Cardano.Redeemer[] => [
...spendRedeemers(redeemersByType, sortedInputs, executionUnits),
...mintRedeemers(redeemersByType, body, executionUnits),
...withdrawalRedeemers(redeemersByType, body, executionUnits),
...voteRedeemers(redeemersByType, body, executionUnits),
...(redeemersByType.certificate ?? []).map((redeemer) =>
reindexed(redeemer, Cardano.RedeemerPurpose.certificate, redeemer.index, executionUnits)
),
...(redeemersByType.propose ?? []).map((redeemer) =>
reindexed(redeemer, Cardano.RedeemerPurpose.propose, redeemer.index, executionUnits)
)
];

return redeemers;
};
/** Merges evaluated execution units back onto the canonically-indexed redeemers by (purpose, index). */
const mergeExecutionUnits = (redeemers: Cardano.Redeemer[], evaluation: TxEvaluationResult): Cardano.Redeemer[] =>
redeemers.map((redeemer) => {
const evaluated = evaluation.find(
(txEval) => txEval.purpose === redeemer.purpose && txEval.index === redeemer.index
);
return evaluated ? { ...redeemer, executionUnits: evaluated.budget } : redeemer;
});

export const computeMinimumCost =
(
Expand All @@ -116,9 +189,12 @@ export const computeMinimumCost =
const txIns = utxos.map((utxo) => utxo[0]).sort(sortTxIn);

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);
// The builder redeemers carry a sentinel index and a max-budget; reassign canonical indices
// from the transaction body, evaluate, then merge the concrete execution units back in.
const maxBudget = tx.witness.redeemers[0].executionUnits;
const indexed = assignCanonicalIndices(redeemersByType, tx.body, txIns, maxBudget);
tx.witness.redeemers = indexed;
tx.witness.redeemers = mergeExecutionUnits(indexed, await txEvaluator.evaluate(tx, utxos));
}

return {
Expand Down
39 changes: 35 additions & 4 deletions packages/tx-construction/src/tx-builder/TxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Cardano, HandleProvider, HandleResolution, Serialization, inConwayEra,
import {
CustomizeCb,
InsufficientRewardAccounts,
MintProps,
OutOfSyncRewardAccounts,
OutputBuilderTxOut,
PartialTx,
Expand Down Expand Up @@ -142,11 +143,11 @@ export class GenericTxBuilder implements TxBuilder {
#knownInlineDatums = new Set<Cardano.DatumHash>();
#knownRedeemers: RedeemersByType = {
certificate: new Array<Cardano.Redeemer>(),
mint: new Array<Cardano.Redeemer>(),
mint: new Map<Cardano.PolicyId, Cardano.Redeemer>(),
propose: new Array<Cardano.Redeemer>(),
spend: new Map<TxIdWithIndex, Cardano.Redeemer>(),
vote: new Array<Cardano.Redeemer>(),
withdrawal: new Array<Cardano.Redeemer>()
vote: new Array<{ voter: Cardano.Voter; redeemer: Cardano.Redeemer }>(),
withdrawal: new Map<Cardano.RewardAccount, Cardano.Redeemer>()
};

#unresolvedInputs = new Array<Cardano.TxIn>();
Expand Down Expand Up @@ -219,6 +220,31 @@ export class GenericTxBuilder implements TxBuilder {
return this;
}

addMint({ policy, assets, redeemer }: MintProps): TxBuilder {
const policyHash = Serialization.Script.fromCore(policy).hash();
this.#knownScripts.set(policyHash, policy);

const policyId = Cardano.PolicyId(policyHash);
const mint = new Map(this.partialTxBody.mint);
for (const [assetName, quantity] of assets) {
mint.set(Cardano.AssetId.fromParts(policyId, assetName), quantity);
}
this.partialTxBody = { ...this.partialTxBody, mint };

if (redeemer) {
// Keyed by policy id; the canonical redeemer index is derived from the sorted minting
// policies of the transaction body during input selection.
this.#knownRedeemers.mint?.set(policyId, {
data: redeemer,
executionUnits: { memory: 0, steps: 0 },
index: 0,
purpose: Cardano.RedeemerPurpose.mint
});
}

return this;
}

addDatum(datum: Cardano.PlutusData): TxBuilder {
const hash = Serialization.PlutusData.fromCore(datum).hash();

Expand Down Expand Up @@ -337,7 +363,11 @@ export class GenericTxBuilder implements TxBuilder {
);
const auxiliaryData = this.partialAuxiliaryData && { ...this.partialAuxiliaryData };
const extraSigners = this.partialExtraSigners && [...this.partialExtraSigners];
const partialSigningOptions = this.partialSigningOptions && { ...this.partialSigningOptions, extraSigners };
// Include extra signers in the signing options used for fee estimation, even when no
// other signing options were set — otherwise their witnesses are added at sign() but
// not accounted for in the fee, producing an insufficient-fee transaction.
const partialSigningOptions =
this.partialSigningOptions || extraSigners ? { ...this.partialSigningOptions, extraSigners } : undefined;

if (this.partialAuxiliaryData) {
this.partialTxBody.auxiliaryDataHash = Cardano.computeAuxiliaryDataHash(this.partialAuxiliaryData);
Expand Down Expand Up @@ -412,6 +442,7 @@ export class GenericTxBuilder implements TxBuilder {
customizeCb: this.#customizeCb,
handleResolutions: this.#handleResolutions,
inputs: new Set(this.#preSelectedInputs.values()),
mint: this.partialTxBody.mint,
options: {
validityInterval: this.partialTxBody.validityInterval
},
Expand Down
1 change: 1 addition & 0 deletions packages/tx-construction/src/tx-builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './initializeTx';
export * from './types';
export * from './TxBuilder';
export * from './GreedyTxEvaluator';
export * from './utils';
Loading
Loading