From e27eeb73c7f9bf8171af9d66ef9c2991f9ce882a Mon Sep 17 00:00:00 2001 From: Alexander Nemish Date: Fri, 25 Sep 2026 18:45:22 +0200 Subject: [PATCH 1/2] chore(deps): scalus 1.3.0 1.3.0 adds `balancer.balanceTx`, which settles a transaction's fee, execution units and change output against each other. Nothing in this repo uses it yet; the bump is separate so the version change and the code that depends on it can be reviewed apart. Everything already here is unaffected: OfflineEvaluatorScalus and @meshsdk/scalus-emulator use APIs 1.2.1 already had, and 1.3.0 removes nothing. The lockfile needs regenerating once 1.3.0 is published: npm install --package-lock-only --- package-lock.json | 31 ++++++++++++++-------- packages/mesh-core-cst/package.json | 2 +- packages/mesh-scalus-emulator/package.json | 2 +- 3 files changed, 22 insertions(+), 13 deletions(-) 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-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-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": "*", From f03d5a52d94f6307f82692ea0534bb5e7dcab8ea Mon Sep 17 00:00:00 2001 From: Alexander Nemish Date: Fri, 25 Sep 2026 18:46:01 +0200 Subject: [PATCH 2/2] feat(tx-builder): settle fee, execution units and change together 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. Today that is worked around by hard-coding ExUnits or applying a multiplier. This adds an optional balancer that runs the loop instead: const txHex = await new MeshTxBuilder({ fetcher, balancer: new ScalusTxBalancer("preprod") }) .txOut(bob, [{ unit: "lovelace", quantity: "25000000" }]) .changeAddress(alice) .selectUtxosFrom(utxos) .complete(); ITxBalancer sits in @meshsdk/common beside IEvaluator, so the mechanism is not tied to Scalus. ScalusTxBalancer implements it. Coin selection and change placement stay with the builder, which passes the index of the output that should absorb the difference; an implementation may edit only that output's lovelace, the fee and the redeemers. Opt-in: a builder with no balancer serializes exactly as before, which a test pins. Mesh's own Protocol maps onto the record Scalus reads field for field, so there is no Blockfrost JSON round trip, and UTxOs go over as CIP-30 [input, output] pairs that toTxUnspentOutput already produces. Needs scalus 1.3.0, whose balancer.balanceTx is marked experimental: it may change shape in any release. Nothing else in the repo depends on it. --- .../mesh-common/src/interfaces/balancer.ts | 30 ++++ packages/mesh-common/src/interfaces/index.ts | 1 + packages/mesh-core-cst/jest.config.ts | 6 +- packages/mesh-core-cst/jest.scalus.config.ts | 2 +- .../offline-providers/balance-tx-scalus.ts | 101 ++++++++++++++ .../src/offline-providers/index.ts | 2 + .../offline-providers/scalus-tx-balancer.ts | 62 +++++++++ .../test/balance-tx-scalus.test.ts | 130 ++++++++++++++++++ .../test/scalus-tx-balancer.test.ts | 92 +++++++++++++ .../src/mesh-tx-builder/index.ts | 28 +++- 10 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 packages/mesh-common/src/interfaces/balancer.ts create mode 100644 packages/mesh-core-cst/src/offline-providers/balance-tx-scalus.ts create mode 100644 packages/mesh-core-cst/src/offline-providers/scalus-tx-balancer.ts create mode 100644 packages/mesh-core-cst/test/balance-tx-scalus.test.ts create mode 100644 packages/mesh-core-cst/test/scalus-tx-balancer.test.ts 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/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-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 =