Skip to content

Commit 3737871

Browse files
rhyslbwclaude
andcommitted
feat(tx-construction): add fluent minting to the transaction builder
Add `TxBuilder.addMint({ policy, assets, redeemer })` so consumers can mint or burn assets under a policy without dropping to the lower-level `initializeTx`. The policy script is attached to the witness set, the asset quantities are added to the body, and (for Plutus policies) the redeemer is included and its execution units evaluated during `build()`. Previously `GenericTxBuilder` had no minting support — `mint` was never forwarded to `initializeTx`. This also fixes redeemer index reconciliation for non-spend purposes: builder redeemers are stamped with a sentinel index by `buildRedeemers`, but `reorgRedeemers` only reindexed spend redeemers, so mint/withdrawal/certificate redeemers reached `updateRedeemers` with an index the evaluator could not match. Sentinel-indexed non-spend redeemers are now reindexed by their position within their purpose; redeemers that already carry a concrete index are left untouched. Coverage: unit tests for plutus mint, burn (negative quantity), native-script mint without a redeemer, and multi-policy redeemer reindexing; plus an e2e test that mints through the builder and submits. The `web-extension` remote-api property map gains the new method. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 824fed7 commit 3737871

6 files changed

Lines changed: 175 additions & 33 deletions

File tree

