diff --git a/package-lock.json b/package-lock.json index ec8d6aa42..93a208360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13129,15 +13129,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/scalus": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/scalus/-/scalus-1.2.1.tgz", - "integrity": "sha512-zpYcEpZUTgC4Jb2XFZ9XcULS04RmPpiYeYiac9+maOtHf1hCdLUQp26Y0Eoyhil6O4BtcS48fPqZffdS8pgIvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -15590,7 +15581,7 @@ "blakejs": "^1.2.1", "bn.js": "^5.2.0", "hash.js": "^1.1.7", - "scalus": "^1.2.1" + "scalus": "^1.3.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -15602,6 +15593,15 @@ "typescript": "^5.3.3" } }, + "packages/mesh-core-cst/node_modules/scalus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-1.3.0.tgz", + "integrity": "sha512-D2Z+wuQS1hEr2r2nWRkhvI2If9n8PdKH8/PpnP+ijo+mkDDKOprU5GvjccH9F7LBEfj8uCY73wBc2wSWu+MA2w==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "packages/mesh-scalus-emulator": { "name": "@meshsdk/scalus-emulator", "version": "1.9.1", @@ -15609,7 +15609,7 @@ "dependencies": { "@meshsdk/common": "1.9.1", "@meshsdk/core-cst": "1.9.1", - "scalus": "^1.2.1" + "scalus": "^1.3.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -15619,6 +15619,15 @@ "typescript": "^5.3.3" } }, + "packages/mesh-scalus-emulator/node_modules/scalus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-1.3.0.tgz", + "integrity": "sha512-D2Z+wuQS1hEr2r2nWRkhvI2If9n8PdKH8/PpnP+ijo+mkDDKOprU5GvjccH9F7LBEfj8uCY73wBc2wSWu+MA2w==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", "version": "1.9.1", diff --git a/packages/mesh-common/src/interfaces/balancer.ts b/packages/mesh-common/src/interfaces/balancer.ts new file mode 100644 index 000000000..d41034c02 --- /dev/null +++ b/packages/mesh-common/src/interfaces/balancer.ts @@ -0,0 +1,30 @@ +import { Protocol } from "../types"; +import { UTxO } from "../types/utxo"; + +/** + * Settles the numbers of a built transaction that depend on each other: the execution units of + * every redeemer, the fee, and the lovelace of the change output. + * + * `MeshTxBuilder` evaluates scripts before it adds the change output, so the units it declares can + * be wrong for the transaction it finally builds: a script sees the whole transaction, and an extra + * output can change what it costs, which changes the fee, which changes the change. A balancer runs + * that as a loop until the three agree. + * + * Coin selection and change placement stay with the builder. An implementation is given the output + * that should absorb the difference and may edit only its lovelace, the fee, and the redeemers. + */ +export interface ITxBalancer { + /** + * @param tx - the serialized transaction, as CBOR hex + * @param utxos - every UTxO the transaction's inputs, collateral and reference inputs name + * @param params - the protocol parameters the transaction is built against + * @param changeOutputIndex - which output absorbs the difference, counting from 0 + * @returns the balanced transaction as CBOR hex + */ + balanceTx( + tx: string, + utxos: UTxO[], + params: Protocol, + changeOutputIndex: number, + ): Promise; +} diff --git a/packages/mesh-common/src/interfaces/index.ts b/packages/mesh-common/src/interfaces/index.ts index f141dce8b..d6d0e98f7 100644 --- a/packages/mesh-common/src/interfaces/index.ts +++ b/packages/mesh-common/src/interfaces/index.ts @@ -5,4 +5,5 @@ export * from "./submitter"; export * from "./serializer"; export * from "./signer"; export * from "./evaluator"; +export * from "./balancer"; export * from "./wallet"; diff --git a/packages/mesh-core-cst/jest.config.ts b/packages/mesh-core-cst/jest.config.ts index 1e7ddeecd..78df036be 100644 --- a/packages/mesh-core-cst/jest.config.ts +++ b/packages/mesh-core-cst/jest.config.ts @@ -2,7 +2,11 @@ import type { Config } from "jest"; const jestConfig: Config = { clearMocks: true, - testPathIgnorePatterns: ["/offline-evaluator-scalus.test.ts$"], + testPathIgnorePatterns: [ + "/offline-evaluator-scalus.test.ts$", + "/balance-tx-scalus.test.ts$", + "/scalus-tx-balancer.test.ts$", + ], maxWorkers: 1, testEnvironment: "node", testMatch: ["**/packages/**/*.test.ts"], diff --git a/packages/mesh-core-cst/jest.scalus.config.ts b/packages/mesh-core-cst/jest.scalus.config.ts index 92e25277c..81127b828 100644 --- a/packages/mesh-core-cst/jest.scalus.config.ts +++ b/packages/mesh-core-cst/jest.scalus.config.ts @@ -3,7 +3,7 @@ import base from "./jest.config"; export default { ...base, testPathIgnorePatterns: [], - testMatch: ["**/offline-evaluator-scalus.test.ts"], + testMatch: ["**/offline-evaluator-scalus.test.ts", "**/balance-tx-scalus.test.ts", "**/scalus-tx-balancer.test.ts"], preset: "ts-jest/presets/default-esm", extensionsToTreatAsEsm: [".ts"], transform: { "^.+\\.[jt]s?$": ["ts-jest", { useESM: true }] }, diff --git a/packages/mesh-core-cst/package.json b/packages/mesh-core-cst/package.json index 014c42d67..f86b0ca4d 100644 --- a/packages/mesh-core-cst/package.json +++ b/packages/mesh-core-cst/package.json @@ -53,7 +53,7 @@ "blakejs": "^1.2.1", "bn.js": "^5.2.0", "hash.js": "^1.1.7", - "scalus": "^1.2.1" + "scalus": "^1.3.0" }, "overrides": { "@cardano-sdk/crypto": { diff --git a/packages/mesh-core-cst/src/offline-providers/balance-tx-scalus.ts b/packages/mesh-core-cst/src/offline-providers/balance-tx-scalus.ts new file mode 100644 index 000000000..0cf517a62 --- /dev/null +++ b/packages/mesh-core-cst/src/offline-providers/balance-tx-scalus.ts @@ -0,0 +1,101 @@ +import { + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + Network, + Protocol, + SLOT_CONFIG_NETWORK, + UTxO, +} from "@meshsdk/common"; + +import { toTxUnspentOutput } from "../utils"; + +/** + * Balances a transaction with Scalus: sets every redeemer's execution units, the fee, and the + * lovelace of one change output, until the three agree. + * + * Mesh evaluates scripts before it adds change outputs, so the units it declares can be wrong for + * the transaction it finally builds: a script sees the whole transaction, and an extra output can + * change what it costs. This runs the loop instead. Coin selection and change placement stay with + * `MeshTxBuilder`; only the numbers move, in the output you name. + * + * @param tx - the built transaction, as CBOR hex + * @param utxos - every UTxO the transaction's inputs, collateral and reference inputs name + * @param params - the protocol parameters, as `fetchProtocolParameters` returns them + * @param network - which network, for slot arithmetic + * @param changeOutputIndex - which output absorbs the difference, counting from 0 + * @param protocolMajorVersion - the ledger protocol version to cost against + * @param costModels - cost models by Plutus version, defaulting to the mainnet ones + * @param extraSigners - hex key hashes for signatures the transaction does not name, such as the + * keys a native script requires. Inferred signers are always included as well + * @returns the balanced transaction as CBOR hex, ready to sign + */ +export const balanceTxWithScalus = async ( + tx: string, + utxos: UTxO[], + params: Protocol, + network: Network, + changeOutputIndex: number, + protocolMajorVersion: number, + costModels: number[][] = [ + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + ], + extraSigners: string[] = [], +): Promise => { + // Keep native import() in the CJS build: Scalus is ESM-only. + const { balancer } = await import("scalus"); + + // CIP-30 `transaction_unspent_output` is `[input, output]`, which is what Scalus takes. + const pairs = utxos.map((utxo) => toTxUnspentOutput(utxo).toCbor().toString()); + + const slots = SLOT_CONFIG_NETWORK[network]; + + const balanced = balancer.balanceTx( + tx, + pairs, + { + zeroTime: slots.zeroTime, + zeroSlot: slots.zeroSlot, + slotLength: slots.slotLength, + }, + scalusParams(params, protocolMajorVersion, costModels), + changeOutputIndex, + extraSigners, + ); + return Buffer.from(balanced).toString("hex"); +}; + +/** + * Mesh's `Protocol` as the record Scalus reads. Every number is one Mesh already holds, so there is + * no JSON round trip: the deposits Scalus does not consult while balancing stay at zero. + */ +const scalusParams = ( + p: Protocol, + protocolMajorVersion: number, + costModels: number[][], +) => ({ + txFeePerByte: p.minFeeA, + txFeeFixed: p.minFeeB, + maxTxSize: p.maxTxSize, + maxValueSize: p.maxValSize, + stakeAddressDeposit: p.keyDeposit, + stakePoolDeposit: p.poolDeposit, + dRepDeposit: 0, + govActionDeposit: 0, + utxoCostPerByte: p.coinsPerUtxoSize, + priceMemory: p.priceMem, + priceSteps: p.priceStep, + maxTxExecutionMemory: Number(p.maxTxExMem), + maxTxExecutionSteps: Number(p.maxTxExSteps), + collateralPercentage: p.collateralPercent, + maxCollateralInputs: p.maxCollateralInputs, + minFeeRefScriptCostPerByte: p.minFeeRefScriptCostPerByte, + protocolMajorVersion, + costModels: { + PlutusV1: costModels[0], + PlutusV2: costModels[1], + PlutusV3: costModels[2], + }, +}); diff --git a/packages/mesh-core-cst/src/offline-providers/index.ts b/packages/mesh-core-cst/src/offline-providers/index.ts index ccd348e2a..e0cb232b7 100644 --- a/packages/mesh-core-cst/src/offline-providers/index.ts +++ b/packages/mesh-core-cst/src/offline-providers/index.ts @@ -1 +1,3 @@ export * from "./offline-evaluator-scalus"; +export * from "./balance-tx-scalus"; +export * from "./scalus-tx-balancer"; diff --git a/packages/mesh-core-cst/src/offline-providers/scalus-tx-balancer.ts b/packages/mesh-core-cst/src/offline-providers/scalus-tx-balancer.ts new file mode 100644 index 000000000..5672e2ea9 --- /dev/null +++ b/packages/mesh-core-cst/src/offline-providers/scalus-tx-balancer.ts @@ -0,0 +1,62 @@ +import { + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + ITxBalancer, + Network, + Protocol, + UTxO, +} from "@meshsdk/common"; + +import { balanceTxWithScalus } from "./balance-tx-scalus"; + +/** + * An {@link ITxBalancer} backed by Scalus, for `new MeshTxBuilder({ balancer })`. + * + * With one configured, `complete()` no longer hands back a transaction whose execution units were + * computed before the change output existed. The builder still chooses the inputs and places the + * change; this settles the fee, the units and that output's lovelace against each other. + * + * ```ts + * const balancer = new ScalusTxBalancer("preprod"); + * const txHex = await new MeshTxBuilder({ fetcher, evaluator, balancer }) + * .txOut(bob, [{ unit: "lovelace", quantity: "25000000" }]) + * .changeAddress(alice) + * .selectUtxosFrom(utxos) + * .complete(); + * ``` + */ +export class ScalusTxBalancer implements ITxBalancer { + constructor( + private readonly network: Network, + /** The ledger protocol version to cost against. Mainnet runs 11 (van Rossem). */ + private readonly protocolMajorVersion: number = 11, + private readonly costModels: number[][] = [ + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + ], + /** + * Hex key hashes for signatures the transaction does not name, such as the keys a native + * script requires. Inferred signers are always included as well. + */ + private readonly extraSigners: string[] = [], + ) {} + + balanceTx = async ( + tx: string, + utxos: UTxO[], + params: Protocol, + changeOutputIndex: number, + ): Promise => + balanceTxWithScalus( + tx, + utxos, + params, + this.network, + changeOutputIndex, + this.protocolMajorVersion, + this.costModels, + this.extraSigners, + ); +} diff --git a/packages/mesh-core-cst/test/balance-tx-scalus.test.ts b/packages/mesh-core-cst/test/balance-tx-scalus.test.ts new file mode 100644 index 000000000..5dde6107a --- /dev/null +++ b/packages/mesh-core-cst/test/balance-tx-scalus.test.ts @@ -0,0 +1,130 @@ +import { MeshTxBuilder, MeshWallet, OfflineFetcher } from "@meshsdk/core"; +import { DEFAULT_PROTOCOL_PARAMETERS } from "@meshsdk/common"; + +import { balanceTxWithScalus } from "../src/offline-providers"; + +// A 24-word test mnemonic. The address it derives owns the UTxO below. +const MNEMONIC = ("abandon ".repeat(23) + "art").split(" "); +const BOB = "addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw"; +const PV = 11; + +/** The fee of a transaction, read out of its CBOR without a decoder library. */ +const feeOf = async (txHex: string): Promise => { + const { Serialization } = await import("@cardano-sdk/core"); + return Serialization.Transaction.fromCbor( + Serialization.TxCBOR(txHex), + ).body().fee(); +}; + +/** + * The same transaction with its fee set to zero, as a draft has it. + * + * Balancing a transaction that already carries Mesh's fee proves nothing: Scalus never lowers a fee + * below the one it was given, so it would hand back the same number without computing anything. + * Starting from zero, the fee it returns is one it worked out. + */ +const withZeroFee = async (txHex: string): Promise => { + const { Serialization } = await import("@cardano-sdk/core"); + const tx = Serialization.Transaction.fromCbor(Serialization.TxCBOR(txHex)); + const body = tx.body(); + body.setFee(0n); + tx.setBody(body); + return tx.toCbor().toString(); +}; + +describe("balanceTxWithScalus", () => { + let wallet: MeshWallet; + let alice: string; + let utxos: Awaited>; + + beforeAll(async () => { + const fetcher = new OfflineFetcher(); + wallet = new MeshWallet({ + networkId: 0, + fetcher, + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + alice = await wallet.getChangeAddress(); + utxos = [ + { + input: { txHash: "11".repeat(32), outputIndex: 0 }, + output: { + address: alice, + amount: [{ unit: "lovelace", quantity: "1000000000" }], + }, + }, + ]; + }); + + const build = async () => + await new MeshTxBuilder({ params: DEFAULT_PROTOCOL_PARAMETERS }) + .txOut(BOB, [{ unit: "lovelace", quantity: "25000000" }]) + .changeAddress(alice) + .selectUtxosFrom(utxos) + .complete(); + + it("balances a transaction Mesh built, and agrees with Mesh's own fee", async () => { + const tx = await build(); + const draft = await withZeroFee(tx); + expect(await feeOf(draft)).toBe(0n); + + const balanced = await balanceTxWithScalus( + draft, + utxos, + DEFAULT_PROTOCOL_PARAMETERS, + "preprod", + 1, // MeshTxBuilder appends the change output last + PV, + ); + + expect(typeof balanced).toBe("string"); + + // Scalus computes the ledger's own minimum. Mesh carries a byte or two of slack, so Scalus's + // fee is at most Mesh's and within a few bytes of it. A mistake mapping Mesh's `Protocol` onto + // the record Scalus reads would move this by thousands of lovelace, not by a few. + const meshFee = await feeOf(tx); + const scalusFee = await feeOf(balanced); + expect(scalusFee).toBeGreaterThan(0n); + expect(scalusFee).toBeLessThanOrEqual(meshFee); + expect(Number(meshFee - scalusFee)).toBeLessThan( + 10 * DEFAULT_PROTOCOL_PARAMETERS.minFeeA, + ); + }); + + it("is still signable, so the body it returns is well formed", async () => { + const tx = await build(); + const balanced = await balanceTxWithScalus( + tx, + utxos, + DEFAULT_PROTOCOL_PARAMETERS, + "preprod", + 1, + PV, + ); + const signed = await wallet.signTx(balanced, true); + expect(signed.length).toBeGreaterThan(balanced.length); + }); + + it("charges for an extra signer a native script would need", async () => { + const tx = await build(); + const plain = await balanceTxWithScalus( + tx, utxos, DEFAULT_PROTOCOL_PARAMETERS, "preprod", 1, PV, undefined, [], + ); + const withSigner = await balanceTxWithScalus( + tx, utxos, DEFAULT_PROTOCOL_PARAMETERS, "preprod", 1, PV, undefined, + ["aa".repeat(28)], + ); + // One vkey witness is about 101 bytes, so the fee rises by roughly 101 * minFeeA. + const rise = Number((await feeOf(withSigner)) - (await feeOf(plain))); + expect(rise).toBeGreaterThan(50 * DEFAULT_PROTOCOL_PARAMETERS.minFeeA); + expect(rise).toBeLessThan(200 * DEFAULT_PROTOCOL_PARAMETERS.minFeeA); + }); + + it("rejects a change index that is not an output", async () => { + const tx = await build(); + await expect( + balanceTxWithScalus(tx, utxos, DEFAULT_PROTOCOL_PARAMETERS, "preprod", 99, PV), + ).rejects.toThrow(/changeOutputIndex/); + }); +}); diff --git a/packages/mesh-core-cst/test/scalus-tx-balancer.test.ts b/packages/mesh-core-cst/test/scalus-tx-balancer.test.ts new file mode 100644 index 000000000..b7f74dd91 --- /dev/null +++ b/packages/mesh-core-cst/test/scalus-tx-balancer.test.ts @@ -0,0 +1,92 @@ +import { + DEFAULT_PROTOCOL_PARAMETERS, + ITxBalancer, + UTxO, +} from "@meshsdk/common"; +import { MeshTxBuilder, MeshWallet, OfflineFetcher } from "@meshsdk/core"; + +import { ScalusTxBalancer } from "../src/offline-providers"; + +// A 24-word test mnemonic. The address it derives owns the UTxO below. +const MNEMONIC = ("abandon ".repeat(23) + "art").split(" "); +const BOB = "addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw"; + +const feeOf = async (txHex: string): Promise => { + const { Serialization } = await import("@cardano-sdk/core"); + return Serialization.Transaction.fromCbor(Serialization.TxCBOR(txHex)) + .body() + .fee(); +}; + +describe("MeshTxBuilder with a Scalus balancer", () => { + let wallet: MeshWallet; + let alice: string; + let utxos: UTxOLike[]; + + type UTxOLike = { + input: { txHash: string; outputIndex: number }; + output: { address: string; amount: { unit: string; quantity: string }[] }; + }; + + beforeAll(async () => { + wallet = new MeshWallet({ + networkId: 0, + fetcher: new OfflineFetcher(), + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + alice = await wallet.getChangeAddress(); + utxos = [ + { + input: { txHash: "11".repeat(32), outputIndex: 0 }, + output: { + address: alice, + amount: [{ unit: "lovelace", quantity: "1000000000" }], + }, + }, + ]; + }); + + const build = async (balancer?: ITxBalancer) => + await new MeshTxBuilder({ params: DEFAULT_PROTOCOL_PARAMETERS, balancer }) + .txOut(BOB, [{ unit: "lovelace", quantity: "25000000" }]) + .changeAddress(alice) + .selectUtxosFrom(utxos as never) + .complete(); + + it("hands the built transaction to the balancer and returns its answer", async () => { + const calls: { utxos: UTxO[]; changeOutputIndex: number }[] = []; + const recording: ITxBalancer = { + balanceTx: async (_tx, utxos, _params, changeOutputIndex) => { + calls.push({ utxos, changeOutputIndex }); + return "balanced"; + }, + }; + + const result = await build(recording); + + expect(calls).toHaveLength(1); + // Coin selection appends the change output after the payment, so it is output 1. + expect(calls[0]!.changeOutputIndex).toBe(1); + // The selected input arrives with its address and value: a balancer infers signers from them. + expect(calls[0]!.utxos).toContainEqual(utxos[0]); + expect(result).toBe("balanced"); + }); + + it("leaves the transaction signable, so the body it produced is well formed", async () => { + const balanced = await build(new ScalusTxBalancer("preprod")); + const signed = await wallet.signTx(balanced, true); + expect(signed.length).toBeGreaterThan(balanced.length); + }); + + it("charges for extra signers the transaction does not name", async () => { + const none = await build(new ScalusTxBalancer("preprod")); + const withSigner = await build( + new ScalusTxBalancer("preprod", 11, undefined, ["aa".repeat(28)]), + ); + // One vkey witness is about 101 bytes, so the fee rises by roughly 101 * minFeeA. + const rise = Number((await feeOf(withSigner)) - (await feeOf(none))); + expect(rise).toBeGreaterThan(50 * DEFAULT_PROTOCOL_PARAMETERS.minFeeA); + expect(rise).toBeLessThan(200 * DEFAULT_PROTOCOL_PARAMETERS.minFeeA); + }); +}); diff --git a/packages/mesh-scalus-emulator/package.json b/packages/mesh-scalus-emulator/package.json index a3dc49dbd..17da710b7 100644 --- a/packages/mesh-scalus-emulator/package.json +++ b/packages/mesh-scalus-emulator/package.json @@ -30,7 +30,7 @@ "dependencies": { "@meshsdk/common": "1.9.1", "@meshsdk/core-cst": "1.9.1", - "scalus": "^1.2.1" + "scalus": "^1.3.0" }, "devDependencies": { "@meshsdk/configs": "*", diff --git a/packages/mesh-transaction/src/mesh-tx-builder/index.ts b/packages/mesh-transaction/src/mesh-tx-builder/index.ts index 77db15266..e0442dbcf 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/index.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/index.ts @@ -10,6 +10,8 @@ import { DEFAULT_V2_COST_MODEL_LIST, DEFAULT_V3_COST_MODEL_LIST, IEvaluator, + ITxBalancer, + txInToUtxo, IFetcher, IMeshTxSerializer, ISubmitter, @@ -53,6 +55,7 @@ export interface MeshTxBuilderOptions { fetcher?: IFetcher; submitter?: ISubmitter; evaluator?: IEvaluator; + balancer?: ITxBalancer; serializer?: IMeshTxSerializer; selector?: IInputSelector; isHydra?: boolean; @@ -66,6 +69,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { fetcher?: IFetcher; submitter?: ISubmitter; evaluator?: IEvaluator; + balancer?: ITxBalancer; txHex: string = ""; verbose: boolean; protected queriedTxHashes: Set = new Set(); @@ -78,6 +82,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { fetcher, submitter, evaluator, + balancer, params, isHydra = false, verbose = false, @@ -86,6 +91,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { if (fetcher) this.fetcher = fetcher; if (submitter) this.submitter = submitter; if (evaluator) this.evaluator = evaluator; + if (balancer) this.balancer = balancer; if (params) this.protocolParams(params); if (serializer) { this.serializer = serializer; @@ -244,8 +250,26 @@ export class MeshTxBuilder extends MeshTxBuilderCore { this._protocolParams, ); - this.txHex = txHex; - return txHex; + // Coin selection has already appended the change output to the body, so it is the last one. A + // balancer settles the fee, the execution units and that output's lovelace together; without + // one the units were computed before the change output existed. + const balanced = this.balancer + ? await this.balancer.balanceTx( + txHex, + // Every input the transaction names has to resolve, not only the ones a script reads: + // `inputsForEvaluation` is empty when nothing evaluates. + [ + ...this.meshTxBuilderBody.inputs.map((i) => txInToUtxo(i.txIn)), + ...this.meshTxBuilderBody.collaterals.map((c) => txInToUtxo(c.txIn)), + ...Object.values(this.meshTxBuilderBody.inputsForEvaluation), + ], + this._protocolParams, + this.meshTxBuilderBody.outputs.length - 1, + ) + : txHex; + + this.txHex = balanced; + return balanced; }; selectUtxos =