packages/e2e/test/wallet_epoch_0/PersonalWallet/mint.test.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { BaseWallet, FinalizeTxProps } from '@cardano-sdk/wallet';
1+
import { BaseWallet } from '@cardano-sdk/wallet';
22
import { Cardano, nativeScriptPolicyId } from '@cardano-sdk/core';
3-
import { InitializeTxProps } from '@cardano-sdk/tx-construction';
43
import { KeyRole, util } from '@cardano-sdk/key-management';
54
import {
65
bip32Ed25519Factory,
@@ -66,38 +65,16 @@ describe('PersonalWallet/mint', () => {
6665

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

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

73-
const txProps: InitializeTxProps = {
74-
mint: tokens,
75-
outputs: new Set([
76-
{
77-
address: walletAddress,
78-
value: {
79-
assets: tokens,
80-
coins
81-
}
82-
}
83-
]),
84-
signingOptions: {
85-
extraSigners: [alicePolicySigner]
86-
},
87-
witness: { scripts: [policyScript] }
88-
};
89-
90-
const unsignedTx = await wallet.initializeTx(txProps);
91-
92-
const finalizeProps: FinalizeTxProps = {
93-
signingOptions: {
94-
extraSigners: [alicePolicySigner]
95-
},
96-
tx: unsignedTx,
97-
witness: { scripts: [policyScript] }
98-
};
99-
100-
const signedTx = await wallet.finalizeTx(finalizeProps);
10178
const [, txFoundInHistory] = await submitAndConfirm(wallet, signedTx, 1);
10279

10380
expect(txFoundInHistory.id).toEqual(signedTx.id);

packages/tx-construction/src/input-selection/selectionConstraints.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ const updateRedeemers = (
7575
return result;
7676
};
7777

78+
/**
79+
* `buildRedeemers` stamps builder-provided redeemers with a sentinel index. Spend redeemers are
80+
* reindexed by input position elsewhere; the remaining (mint/withdrawal/certificate/...) redeemers
81+
* carrying the sentinel are reindexed here by their position within their purpose, matching the
82+
* canonical ordering in which they were added (e.g. mint redeemers in sorted-policy order).
83+
* Redeemers that already carry a concrete index are left untouched.
84+
*/
85+
const reindexSentinelRedeemers = (redeemers: Cardano.Redeemer[]): void => {
86+
const indexByPurpose = new Map<Cardano.RedeemerPurpose, number>();
87+
for (const redeemer of redeemers) {
88+
if (redeemer.index !== Number.MAX_SAFE_INTEGER) continue;
89+
const index = indexByPurpose.get(redeemer.purpose) ?? 0;
90+
redeemer.index = index;
91+
indexByPurpose.set(redeemer.purpose, index + 1);
92+
}
93+
};
94+
7895
const reorgRedeemers = (
7996
redeemerByType: RedeemersByType,
8097
witness: Cardano.Witness,
@@ -86,6 +103,8 @@ const reorgRedeemers = (
86103
// Lets remove all spend redeemers if any.
87104
redeemers = witness.redeemers.filter((redeemer) => redeemer.purpose !== Cardano.RedeemerPurpose.spend);
88105

106+
reindexSentinelRedeemers(redeemers);
107+
89108
// Add them back with the correct redeemer index.
90109
if (redeemerByType.spend) {
91110
for (const [key, value] of redeemerByType.spend) {

packages/tx-construction/src/tx-builder/TxBuilder.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Cardano, HandleProvider, HandleResolution, Serialization, inConwayEra,
1212
import {
1313
CustomizeCb,
1414
InsufficientRewardAccounts,
15+
MintProps,
1516
OutOfSyncRewardAccounts,
1617
OutputBuilderTxOut,
1718
PartialTx,
@@ -219,6 +220,32 @@ export class GenericTxBuilder implements TxBuilder {
219220
return this;
220221
}
221222

223+
addMint({ policy, assets, redeemer }: MintProps): TxBuilder {
224+
const policyHash = Serialization.Script.fromCore(policy).hash();
225+
this.#knownScripts.set(policyHash, policy);
226+
227+
const policyId = Cardano.PolicyId(policyHash);
228+
const mint = new Map(this.partialTxBody.mint);
229+
for (const [assetName, quantity] of assets) {
230+
mint.set(Cardano.AssetId.fromParts(policyId, assetName), quantity);
231+
}
232+
this.partialTxBody = { ...this.partialTxBody, mint };
233+
234+
if (redeemer) {
235+
this.#knownRedeemers.mint?.push({
236+
data: redeemer,
237+
executionUnits: { memory: 0, steps: 0 },
238+
// Position within the mint redeemers; reconciled to canonical (sorted-policy) order
239+
// during input selection. Callers minting under multiple policies should add them in
240+
// sorted-policy order.
241+
index: this.#knownRedeemers.mint.length,
242+
purpose: Cardano.RedeemerPurpose.mint
243+
});
244+
}
245+
246+
return this;
247+
}
248+
222249
addDatum(datum: Cardano.PlutusData): TxBuilder {
223250
const hash = Serialization.PlutusData.fromCore(datum).hash();
224251

@@ -412,6 +439,7 @@ export class GenericTxBuilder implements TxBuilder {
412439
customizeCb: this.#customizeCb,
413440
handleResolutions: this.#handleResolutions,
414441
inputs: new Set(this.#preSelectedInputs.values()),
442+
mint: this.partialTxBody.mint,
415443
options: {
416444
validityInterval: this.partialTxBody.validityInterval
417445
},

packages/tx-construction/src/tx-builder/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,15 @@ export type ScriptUnlockProps = {
266266
datum?: Cardano.PlutusData;
267267
};
268268

269+
export type MintProps = {
270+
/** The minting policy script. The policy id is derived from its hash. */
271+
policy: Cardano.Script;
272+
/** Asset names mapped to the quantity to mint (positive) or burn (negative), under the policy. */
273+
assets: Map<Cardano.AssetName, bigint>;
274+
/** Redeemer for Plutus minting policies; omit for native scripts. */
275+
redeemer?: Cardano.PlutusData;
276+
};
277+
269278
export interface TxBuilder {
270279
/**
271280
* @returns a partial transaction that has properties set by calling other TxBuilder methods. Does not validate the transaction.
@@ -289,6 +298,16 @@ export interface TxBuilder {
289298
*/
290299
addInput(input: Cardano.TxIn | Cardano.Utxo, scriptUnlockProps?: ScriptUnlockProps): TxBuilder;
291300

301+
/**
302+
* Mints (or burns) assets under a minting policy. The policy script is attached to the witness
303+
* set and, for Plutus policies, the redeemer is included and its execution units are evaluated
304+
* during `build()`. Call multiple times to mint under several policies.
305+
*
306+
* @param props - The minting policy, the assets (by name) to mint/burn, and an optional redeemer.
307+
* @returns {TxBuilder} The current TxBuilder instance for chaining.
308+
*/
309+
addMint(props: MintProps): TxBuilder;
310+
292311
/**
293312
* Adds a datum to the transaction.
294313
*

packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
22
import * as Crypto from '@cardano-sdk/crypto';
33
import { AddressType, Bip32Account, InMemoryKeyAgent, util } from '@cardano-sdk/key-management';
4-
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
4+
import { Cardano, Serialization, coalesceValueQuantities } from '@cardano-sdk/core';
55
import {
66
DatumResolver,
77
GenericTxBuilder,
@@ -282,6 +282,101 @@ describe('TxBuilder/plutusScripts', () => {
282282
expect(roundRobinRandomImprove).toHaveBeenCalled();
283283
});
284284

285+
it('can mint assets under a plutus policy with a redeemer', async () => {
286+
const assetName = Cardano.AssetName('4d794e4654'); // "MyNFT"
287+
const tx = await txBuilder
288+
.addMint({ assets: new Map([[assetName, 1n]]), policy: script, redeemer: 1n })
289+
.build()
290+
.inspect();
291+
292+
const policyId = Cardano.PolicyId(Serialization.Script.fromCore(script).hash());
293+
const assetId = Cardano.AssetId.fromParts(policyId, assetName);
294+
295+
expect(tx.body.mint?.get(assetId)).toEqual(1n);
296+
expect(tx.witness?.scripts?.some((s) => s === script)).toBeTruthy();
297+
expect(
298+
tx.witness?.redeemers?.some(
299+
(redeemer) => redeemer.purpose === Cardano.RedeemerPurpose.mint && redeemer.data === 1n
300+
)
301+
).toBeTruthy();
302+
expect(tx.body.scriptIntegrityHash).toBeDefined();
303+
});
304+
305+
it('can burn assets under a plutus policy (negative quantity)', async () => {
306+
const assetName = Cardano.AssetName('4d794e4654');
307+
const policyId = Cardano.PolicyId(Serialization.Script.fromCore(script).hash());
308+
const assetId = Cardano.AssetId.fromParts(policyId, assetName);
309+
310+
// The asset being burned must be available on an input.
311+
const assetHolderAddress = Cardano.PaymentAddress(
312+
'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6'
313+
);
314+
const inputWithAsset: Cardano.Utxo = [
315+
{
316+
address: assetHolderAddress,
317+
index: 0,
318+
txId: Cardano.TransactionId('aa21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0')
319+
},
320+
{
321+
address: assetHolderAddress,
322+
value: { assets: new Map([[assetId, 1n]]), coins: 10_000_000n }
323+
}
324+
];
325+
326+
const tx = await txBuilder
327+
.addInput(inputWithAsset)
328+
.addMint({ assets: new Map([[assetName, -1n]]), policy: script, redeemer: 1n })
329+
.build()
330+
.inspect();
331+
332+
expect(tx.body.mint?.get(assetId)).toEqual(-1n);
333+
expect(tx.body.scriptIntegrityHash).toBeDefined();
334+
});
335+
336+
it('can mint under a native policy without a redeemer', async () => {
337+
const nativePolicy: Cardano.NativeScript = {
338+
__type: Cardano.ScriptType.Native,
339+
kind: Cardano.NativeScriptKind.RequireAllOf,
340+
scripts: []
341+
};
342+
const assetName = Cardano.AssetName('4e6174697665'); // "Native"
343+
const policyId = Cardano.PolicyId(Serialization.Script.fromCore(nativePolicy).hash());
344+
const assetId = Cardano.AssetId.fromParts(policyId, assetName);
345+
346+
const tx = await txBuilder
347+
.addMint({ assets: new Map([[assetName, 5n]]), policy: nativePolicy })
348+
.build()
349+
.inspect();
350+
351+
expect(tx.body.mint?.get(assetId)).toEqual(5n);
352+
expect(tx.witness?.scripts?.some((s) => s === nativePolicy)).toBeTruthy();
353+
// Native minting has no redeemer and therefore no script-data-hash.
354+
expect(tx.witness?.redeemers?.some((r) => r.purpose === Cardano.RedeemerPurpose.mint)).toBeFalsy();
355+
expect(tx.body.scriptIntegrityHash).toBeUndefined();
356+
});
357+
358+
it('reindexes mint redeemers across multiple policies', async () => {
359+
const policyA = script;
360+
const policyB: Cardano.PlutusScript = {
361+
__type: Cardano.ScriptType.Plutus,
362+
bytes: HexBlob('4d01000033222220051200120011'),
363+
version: Cardano.PlutusLanguageVersion.V2
364+
};
365+
366+
const tx = await txBuilder
367+
.addMint({ assets: new Map([[Cardano.AssetName('41'), 1n]]), policy: policyA, redeemer: 1n })
368+
.addMint({ assets: new Map([[Cardano.AssetName('42'), 1n]]), policy: policyB, redeemer: 2n })
369+
.build()
370+
.inspect();
371+
372+
const mintRedeemers = (tx.witness?.redeemers ?? []).filter((r) => r.purpose === Cardano.RedeemerPurpose.mint);
373+
expect(mintRedeemers).toHaveLength(2);
374+
// Reindexed to canonical sequential positions, each with evaluated execution units.
375+
expect(mintRedeemers.map((r) => r.index).sort()).toEqual([0, 1]);
376+
expect(mintRedeemers.every((r) => r.executionUnits.steps > 0)).toBeTruthy();
377+
expect(new Set(mintRedeemers.map((r) => r.data))).toEqual(new Set([1n, 2n]));
378+
});
379+
285380
it('can point the redeemer to the right input', async () => {
286381
const tx = await txBuilder
287382
.addInput(

packages/web-extension/src/observableWallet/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export const txBuilderProperties: RemoteApiProperties<Omit<TxBuilder, 'customize
4747
getApiProperties: () => txBuilderProperties,
4848
propType: RemoteApiPropertyType.ApiFactory
4949
},
50+
addMint: {
51+
getApiProperties: () => txBuilderProperties,
52+
propType: RemoteApiPropertyType.ApiFactory
53+
},
5054
addOutput: {
5155
getApiProperties: () => txBuilderProperties,
5256
propType: RemoteApiPropertyType.ApiFactory

0 commit comments

Comments
 (0)